Developer docs

Get started

Requests and responses

In short

Send JSON to https://app.clik.so/api with your key in the Authorization header. Every answer comes back in the same JSON envelope, with your data in data. Long lists come in pages.

You need

An API key
See Authentication.
A server
Your own backend, script or job. The API does not answer calls from a web page.

Base URL

Every path in these docs comes after this base URL:

Base URL
https://app.clik.so/api

So GET /links means GET https://app.clik.so/api/links. Always use https.

Headers

  • Authorization: Bearer clik_live_… — on every request. See Authentication.
  • Content-Type: application/json — on every request that has a body.
  • Workspace-Id: <workspace id> — optional. If you send it, it must match the key's workspace.

Response envelope

Every success with a body looks like this. Your data is in data. The status field repeats the HTTP status.

Success
{
  "status": 200,
  "success": true,
  "message": "Success",
  "data": {
    "id": "6b7c8d9e-0f1a-4b2c-8d3e-4f5a6b7c8d9e",
    "name": "Campaigns"
  }
}

Every error looks like this. Read error.code in your code, not error.message. The message is for people, and its words can change.

Error
{
  "status": 422,
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "name must be 20 characters or fewer"
  }
}

Some errors add an error.details object with more facts. The Errors page shows each one.

Empty responses

A delete answers 204 No Content with an empty body. Do not try to parse it as JSON. A 204 means it worked.

Ids and dates

  • Every id is a UUID, such as 3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c.
  • Every date is an ISO 8601 string in UTC, such as 2026-10-09T09:30:00.000Z.
  • When you send a date, such as expires_at, use the same format.

Change things with PUT

To change a link, folder or tag, send PUT with only the fields you want to change. Fields you leave out stay as they are. There is no PATCH for single items.

curl -X PUT "https://app.clik.so/api/links/3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c" \
  -H "Authorization: Bearer $CLIKSO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "destination_url": "https://example.com/autumn-sale",
    "title": "Autumn sale",
    "expires_at": null
  }'
const res = await fetch("https://app.clik.so/api/links/3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.CLIKSO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "destination_url": "https://example.com/autumn-sale",
    "title": "Autumn sale",
    "expires_at": null
  }),
});

const data = await res.json();
console.log(res.status, data);

Uses fetch (Node 18+ or the browser). Set CLIKSO_API_KEY first.

import os
import requests

response = requests.put(
    "https://app.clik.so/api/links/3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c",
    headers={"Authorization": f"Bearer {os.environ['CLIKSO_API_KEY']}"},
    json={
        "destination_url": "https://example.com/autumn-sale",
        "title": "Autumn sale",
        "expires_at": None,
    },
)

print(response.status_code, response.json())

Uses requests. Set CLIKSO_API_KEY first.

<?php

$body = <<<'JSON'
{
  "destination_url": "https://example.com/autumn-sale",
  "title": "Autumn sale",
  "expires_at": null
}
JSON;

$ch = curl_init('https://app.clik.so/api/links/3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PUT',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('CLIKSO_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => $body,
]);

$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

echo $status, PHP_EOL, $response, PHP_EOL;

Uses the curl extension. Set CLIKSO_API_KEY first.

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

func main() {
	body := strings.NewReader(`{
  "destination_url": "https://example.com/autumn-sale",
  "title": "Autumn sale",
  "expires_at": null
}`)

	req, err := http.NewRequest("PUT", "https://app.clik.so/api/links/3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c", body)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CLIKSO_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	data, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, string(data))
}

Uses net/http. Set CLIKSO_API_KEY first.

require "net/http"
require "json"

uri = URI('https://app.clik.so/api/links/3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c')
request = Net::HTTP::Put.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("CLIKSO_API_KEY")}"
request["Content-Type"] = "application/json"
request.body = {
  'destination_url' => 'https://example.com/autumn-sale',
  'title' => 'Autumn sale',
  'expires_at' => nil
}.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

puts response.code, response.body

Uses Net::HTTP. Set CLIKSO_API_KEY first.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Main {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "destination_url": "https://example.com/autumn-sale",
              "title": "Autumn sale",
              "expires_at": null
            }
            """;

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://app.clik.so/api/links/3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c"))
            .header("Authorization", "Bearer " + System.getenv("CLIKSO_API_KEY"))
            .header("Content-Type", "application/json")
            .method("PUT", HttpRequest.BodyPublishers.ofString(body))
            .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println(response.statusCode());
        System.out.println(response.body());
    }
}

Uses java.net.http (Java 15+). Set CLIKSO_API_KEY first.

using System.Net.Http.Headers;

using var client = new HttpClient();
var request = new HttpRequestMessage(new HttpMethod("PUT"), "https://app.clik.so/api/links/3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c");
request.Headers.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("CLIKSO_API_KEY"));
request.Content = new StringContent(
    """
    {
      "destination_url": "https://example.com/autumn-sale",
      "title": "Autumn sale",
      "expires_at": null
    }
    """,
    System.Text.Encoding.UTF8,
    "application/json");

using var response = await client.SendAsync(request);
Console.WriteLine((int)response.StatusCode);
Console.WriteLine(await response.Content.ReadAsStringAsync());

Uses HttpClient (C# 11+). Set CLIKSO_API_KEY first.

#include <cstdlib>
#include <iostream>
#include <string>
#include <curl/curl.h>

static size_t collect(char* data, size_t size, size_t n, void* out) {
    static_cast<std::string*>(out)->append(data, size * n);
    return size * n;
}

int main() {
    CURL* curl = curl_easy_init();
    if (!curl) return 1;

    const char* key = std::getenv("CLIKSO_API_KEY");
    std::string auth = std::string("Authorization: Bearer ") + (key ? key : "");
    struct curl_slist* headers = curl_slist_append(nullptr, auth.c_str());
    headers = curl_slist_append(headers, "Content-Type: application/json");
    const char* body = R"json({
  "destination_url": "https://example.com/autumn-sale",
  "title": "Autumn sale",
  "expires_at": null
})json";
    std::string response;

    curl_easy_setopt(curl, CURLOPT_URL, "https://app.clik.so/api/links/3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c");
    curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT");
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
    curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, collect);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);

    CURLcode rc = curl_easy_perform(curl);
    long status = 0;
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
    curl_slist_free_all(headers);
    curl_easy_cleanup(curl);

    if (rc != CURLE_OK) {
        std::cerr << curl_easy_strerror(rc) << std::endl;
        return 1;
    }
    std::cout << status << "\n" << response << std::endl;
    return 0;
}

Uses libcurl. Set CLIKSO_API_KEY first.

Response 200
{
  "status": 200,
  "success": true,
  "message": "Link updated",
  "data": {
    "id": "3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c",
    "workspace_id": "8a1f2c3d-4b5e-4f60-9a7b-1c2d3e4f5a6b",
    "custom_domain_id": null,
    "domain": "clik.is",
    "domain_status": "default",
    "title": "Autumn sale",
    "description": "",
    "short_code": "summer-sale",
    "destination_url": "https://example.com/autumn-sale",
    "status": "active",
    "expires_at": null,
    "expired_redirect_url": null,
    "folder_id": "6b7c8d9e-0f1a-4b2c-8d3e-4f5a6b7c8d9e",
    "ab_testing": [],
    "geo_targeting": null,
    "device_targeting": null,
    "qr_metadata": null,
    "og_preview": null,
    "meta_pixel_id": null,
    "tiktok_pixel_id": null,
    "ga4_measurement_id": null,
    "gtm_container_id": null,
    "has_password": false,
    "click_count": 128,
    "created_by": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
    "created_at": "2026-10-09T09:30:00.000Z",
    "updated_at": "2026-10-10T08:15:00.000Z",
    "tags": [
      {
        "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "name": "launch",
        "color": "#3b82f6"
      }
    ],
    "creator": {
      "id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
      "display_name": "Asha Rao",
      "email": "asha@example.com",
      "photo_url": ""
    }
  }
}

Three rules for update bodies:

  • A field you leave out does not change.
  • A field set to null is cleared. Above, expires_at: null removes the expiry date. The rule objects work the same way: geo_targeting: null removes the country rules. Sending {} (or [] for ab_testing) clears them too.
  • On links, tags is the one exception. tags: null leaves the tags alone. Send tags: [] to remove every tag.

To change many links at once, use PATCH /links/bulk or DELETE /links/bulk. See Manage links.

Page through lists

List calls return one page at a time. You choose the page with limit and offset.

curl "https://app.clik.so/api/links?limit=20" \
  -H "Authorization: Bearer $CLIKSO_API_KEY"
const res = await fetch("https://app.clik.so/api/links?limit=20", {
  headers: {
    Authorization: `Bearer ${process.env.CLIKSO_API_KEY}`,
  },
});

const data = await res.json();
console.log(res.status, data);

Uses fetch (Node 18+ or the browser). Set CLIKSO_API_KEY first.

import os
import requests

response = requests.get(
    "https://app.clik.so/api/links",
    headers={"Authorization": f"Bearer {os.environ['CLIKSO_API_KEY']}"},
    params={
        "limit": 20,
    },
)

print(response.status_code, response.json())

Uses requests. Set CLIKSO_API_KEY first.

<?php

$ch = curl_init('https://app.clik.so/api/links?limit=20');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('CLIKSO_API_KEY'),
    ],
]);

$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

echo $status, PHP_EOL, $response, PHP_EOL;

Uses the curl extension. Set CLIKSO_API_KEY first.

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://app.clik.so/api/links?limit=20", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("CLIKSO_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	data, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, string(data))
}

Uses net/http. Set CLIKSO_API_KEY first.

require "net/http"

uri = URI('https://app.clik.so/api/links?limit=20')
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("CLIKSO_API_KEY")}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

puts response.code, response.body

Uses Net::HTTP. Set CLIKSO_API_KEY first.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Main {
    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://app.clik.so/api/links?limit=20"))
            .header("Authorization", "Bearer " + System.getenv("CLIKSO_API_KEY"))
            .GET()
            .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println(response.statusCode());
        System.out.println(response.body());
    }
}

Uses java.net.http (Java 15+). Set CLIKSO_API_KEY first.

using System.Net.Http.Headers;

using var client = new HttpClient();
var request = new HttpRequestMessage(new HttpMethod("GET"), "https://app.clik.so/api/links?limit=20");
request.Headers.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("CLIKSO_API_KEY"));

using var response = await client.SendAsync(request);
Console.WriteLine((int)response.StatusCode);
Console.WriteLine(await response.Content.ReadAsStringAsync());

Uses HttpClient (C# 11+). Set CLIKSO_API_KEY first.

#include <cstdlib>
#include <iostream>
#include <string>
#include <curl/curl.h>

static size_t collect(char* data, size_t size, size_t n, void* out) {
    static_cast<std::string*>(out)->append(data, size * n);
    return size * n;
}

int main() {
    CURL* curl = curl_easy_init();
    if (!curl) return 1;

    const char* key = std::getenv("CLIKSO_API_KEY");
    std::string auth = std::string("Authorization: Bearer ") + (key ? key : "");
    struct curl_slist* headers = curl_slist_append(nullptr, auth.c_str());
    std::string response;

    curl_easy_setopt(curl, CURLOPT_URL, "https://app.clik.so/api/links?limit=20");
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, collect);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);

    CURLcode rc = curl_easy_perform(curl);
    long status = 0;
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
    curl_slist_free_all(headers);
    curl_easy_cleanup(curl);

    if (rc != CURLE_OK) {
        std::cerr << curl_easy_strerror(rc) << std::endl;
        return 1;
    }
    std::cout << status << "\n" << response << std::endl;
    return 0;
}

Uses libcurl. Set CLIKSO_API_KEY first.

Response 200
{
  "status": 200,
  "success": true,
  "message": "Success",
  "data": [
    {
      "id": "3f6c1a2e-9b4d-4e8f-a1c2-7d9e0f1a2b3c",
      "workspace_id": "8a1f2c3d-4b5e-4f60-9a7b-1c2d3e4f5a6b",
      "custom_domain_id": null,
      "domain": "clik.is",
      "domain_status": "default",
      "title": "Summer sale",
      "description": "",
      "short_code": "summer-sale",
      "destination_url": "https://example.com/summer-sale",
      "status": "active",
      "expires_at": null,
      "expired_redirect_url": null,
      "folder_id": "6b7c8d9e-0f1a-4b2c-8d3e-4f5a6b7c8d9e",
      "ab_testing": [],
      "geo_targeting": null,
      "device_targeting": null,
      "qr_metadata": null,
      "og_preview": null,
      "meta_pixel_id": null,
      "tiktok_pixel_id": null,
      "ga4_measurement_id": null,
      "gtm_container_id": null,
      "has_password": false,
      "click_count": 128,
      "created_by": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
      "created_at": "2026-10-09T09:30:00.000Z",
      "updated_at": "2026-10-09T09:30:00.000Z",
      "tags": [
        {
          "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
          "name": "launch",
          "color": "#3b82f6"
        }
      ],
      "creator": {
        "id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
        "display_name": "Asha Rao",
        "email": "asha@example.com",
        "photo_url": ""
      }
    }
  ],
  "meta": {
    "total": 1,
    "limit": 20,
    "offset": 0,
    "total_is_capped": false,
    "has_more": false
  }
}

The meta object next to data tells you where you are:

  • limit — how many items you asked for. The default is 50. The most is 200; a bigger number is cut down, not refused.
  • offset — how many items were skipped.
  • has_more — true when there is at least one more page.
  • total — how many items match. It stops counting at 10,000.
  • total_is_capped — true when total stopped at that cap.

To read every link, add limit to offset and ask again until has_more is false. Do not stop at total, because it may be capped.

Search and sort

  • q searches the title, short code, destination URL and description.
  • sort_by picks the order: created_at (the default), title, status or click_count.
  • order is desc (newest first, the default) or asc.

Folders and tags come back as one full list, sorted by name. They have no paging.

Call from your server

The API does not send CORS headers, so a browser blocks calls from a web page. That is on purpose: a key in a web page is a key anyone can copy. Call the API from your server, then pass the result to your page.

What you get back

  • 200 with the envelope for reads and updates.
  • 201 with the envelope when you create something.
  • 204 with no body when you delete something.
  • 4xx or 5xx with the error envelope when something goes wrong.

Common errors

401UNAUTHORIZEDThe Authorization header is missing or the key is wrong.Send Authorization: Bearer clik_live_… with a key from Settings → API.
403FORBIDDENThe Workspace-Id header names a different workspace than the key.Check the key’s permission and your role. If you send Workspace-Id, it must match the key’s workspace.
404NOT_FOUNDThe id is not in this workspace, or it was deleted.Check the id. Ids from another workspace are never found.
422VALIDATION_ERRORThe body is not valid JSON, or a field is missing or wrong.Fix the field and send the request again.
429RATE_LIMITEDToo many requests this minute. Wait for the Retry-After seconds.Wait the number of seconds in the Retry-After header, then try again.

Good to know

  • New fields can appear in responses at any time. Ignore fields you do not know, and never fail on them.
  • message is for people. Your code should check success and error.code.
  • The API never returns a ready-made short URL. Build it from domain and short_code: https://clik.is/summer-sale.
  • There is no bulk create. Send one POST /links per link.