ClickClacks server API

Send events and identify calls from your own servers: subscriptions, invoices, exports — things a visitor can’t fake and an ad blocker can’t hide.

Overview

One endpoint takes batches of track and identify items: POST https://app.clickclacks.io/api/v1/batch. It is served on the app host only; custom domains carry browser traffic, not server calls. The path carries the major version, and changes within v1 are additive only: new optional fields, item types and error codes. Ignore response fields you don’t know.

curl https://app.clickclacks.io/api/v1/batch \
  -H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"event":"Invoice paid","distinct_id":"user_8412","insert_id":"inv_2291","properties":{"amount_cents":4900}}]}'

Server keys

A server key (cks_live_…) belongs to one server Source and can only write events to it. Create one in onboarding or on the Source’s page. It is shown once; afterwards you see only its prefix, last four characters and when it was last used. Send it as Authorization: Bearer cks_live_…, and keep it in an environment variable such as CLICKCLACKS_SERVER_KEY.

POST /api/v1/batch

POST /api/v1/batch HTTP/1.1
Host: app.clickclacks.io
Authorization: Bearer cks_live_…
Content-Type: application/json
User-Agent: clickclacks-node/1.0.0

{
  "items": [
    {
      "type": "identify",
      "distinct_id": "user_8412",
      "anonymous_id": "per_k3J9sQ1xR2",
      "properties": { "plan": "pro", "company": "Birds Heard" }
    },
    {
      "event": "Subscription started",
      "distinct_id": "user_8412",
      "timestamp": "2026-09-25T14:03:11.402Z",
      "insert_id": "sub_started_7f3a91",
      "properties": {
        "plan": "pro",
        "$revenue": 49,
        "$currency": "USD",
        "$user_agent": "Mozilla/5.0 …",
        "$country": "DE"
      }
    }
  ]
}

Items are validated one by one. Valid items are accepted and invalid ones are reported with their index. The status is 202 when at least one item was accepted or dropped by policy, and 400 with all_items_invalid when none was. request_id identifies the request if you contact support.

HTTP/1.1 202 Accepted
Content-Type: application/json
RateLimit-Policy: 1000;w=1, 5000;w=10

{
  "accepted": 498,
  "dropped": [{ "index": 12, "reason": "bot_filtered" }],
  "errors": [
    { "index": 3, "code": "timestamp_too_old", "field": "timestamp",
      "message": "Timestamps older than 7 days must use /api/v1/import." }
  ],
  "request_id": "8c1f2d…-FRA"
}

Dry runs and strict mode

?validate=true answers 200 with every item exactly as it would be stored — resolved person, event id, enrichment and dropped keys — plus every error. Nothing is stored or metered, but the call counts toward the rate limits. ?strict=true refuses the whole batch on the first invalid item, which is useful in CI.

Items

FieldRule
type"track" (the default), "identify" or "group" (see groups).
eventRequired for track. 1–128 characters; names starting with $ are reserved.
distinct_idYour user id, 1–128 characters. Required unless anonymous_id is sent (and always for identify).
anonymous_idA browser’s per_… key, to attach the event to that browser’s person.
timestampISO 8601 with an offset, or epoch milliseconds. Defaults to arrival time.
insert_id1–80 characters of [A-Za-z0-9_-]. See idempotency.
session_idA ses_… id, to join a browser session. Without it, server events form one “server session” that never inflates session counts.
propertiesA JSON object: at most 8 KiB, 255 keys, nesting depth 5.

People. With anonymous_id, the event belongs to that browser. Otherwise distinct_id is matched to the person who already identified with it; if nobody has, a server person is created and a later browser identify with the same id joins it. identify items are free and set that person’s traits; the newest wins.

$ properties. You may send $ip, $user_agent, $country (ISO 3166-1 alpha-2), $current_url, $groups, $revenue, $currency and $insert_id. $user_agent is parsed into browser, OS and device, used for bot filtering, then dropped. $ip is kept only when the Source records IP addresses. Enrichment keys the server sets ($source_id, $browser, $lib…) are stripped silently; any other $ key is refused with reserved_property. $revenue and $currency are validated and stored now for Revenue later.

Groups (companies, workspaces, teams)

An event counts for a company when it carries $groups, an object of group type to group id: "$groups": { "company": "cmp_311" }. At most 5 types per event and per project; ids are strings of 1–255 characters. Events are never re-stamped, so an event sent without $groups never counts for a company. Use opaque ids (cmp_311), not domains or email addresses.

A project keeps at most 5 group types, in the order they first arrive. A sixth type is left off the event (the rest of the event is kept) and counted on the Source’s health as group_type_limit.

A group item sets a company’s traits (plan, seats, name). It has no person: send no distinct_id, anonymous_id or session_id. Group items are free, and the newest one for a group replaces all its traits, so send the full set you want shown, at least monthly, to keep the profile inside the project’s retention.

FieldRule
type"group"
group_typeRequired. ^[a-z0-9_]{1,64}$, for example company.
group_idRequired. A string of 1–255 characters (numbers are refused; the SDK converts them).
propertiesThe traits: at most 8 KiB, 255 keys, depth 5, and no $ keys.
timestamp, insert_idAs for track.
{ "items": [
  { "type": "group", "group_type": "company", "group_id": "cmp_311",
    "insert_id": "grp_cmp_311_2026_09_27",
    "properties": { "name": "Acme", "plan": "pro", "seats": 40 } },
  { "event": "Seats changed", "distinct_id": "user_8412",
    "properties": { "seats": 41, "$groups": { "company": "cmp_311" } } }
] }

Personal data. Company traits are shown to everyone in the organization, so a trait that looks like personal data (an email address, a phone number, or a key such as email, phone, ip, address or password) is left out by default and listed in the response’s warnings[] with code group_trait_dropped. With ?strict=true it is an item error instead. A Source setting, “Allow personal data in group traits”, keeps them.

Timestamps and late data

Server timestamps are trusted as sent from 7 days in the past to 10 minutes ahead. Older items are refused with timestamp_too_old; a historical import endpoint is coming. A late event lands in its own day and reports include it after the next refresh. Alerts and digests don’t re-evaluate windows they already sent.

Idempotency

Send an insert_id with every item (the Node SDK always does) and retry with the same one. Repeats within the same Source are accepted and stored once. The same insert_id twice in one request is refused for the later item (duplicate_insert_id). Without insert_id, a retry can count an event twice. If a response is lost after the batch was accepted, a retried batch may be metered twice; it is still stored once.

Limits

LimitValue
Items per request500
Body1 MiB uncompressed; Content-Encoding: gzip is accepted
Properties per item8 KiB, 255 keys, depth 5
Events per project, sustained1,000 per second (60,000 per minute)
Events per project, burst5,000 per second (50,000 per 10 seconds)
Requests per key100 per second

Over a limit, the API answers 429 rate_limited with Retry-After of 10 or 60 seconds, and nothing in that request is stored or metered. Limits are the same on every plan. They are counted per Cloudflare location, so a sender in many regions at once may briefly exceed them.

Errors

Every non-2xx response has the shape { "error": { "code", "message", "docs_url", "request_id" } }. Codes are stable; messages may change.

HTTP/1.1 429 Too Many Requests
Retry-After: 10

{ "error": { "code": "rate_limited",
  "message": "Event rate over 5,000/s for this project. Retry after 10 seconds.",
  "docs_url": "https://app.clickclacks.io/docs/api#rate-limits",
  "request_id": "8c1f…" } }

Request errors

HTTPCodeWhenRetry?
400invalid_jsonThe body isn’t JSON.No
400invalid_bodyThe top-level shape is wrong (items missing or not an array).No
400batch_emptyitems is empty.No
400batch_too_many_itemsMore than 500 items.No — split the batch
400all_items_invalidEvery item failed; errors[] lists each one.No
400strict_item_invalidWith ?strict=true, at least one item was invalid, so the whole batch was refused and nothing was stored; errors[] lists the invalid items.No — fix or drop the listed items and resend
400invalid_queryAn unknown or invalid query parameter.No
401missing_keyNo Authorization: Bearer header.No
401invalid_keyAn unknown, revoked or expired key, or a deleted Source or Organization.No
403browser_not_allowedThe request carries Origin or Sec-Fetch-*: it came from a browser.No
404not_foundAn unknown /api/v1/… path, or the API is not enabled for this project yet.No
405method_not_allowedNot POST.No
410api_version_retiredReserved for a retired API version.No
413payload_too_largeOver 1 MiB uncompressed.Split and resend
415unsupported_media_typeContent-Type isn’t application/json.No
415unsupported_encodingContent-Encoding isn’t gzip or identity.No
429rate_limitedA rate limit tripped. Retry-After is set.Yes, after Retry-After
500internal_errorSomething unexpected.Yes, with backoff
503collection_unavailableDurable acceptance failed. Retry-After: 5.Yes — resend the whole request

Item errors (in errors[])

CodeWhenRetry?
invalid_itemThe item isn’t a JSON object.No
invalid_item_typetype isn’t track, identify or group.No
item_type_not_yet_supportedalias, before it ships.No
missing_event_nameA track item has no event.No
invalid_event_nameevent isn’t 1–128 characters.No
reserved_event_nameevent starts with $ (except $agent_step and $agent_run), or is an internal name.No
missing_identityNeither distinct_id nor anonymous_id.No
invalid_distinct_iddistinct_id isn’t 1–128 characters, or has control characters.No
invalid_anonymous_idanonymous_id isn’t a per_… browser key.No
invalid_session_idsession_id isn’t a ses_… id.No
invalid_insert_idinsert_id isn’t 1–80 characters of [A-Za-z0-9_-].No
duplicate_insert_idThe same insert_id twice in one request; the later item is refused.No
invalid_timestamptimestamp isn’t ISO 8601 with an offset or epoch milliseconds.No
timestamp_too_oldOlder than 7 days. Use /api/v1/import (coming soon) for backfills.No
timestamp_in_futureMore than 10 minutes ahead of the server clock.No
timestamp_before_retentionImport only: older than the project’s raw-event retention.No
invalid_propertiesproperties isn’t a JSON object, or nests deeper than 5 levels.No
properties_too_largeOver 8 KiB of JSON after normalisation.No
too_many_propertiesMore than 255 keys.No
reserved_propertyA $ key the API doesn’t accept.No
invalid_reserved_propertyAn accepted $ key with a bad value, for example a $currency that isn’t ISO 4217, or a $groups that isn’t at most 5 types of ^[a-z0-9_]{1,64}$ to string ids.No
invalid_groupA group item whose group_type isn’t 1–64 characters of a-z, 0-9 and _, or whose group_id isn’t a string of 1–255 characters.No
group_trait_droppedWith ?strict=true only: a group trait looks like personal data (an email address, a phone number, or a key such as email, phone, ip, address, password). Without strict mode it is left out and reported in warnings[].No

Dropped items (in dropped[])

ReasonWhenRetry?
bot_filtered$user_agent is a known bot and the Source filters bots.No
collection_pausedThe item was valid, but collection is paused over the plan limit.No

Node SDK

@clickclacks/node has no dependencies and runs on Node 18+ and Cloudflare Workers. It batches, gzips, gives every item an insert_id, and retries network errors, 408, 429 and 5xx with exponential backoff, always honouring Retry-After. Other 4xx answers and per-item errors go to onError and are never retried.

npm install @clickclacks/node
import { ClickClacks } from '@clickclacks/node'

const clickclacks = new ClickClacks({
  key: process.env.CLICKCLACKS_SERVER_KEY!,     // cks_live_…
  flushAt: 100,                            // items per batch (max 500)
  flushInterval: 5_000,                    // ms; 0 disables the timer (edge mode)
  maxQueueSize: 10_000,
  onError: (error) => console.warn(error), // queue_full, item errors, final failures
})

clickclacks.track({ event: 'Subscription started', distinctId: 'user_8412', properties: { plan: 'pro' } })
clickclacks.identify({ distinctId: 'user_8412', anonymousId: 'per_k3J9sQ1xR2', properties: { plan: 'pro' } })
clickclacks.group({ groupType: 'company', groupId: 'cmp_311', properties: { name: 'Acme', plan: 'pro', seats: 40 } })
clickclacks.track({ event: 'Seats changed', distinctId: 'user_8412', properties: { seats: 41 }, groups: { company: 'cmp_311' } })

await clickclacks.flush()     // send everything queued now
await clickclacks.shutdown()  // flush, stop the timer, refuse new calls

Call shutdown() on SIGTERM so queued events are sent before the process exits. When the queue is full, new items are dropped and reported as queue_full; what’s already queued is kept.

// Cloudflare Workers and other edge runtimes: no timers between requests.
export default {
  async fetch(request, env, ctx) {
    const clickclacks = new ClickClacks({ key: env.CLICKCLACKS_SERVER_KEY, flushInterval: 0 })
    clickclacks.track({ event: 'Export finished', distinctId: 'user_8412' })
    clickclacks.flushWith(ctx) // ctx.waitUntil(clickclacks.flush())
    return new Response('ok')
  },
}

Python and Go

Any HTTP client works. Send the same insert_id on every retry and honour Retry-After.

Python

import os, uuid, requests

requests.post(
    "https://app.clickclacks.io/api/v1/batch",
    headers={"Authorization": f"Bearer {os.environ['CLICKCLACKS_SERVER_KEY']}"},
    json={"items": [{"event": "Invoice paid", "distinct_id": "user_8412",
                     "insert_id": uuid.uuid4().hex,
                     "properties": {"amount_cents": 4900}}]},
    timeout=10,
).raise_for_status()
import os, time, uuid, requests

def send(items, attempts=6):
    for attempt in range(attempts):
        try:
            response = requests.post(
                "https://app.clickclacks.io/api/v1/batch",
                headers={"Authorization": f"Bearer {os.environ['CLICKCLACKS_SERVER_KEY']}"},
                json={"items": items},  # the same insert_id on every attempt
                timeout=10,
            )
        except requests.RequestException:
            response = None
        if response is not None and response.status_code < 500 and response.status_code != 429:
            response.raise_for_status()
            return response.json()
        retry_after = response.headers.get("Retry-After") if response is not None else None
        time.sleep(float(retry_after) if retry_after else min(30, 0.5 * 2 ** attempt))
    raise RuntimeError("ClickClacks batch failed after retries")

send([{"event": "Invoice paid", "distinct_id": "user_8412", "insert_id": uuid.uuid4().hex}])

Go

body, _ := json.Marshal(map[string]any{"items": []map[string]any{{
    "event": "Invoice paid", "distinct_id": "user_8412",
    "insert_id": uuid.NewString(),
    "properties": map[string]any{"amount_cents": 4900},
}}})
req, _ := http.NewRequest("POST", "https://app.clickclacks.io/api/v1/batch", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("CLICKCLACKS_SERVER_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) // retry on 429/5xx, honouring Retry-After
func send(body []byte) error {
    for attempt := 0; attempt < 6; attempt++ {
        req, _ := http.NewRequest("POST", "https://app.clickclacks.io/api/v1/batch", bytes.NewReader(body))
        req.Header.Set("Authorization", "Bearer "+os.Getenv("CLICKCLACKS_SERVER_KEY"))
        req.Header.Set("Content-Type", "application/json")
        resp, err := http.DefaultClient.Do(req)
        wait := time.Duration(math.Min(30, 0.5*math.Pow(2, float64(attempt)))) * time.Second
        if err == nil {
            resp.Body.Close()
            if resp.StatusCode != 429 && resp.StatusCode < 500 {
                return nil // 202: check errors[] in the body for per-item problems
            }
            if s, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil {
                wait = time.Duration(s) * time.Second
            }
        }
        time.Sleep(wait) // body is reused, so every insert_id stays the same
    }
    return errors.New("clickclacks: batch failed after retries")
}

Agency client projects

An app that sets up sites for an agency’s clients can create a ClickClacks project for each one, and close it again. Create an agency provisioning key in Agency settings › MCP keys (kind “Create and close projects”) and send it as Authorization: Bearer cka_agency_…. It acts as the agency owner or admin who created it, in that agency only, and stops working when they leave the agency or lose the permission to manage its projects. It reads no analytics itself, though it can make a read key for each client, and an MCP key can’t be used here. A missing, revoked or read-only key is answered 401.

POST /api/v1/agency/projects

Creates the client: its project, and a website source for the domain. The body is { "name", "slug", "domain" }: a name of 2 to 64 characters, an address of 3 to 48 lowercase letters, numbers and hyphens, and the site’s domain. Add "externalId", the client’s Organization id in your app, for one-click sign-in from it (coming); it is recorded once, so a retry can’t change it. The answer is 201 with the source’s browser key and the tracker’s script tag, ready to paste into the site’s <head>:

{
  "organizationId": "org_…",
  "slug": "kite-studio",
  "browserKey": "pk_live_…",
  "snippet": "<script async src=\"https://app.clickclacks.io/c.js\" data-key=\"pk_live_…\" …></script>",
  "resumed": false
}

Retry with the same slug as often as you need: when a provisioning key of this agency already created that client, the answer is 200 with the same client and "resumed": true, never a second one. A slug used by any other project, or by one closed in the last 30 days, is 409. A bad field is 400 with data.field naming it, and 403 means the agency can’t add a project now (its plan’s limit, or new projects are turned off).

POST /api/v1/agency/projects/{slug}/close

Closes a client a provisioning key of this agency created, for example a demo that expired unsold. It starts the project’s deletion as the key’s creator, the same deletion an owner starts in settings: its data is deleted after 30 days, and until then its owner can restore it from ClickClacks. The answer is 200:

{
  "organizationId": "org_…",
  "slug": "kite-studio",
  "alreadyClosed": false,
  "restoreUntil": "2026-12-04T12:00:00.000Z"
}

Closing one that is already closed answers 200 again with "alreadyClosed": true. A project no provisioning key created, one in another agency, or an unknown slug is 404: a key never closes a project someone added by hand. 403 means the key’s creator is no longer an owner of that project.

POST /api/v1/agency/projects/{slug}/read-keys

Makes a read-only analytics key for a client a provisioning key of this agency created, so your app can show that client its numbers. The body is { "name" } (1 to 64 characters, shown in the client’s Settings › API keys). The answer is 201, and the secret is in it only once:

{
  "token": { "id": "aat_…", "name": "aptosstudio", "prefix": "cka_live_…", "scopes": ["analytics:read"], … },
  "secret": "cka_live_…"
}

Send the secret as Authorization: Bearer cka_live_… to GET /api/v1/analytics/overview and GET /api/v1/analytics/events. It reads that client only, and stops working when the client is closed or someone revokes it. It is the client’s own key: it shows in the client’s Settings › API keys, and revoking the provisioning key does not revoke it. A project no provisioning key created, one in another agency, or an unknown slug is 404; 403 means the key’s creator is no longer an owner of that project.

POST /api/v1/agency/projects/{slug}/read-keys/{id}/revoke revokes one and answers { "token" } with its revokedAt; a key of another client is 404. The client’s owner can revoke it in settings too.

POST /api/v1/agency/projects/{slug}/pro

Turns ClickClacks Pro on or off for a client a provisioning key of this agency created, when your app sells it to them. The body is { "pro": true } or { "pro": false }, and the answer is { "slug", "pro" }. Pro opens session replay, heatmaps, funnels, friction findings and Ask for the client’s people; a client starts without it, and its overview is always open. Turning it off locks those screens again and deletes nothing, so turning it back on shows what was there. Each change is recorded in the client’s audit log; sending the same value again changes nothing. Nothing is charged by ClickClacks: your app bills the client. A project no provisioning key created, one in another agency, or an unknown slug is 404.

GET /api/v1/analytics/events

Every event name in the read key’s project with how many times it happened, most first (up to 200). It takes the overview’s start, end (YYYY-MM-DD), source and properties. Custom events carry the page they happened on as path, so properties=[{"property":"path","values":["/contact"]}] gives the calls, forms and bookings on that page; the overview’s top pages lists the pages.

{ "names": [{ "name": "$pageview", "count": 1204 }, { "name": "phone_call", "count": 31 }] }

Privacy

Changelog

Questions? The MCP docs cover reading your analytics from an AI agent.