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, on the Source’s page, or in Settings › API keys. 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) or "identify". group is reserved. |
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. $groups, $revenue and $currency are validated and stored now for Groups and Revenue later.
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 or identify. | No |
item_type_not_yet_supported | group, or 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. | 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' } })
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")
}
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 (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.