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.

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) 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-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. $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

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 or identify.No
item_type_not_yet_supportedgroup, or alias 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.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' } })

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

Changelog

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