Developer docs

API reference / Links

Update a link

PUThttps://app.clik.so/api/links/{id}

In short

Changes the fields you send and leaves the rest alone. Send null to clear a field, for example expires_at to remove the expiry or geo_targeting to remove the country rules.

You need

API key
write permission (Write or Admin). See Authentication.
Role
Editor or admin in the key's workspace.
Plan
Any plan, including Free. Some fields need a paid plan — see plan-gated fields.

Example

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": ""
    }
  }
}

Other responses: 401 · 402 · 403 · 404 · 409 · 422 · 429

Path parameters

  • idstring · uuidrequired

    The link's id.

Request body

  • destination_urlstring · uri

    A new destination.

  • short_codestring

    A new code, by the same rules as on create (3–30 characters; / for a nested code on Starter and up, 402 UPGRADE_REQUIRED on Free). A blank value, or the current code, keeps the current one.

    3–30 characters · Pattern ^(?:[a-zA-Z0-9-]+|-*[a-zA-Z0-9][a-zA-Z0-9-]*(?:/-*[a-zA-Z0-9][a-zA-Z0-9-]*){1,4})$

  • domainstring or null

    Move the link to clik.is or another free Clikso domain.

  • custom_domain_idstring · uuid or null

    Move the link to one of your verified custom domains.

  • titlestring or null

    A new name. null clears it.

    Up to 60 characters

  • descriptionstring or null

    A new note. null clears it.

    Up to 250 characters

  • statusstring

    archived or expired turns the short URL off; active turns it back on.

    One of: active, archived, expired

  • folder_idstring · uuid or null

    A new folder. null takes the link out of its folder.

  • tagsarray of string · uuid or null

    Replaces all tags. [] removes them all; null or leaving it out keeps them.

  • passwordstring or null

    A new password. null removes it.

    Up to 100 characters

  • expires_atstring · date-time or null

    A new expiry. null removes it.

  • expired_redirect_urlstring · uri or null

    A new after-expiry destination. null clears it.

  • ab_testingarray of ABVariant or null

    New A/B variants. null or [] removes them.

  • ab_testing[].urlstring · urirequired

    Where this share of visitors goes.

  • ab_testing[].percentageintegerrequired

    This variant's weight. Weights are relative, so they do not have to add up to 100.

    0–100

  • geo_targetingGeoTargeting or any or null

    New country rules. null or {} removes them.

  • device_targetingDeviceTargeting or any or null

    New device rules. null or {} removes them.

  • og_previewOgPreview or any or null

    New social preview. null or {} removes it.

  • qr_metadataQrMetadata or any or null

    New QR style. null or {} resets it.

  • meta_pixel_idstring or null

    New Meta Pixel id. null clears it.

    Pattern ^\d{6,20}$

  • tiktok_pixel_idstring or null

    New TikTok Pixel id. null clears it.

    Pattern ^[A-Za-z0-9]{8,40}$

  • ga4_measurement_idstring or null

    New GA4 measurement id. null clears it.

    Pattern ^G-[A-Z0-9]{4,16}$

  • gtm_container_idstring or null

    New Tag Manager container id. null clears it.

    Pattern ^GTM-[A-Z0-9]{4,12}$

Plan-gated fields

Set one of these on a plan below the one listed and you get 402 UPGRADE_REQUIRED, with details.feature, details.field and details.required_plan. On an update, sending the value a link already has, or clearing it, is always allowed.

  • Password protection · passwordOnly a new, non-empty value. Keeping or clearing the current one is always allowed.Starter and up
  • Custom social preview · og_previewOnly a new, non-empty value. Keeping or clearing the current one is always allowed.Starter and up
  • Tracking pixels · meta_pixel_id, tiktok_pixel_id, ga4_measurement_id, gtm_container_idOnly a new, non-empty value.Starter and up
  • Geo-targeting · geo_targetingOnly a new, non-empty value. Keeping or clearing the current one is always allowed.Pro and up
  • Device targeting · device_targetingOnly a new, non-empty value. Keeping or clearing the current one is always allowed.Pro and up
  • A/B testing · ab_testingOnly a new, non-empty value. Keeping or clearing the current one is always allowed.Pro and up
  • nestedSlugs · short_codeOnly a new code with a `/` in it. Re-sending the link's current code, or changing to a code without `/`, is always allowed.Starter and up

What you get back

200 with the usual envelope — status, success, message, data. data has these fields:

  • idstring · uuidrequired

    The link's id.

  • workspace_idstring · uuidrequired

    The workspace the link belongs to.

  • custom_domain_idstring · uuid or null

    The custom domain's id, or null on a Clikso domain.

  • domainstringrequired

    The link's domain, like clik.is or links.example.com.

  • domain_statusstringrequired

    default on a Clikso domain, active on a live custom domain, deleted when the custom domain was removed and the link no longer works.

    One of: default, active, deleted

  • titlestringrequired

    A name for the link. Empty string when not set.

    Up to 60 characters

  • descriptionstringrequired

    A note about the link. Empty string when not set.

    Up to 250 characters

  • short_codestringrequired

    The part after the domain in the short URL: 3–30 letters, digits and hyphens, and on Starter and up / for a nested code like acme/launch (at most 5 parts). Up to 20 characters before 1.64.0.

    3–30 characters · Pattern ^(?:[a-zA-Z0-9-]+|-*[a-zA-Z0-9][a-zA-Z0-9-]*(?:/-*[a-zA-Z0-9][a-zA-Z0-9-]*){1,4})$

  • destination_urlstring · urirequired

    Where the short link sends visitors.

  • statusstringrequired

    active links redirect. archived and expired links do not.

    One of: active, archived, expired

  • expires_atstring · date-time or null

    When the link stops working, or null for never.

  • expired_redirect_urlstring · uri or null

    Where visitors go after the link expires, or null for Clikso's expired page.

  • folder_idstring · uuid or null

    The folder the link is in, or null.

  • ab_testingarray of ABVariant

    A/B split destinations. An empty array when not set, never null.

  • ab_testing[].urlstring · urirequired

    Where this share of visitors goes.

  • ab_testing[].percentageintegerrequired

    This variant's weight. Weights are relative, so they do not have to add up to 100.

    0–100

  • geo_targetingGeoTargeting or any or null

    Destination per country, or null.

  • device_targetingDeviceTargeting or any or null

    Destination per device, or null.

  • qr_metadataQrMetadata or any or null

    Saved QR style, or null.

  • og_previewOgPreview or any or null

    Custom social preview, or null.

  • meta_pixel_idstring or null

    Meta Pixel id, or null.

  • tiktok_pixel_idstring or null

    TikTok Pixel id, or null.

  • ga4_measurement_idstring or null

    Google Analytics 4 measurement id, or null.

  • gtm_container_idstring or null

    Google Tag Manager container id, or null.

  • has_passwordbooleanrequired

    true when visitors must enter a password. The password itself is never returned.

  • click_countintegerrequired

    Lifetime clicks on this link.

    Min 0

  • created_bystring · uuid

    The user id of the member who created it.

  • created_atstring · date-timerequired

    When the link was created.

  • updated_atstring · date-time or null

    When the link last changed.

  • tagsarray of TagRefrequired

    The link's tags. An empty array when it has none.

  • tags[].idstring · uuidrequired

    The tag's id.

  • tags[].namestringrequired

    The tag's name.

    Up to 10 characters

  • tags[].colorstring or nullrequired

    The tag's colour as #rrggbb, or an empty string.

  • creatorCreator or any or null

    The member who created the link, or null if unknown.

Common errors

401UNAUTHORIZEDThe API key is missing, malformed or unknown.
402UPGRADE_REQUIREDYou set a new value on a field your plan does not include, including a new short_code with a / on Free.
403FORBIDDENThe key only has Read permission, or its creator is a viewer in this workspace.
404NOT_FOUNDNo live link with this id, or custom_domain_id is not one of this workspace's domains.
409CONFLICTThe new short_code is already taken on that domain, or it is a reserved name on a Clikso domain.
422VALIDATION_ERRORA field is malformed or too long (details.field names it; a bad short_code is named in details.short_code), or a folder or tag id is not in this workspace.
422DESTINATION_BLOCKEDA URL in the body points at a link shortener or a blocked site.
429RATE_LIMITEDThe key went over its per-minute request limit.

Every code and what to do about it: Errors.

Good to know

  • This is PUT, not PATCH, but it behaves like a patch: fields you leave out keep their value.
  • null clears a plain field: password: null removes the password, folder_id: null takes the link out of its folder, expires_at: null removes the expiry.
  • null also clears the rule objects: geo_targeting, device_targeting, ab_testing, og_preview and qr_metadata (since 1.61.1; before that null was ignored for them). {} (or [] for ab_testing) still works too. Leaving a field out keeps it.
  • tags replaces the whole tag list. tags: [] removes every tag, and tags: null (or leaving it out) keeps the tags as they are.
  • You can change short_code and destination_url after creation. The old short URL stops working at once when the code changes. short_code follows the same rules as on create: 3 to 30 characters, and / for a nested code on Starter and up.
  • Setting status to archived or expired stops the short URL from working. Setting it back to active turns it on again.
  • Keep title to 60 characters and description to 250, expires_at a valid date and expired_redirect_url at most 2048 characters, or you get 422 with details.field naming the field.
  • If your plan was downgraded, a link keeps the paid settings it already has, and you can still edit its other fields. That includes a nested short_code: the link keeps working and you can send its current code back unchanged, but you can't change it to a different code with /.