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 Origin or Sec-Fetch-* header is refused with 403 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.

request
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.

response
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=true answers 200 with 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=true refuses the whole batch if any item is invalid, which is useful in CI.

Items

FieldRule
type"track" (the default) or "identify". group is reserved.
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–Z, a–z, 0–9, _ and -. See Idempotency.
session_idA ses_… id, to join a browser session. Without it, server events never inflate session counts.
propertiesA 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_id is 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 browser identify with that id joins it.
  • anonymous_id attaches the event to one specific browser, using its per_… key.
  • identify items 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

LimitValue
Items per request500
Body1 MiB uncompressed; gzip accepted
Properties per item8 KiB, 255 keys, depth 5
Events per project, sustained1,000 per second
Events per project, burst5,000 per second
Requests per key100 per second
Timestamps accepted as sent7 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.

response
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:

HTTPCodeWhen
400all_items_invalidEvery item failed; errors[] lists each one.
400batch_too_many_itemsMore than 500 items. Split the batch.
401invalid_keyAn unknown, revoked or expired key.
403browser_not_allowedThe request came from a browser.
404not_foundAn unknown path, or the API isn’t on for this project yet.
413payload_too_largeOver 1 MiB uncompressed. Split and resend.
429rate_limitedA rate limit tripped. Retry after Retry-After.
503collection_unavailableTemporarily 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

terminal
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

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

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

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, 429 and 5xx with backoff, honouring Retry-After.

terminal
npm install @clickclacks/node
server.ts
import { 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).

worker.ts
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 in identify traits, and only if you need it.
  • $ip is kept only when the Source records IP addresses.
  • $user_agent is 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.