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.
- A Source holds at most two active keys, so you can roll without downtime: the old key keeps working for the grace period you choose (now, 24 hours or 7 days).
- Revoke takes effect on the next request.
- Keys never work from a browser. A request with an
OriginorSec-Fetch-*header is refused withbrowser_not_allowed, the API sends no CORS headers, and the Source’s health panel warns you so you can roll the key. - Older
sk_live_Source keys keep working here and on/api/ingest.
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
| Field | Rule |
|---|---|
type | "track" (the default), "identify" or "group" (see groups). |
event | Required for track. 1–128 characters; names starting with $ are reserved. |
distinct_id | Your user id, 1–128 characters. Required unless anonymous_id is sent (and always for identify). |
anonymous_id | A browser’s per_… key, to attach the event to that browser’s person. |
timestamp | ISO 8601 with an offset, or epoch milliseconds. Defaults to arrival time. |
insert_id | 1–80 characters of [A-Za-z0-9_-]. See idempotency. |
session_id | A ses_… id, to join a browser session. Without it, server events form one “server session” that never inflates session counts. |
properties | A 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.
| Field | Rule |
|---|---|
type | "group" |
group_type | Required. ^[a-z0-9_]{1,64}$, for example company. |
group_id | Required. A string of 1–255 characters (numbers are refused; the SDK converts them). |
properties | The traits: at most 8 KiB, 255 keys, depth 5, and no $ keys. |
timestamp, insert_id | As 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
| Limit | Value |
|---|---|
| Items per request | 500 |
| Body | 1 MiB uncompressed; Content-Encoding: gzip is accepted |
| Properties per item | 8 KiB, 255 keys, depth 5 |
| Events per project, sustained | 1,000 per second (60,000 per minute) |
| Events per project, burst | 5,000 per second (50,000 per 10 seconds) |
| Requests per key | 100 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
| HTTP | Code | When | Retry? |
|---|---|---|---|
| 400 | invalid_json | The body isn’t JSON. | No |
| 400 | invalid_body | The top-level shape is wrong (items missing or not an array). | No |
| 400 | batch_empty | items is empty. | No |
| 400 | batch_too_many_items | More than 500 items. | No — split the batch |
| 400 | all_items_invalid | Every item failed; errors[] lists each one. | No |
| 400 | strict_item_invalid | With ?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 |
| 400 | invalid_query | An unknown or invalid query parameter. | No |
| 401 | missing_key | No Authorization: Bearer header. | No |
| 401 | invalid_key | An unknown, revoked or expired key, or a deleted Source or Organization. | No |
| 403 | browser_not_allowed | The request carries Origin or Sec-Fetch-*: it came from a browser. | No |
| 404 | not_found | An unknown /api/v1/… path, or the API is not enabled for this project yet. | No |
| 405 | method_not_allowed | Not POST. | No |
| 410 | api_version_retired | Reserved for a retired API version. | No |
| 413 | payload_too_large | Over 1 MiB uncompressed. | Split and resend |
| 415 | unsupported_media_type | Content-Type isn’t application/json. | No |
| 415 | unsupported_encoding | Content-Encoding isn’t gzip or identity. | No |
| 429 | rate_limited | A rate limit tripped. Retry-After is set. | Yes, after Retry-After |
| 500 | internal_error | Something unexpected. | Yes, with backoff |
| 503 | collection_unavailable | Durable acceptance failed. Retry-After: 5. | Yes — resend the whole request |
Item errors (in errors[])
| Code | When | Retry? |
|---|---|---|
invalid_item | The item isn’t a JSON object. | No |
invalid_item_type | type isn’t track, identify or group. | No |
item_type_not_yet_supported | alias, before it ships. | No |
missing_event_name | A track item has no event. | No |
invalid_event_name | event isn’t 1–128 characters. | No |
reserved_event_name | event starts with $ (except $agent_step and $agent_run), or is an internal name. | No |
missing_identity | Neither distinct_id nor anonymous_id. | No |
invalid_distinct_id | distinct_id isn’t 1–128 characters, or has control characters. | No |
invalid_anonymous_id | anonymous_id isn’t a per_… browser key. | No |
invalid_session_id | session_id isn’t a ses_… id. | No |
invalid_insert_id | insert_id isn’t 1–80 characters of [A-Za-z0-9_-]. | No |
duplicate_insert_id | The same insert_id twice in one request; the later item is refused. | No |
invalid_timestamp | timestamp isn’t ISO 8601 with an offset or epoch milliseconds. | No |
timestamp_too_old | Older than 7 days. Use /api/v1/import (coming soon) for backfills. | No |
timestamp_in_future | More than 10 minutes ahead of the server clock. | No |
timestamp_before_retention | Import only: older than the project’s raw-event retention. | No |
invalid_properties | properties isn’t a JSON object, or nests deeper than 5 levels. | No |
properties_too_large | Over 8 KiB of JSON after normalisation. | No |
too_many_properties | More than 255 keys. | No |
reserved_property | A $ key the API doesn’t accept. | No |
invalid_reserved_property | An 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_group | A 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_dropped | With ?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[])
| Reason | When | Retry? |
|---|---|---|
bot_filtered | $user_agent is a known bot and the Source filters bots. | No |
collection_paused | The 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
- Use an opaque internal user id as
distinct_id, not an email address. Put an email or name only inidentifytraits, and only if you need it. - Never put secrets in properties. Values that look like keys are flagged.
$ipis kept only when the Source records IP addresses;$user_agentis never stored raw. Country comes only from a$countryyou send.- Person deletion requests go through support for now.
Changelog
- v1.1 (September 2026).
groupitems and$groupsfor group analytics; additivewarnings[]on accepted responses; Node SDK 1.1.0 (group()and thegroupsoption ontrack()). - v1 (September 2026).
POST /api/v1/batchwithtrackandidentify, server keys,?validateand?strict, and the Node SDK. Coming next:/api/v1/importfor backfills andalias.
Questions? The MCP docs cover reading your analytics from an AI agent.