Docs
Server-side tracking
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, in the same reports as your browser events.
This guide covers what you need to start. The canonical reference, with every error code, is at app.clickclacks.io/docs/api; error responses link straight to it. For the overview, see the server API feature page.
Why server-side
- Nothing to block. Ad blockers and privacy extensions can stop a script in a browser, not a request between two servers.
- Nothing to fake. A server key is secret and never works from a browser, so events sent with it only come from your code.
- No browser needed. Renewals, webhooks, background jobs and exports happen with no page open.
Keep the script tag for pageviews, clicks and scroll depth. Server events join the same people, so a funnel can start on your pricing page and end at “Invoice paid”.
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 with403 browser_not_allowed, and the API sends no CORS headers.
Send a batch
One endpoint takes batches of up to 500 track and identify items: POST https://app.clickclacks.io/api/v1/batch. The body is JSON, at most 1 MiB uncompressed, and Content-Encoding: gzip is accepted. It is served on the app host only; custom tracker domains carry browser traffic, not server calls.
POST /api/v1/batch HTTP/1.1
Host: app.clickclacks.io
Authorization: Bearer cks_live_…
Content-Type: application/json
{
"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"
}
}
]
} 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 the Source’s policy, and 400 all_items_invalid when none was.
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"accepted": 498,
"dropped": [{ "index": 12, "reason": "bot_filtered" }],
"errors": [
{ "index": 3, "code": "timestamp_too_old", "field": "timestamp",
"message": "Timestamps older than 7 days …" }
],
"request_id": "8c1f2d…-FRA"
}Dry runs and strict mode
?validate=trueanswers200with every item exactly as it would be stored, plus every error. Nothing is stored or counted toward your plan, though the call counts toward the rate limits.?strict=truerefuses the whole batch if any item is invalid, 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–Z, a–z, 0–9, _ and -. See Idempotency. |
session_id | A ses_… id, to join a browser session. Without it, server events never inflate session counts. |
properties | A JSON object: at most 8 KiB, 255 keys, nesting depth 5. |
In properties you may send $ip, $user_agent, $country (ISO 3166-1 alpha-2), $current_url, $groups, $revenue, $currency and $insert_id. Any other key that starts with $ is refused.
People and identity
distinct_idis matched to the person who already identified with the same id in the browser, so server events join their history. If nobody has yet, a person is created, and a later browseridentifywith that id joins it.anonymous_idattaches the event to one specific browser, using itsper_…key.identifyitems set a person’s traits, newest wins, and are free.
Timestamps
Timestamps are trusted as sent from 7 days in the past to 10 minutes ahead of the server clock. Older items are refused with timestamp_too_old, and items further ahead with timestamp_in_future. An endpoint for backfilling older history is coming. A late event lands on its own day, and reports include it after their next refresh.
Idempotency
Send an insert_id with every item 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.
Limits
| Limit | Value |
|---|---|
| Items per request | 500 |
| Body | 1 MiB uncompressed; gzip accepted |
| Properties per item | 8 KiB, 255 keys, depth 5 |
| Events per project, sustained | 1,000 per second |
| Events per project, burst | 5,000 per second |
| Requests per key | 100 per second |
| Timestamps accepted as sent | 7 days back to 10 minutes ahead |
The limits are the same on every plan. Over a rate limit, the API answers 429rate_limited with Retry-After, and nothing in that request is stored or counted. Limits are counted per Cloudflare location, so a sender in many regions at once may briefly exceed them. Server events count like any other event toward your monthly total; identify calls are free.
Errors
Every non-2xx response has the same shape, with a stable code and a docs_url into the reference. Messages may change; codes don’t.
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…" } }The ones you’re most likely to meet:
| HTTP | Code | When |
|---|---|---|
| 400 | all_items_invalid | Every item failed; errors[] lists each one. |
| 400 | batch_too_many_items | More than 500 items. Split the batch. |
| 401 | invalid_key | An unknown, revoked or expired key. |
| 403 | browser_not_allowed | The request came from a browser. |
| 404 | not_found | An unknown path, or the API isn’t on for this project yet. |
| 413 | payload_too_large | Over 1 MiB uncompressed. Split and resend. |
| 429 | rate_limited | A rate limit tripped. Retry after Retry-After. |
| 503 | collection_unavailable | Temporarily unavailable. Resend the whole request. |
Retry on 429 and 5xx, honouring Retry-After; other 4xx answers need the request fixed first. The full catalogue lists every request error, item error and drop reason.
curl, Python and Go
Any HTTP client works. Each example reads the key from CLICKCLACKS_SERVER_KEY. Send the same insert_id on every retry and honour Retry-After; the reference has retry loops for Python and Go.
curl
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}}]}'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()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-AfterNode 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, 429 and 5xx with backoff, honouring Retry-After.
npm install @clickclacks/nodeimport { ClickClacks } from '@clickclacks/node'
const clickclacks = new ClickClacks({ key: process.env.CLICKCLACKS_SERVER_KEY! })
clickclacks.track({
event: 'Subscription started',
distinctId: 'user_8412',
properties: { plan: 'pro', $revenue: 49, $currency: 'USD' },
})
clickclacks.identify({
distinctId: 'user_8412',
anonymousId: 'per_k3J9sQ1xR2', // optional: the browser key, to join the browser's history
properties: { plan: 'pro' },
})
// Before the process exits:
process.on('SIGTERM', async () => {
await clickclacks.shutdown()
process.exit(0)
})track and identify return at once and send in the background. Call shutdown() on SIGTERM, or await flush() before a script or job ends, so queued events are sent before the process exits.
Cloudflare Workers
Workers can’t keep timers between requests. Turn the timer off with flushInterval: 0 and flush at the end of each request with clickclacks.flushWith(ctx).
import { ClickClacks } from '@clickclacks/node'
let clickclacks: ClickClacks | undefined
export default {
async fetch(request, env, ctx) {
clickclacks ??= new ClickClacks({ key: env.CLICKCLACKS_SERVER_KEY, flushInterval: 0 })
clickclacks.track({ event: 'Export finished', distinctId: 'user_8412' })
clickclacks.flushWith(ctx)
return new Response('ok')
},
} satisfies ExportedHandler<{ CLICKCLACKS_SERVER_KEY: string }>group() is reserved and comes with Groups. Until then, send group membership on events as properties: { $groups: { company: 'cmp_311' } }.
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. $ipis kept only when the Source records IP addresses.$user_agentis used for browser, OS, device and bot filtering, then dropped; it is never stored raw.- Never put secrets in properties.
Want to read your analytics from an AI assistant instead? The MCP server guide covers connecting Claude Code, Codex or any MCP client.