Send events

One endpoint takes batches of up to 500 events and identify calls from your servers. This page is everything you need to use it well: the shape, the limits, and how to retry without double-counting.

Updated

New to the server API? Start with the overview, which covers creating a server key. The canonical reference in the app is the source of truth for error codes; every error response links to it.

The request

POST https://app.clickclacks.io/api/v1/batch, with your server key as a bearer token and a JSON body holding an items array.

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",
      "properties": { "plan": "pro", "company": "Acme" }
    },
    {
      "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"
      }
    }
  ]
}
  • Content-Type must be application/json (UTF-8).
  • Content-Encoding: gzip is accepted, and worth it for large batches. The 1 MiB limit is counted after decompression.
  • The endpoint is served on app.clickclacks.io only. A custom tracker domain carries browser traffic, not server calls.
  • Only POST is allowed. The only query parameters are validate and strict.
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}}]}'

Prefer a library? The Node SDK and the PHP SDK batch, compress and retry for you, with an insert_id on every item.

Items

Each item is a track (an event) or an identify (traits for a person). Unknown fields are ignored.

FieldRule
type"track" (the default), "identify" or "group" (see groups). alias isn’t available yet.
eventRequired for track: 1–128 characters. Names starting with $ are reserved.
distinct_idYour user ID: a string of 1–128 characters, trimmed, no control characters. Required for identify; for track, send this or anonymous_id or both.
anonymous_idA browser’s per_… ID, to attach the item to that browser. See identity from the server.
session_idA browser’s ses_… session ID, to count the event in that session. Without it, server events never touch session counts.
timestampWhen it happened: ISO 8601 with Z or ±HH:MM, or epoch milliseconds. Defaults to arrival time.
insert_id1–80 characters of A–Z a–z 0–9 _ -. Makes retries safe; see idempotency.
propertiesA JSON object: at most 8 KiB, 255 keys, nested 5 levels deep. For identify, these are the person’s traits.

identify items record a $identify for the person, setting their traits (newest wins), and are free. Identity from the server explains how distinct_id and anonymous_id decide which person an item belongs to.

Agent traces are track items too, named $agent_run and $agent_step, with their own strict properties. See Agent traces.

Properties

Your own property names can be anything that doesn’t start with $. The size limit, 8 KiB of JSON, is counted after ClickClacks adds its own properties (the source, the library, and browser and country details when you send them), so leave some room. These $ keys are accepted:

KeyRule
$ipA valid IPv4 or IPv6 address. Kept only when the source has Record IP address on; otherwise dropped.
$user_agentUp to 1,024 characters. Read for browser, OS, device and bot filtering, then discarded.
$countryTwo letters (ISO 3166-1 alpha-2), any case. Server events have no country otherwise.
$current_urlUp to 2,048 characters. Kept, and its utm_* parameters become campaign properties.
$revenueA number with at most 6 decimal places. Needs $currency.
$currencyThree upper-case letters, such as USD or EUR.
$groupsUp to 5 entries of group type to ID, such as { "company": "cmp_311" }: types are 1–64 characters of a–z 0–9 _, IDs strings of 1–255 characters. See groups.
$insert_idAccepted and ignored. Use the insert_id field.

Keys that ClickClacks sets itself, such as $browser, $source_id or $distinct_id, are removed if you send them. Any other $ key refuses the item with reserved_property.

Groups

An event counts for a company, workspace or team when it carries $groups. A group item records that group’s traits, such as its plan and seat count:

request body
{
  "items": [
    {
      "type": "group",
      "group_type": "company",
      "group_id": "cmp_311",
      "properties": { "name": "Acme", "plan": "pro", "seats": 40 }
    },
    {
      "event": "Seats changed",
      "distinct_id": "user_8412",
      "properties": { "seats": 41, "$groups": { "company": "cmp_311" } }
    }
  ]
}
  • A group item needs group_type (1–64 characters of a–z 0–9 _) and group_id (a string of 1–255 characters), and takes properties, timestamp and insert_id. It has no person, so it refuses distinct_id, anonymous_id and session_id. Group items are free.
  • The newest group item replaces all of that group’s traits, so send the full set each time. Traits can’t start with $.
  • Traits that look like personal data (an email address, a phone number, or a key such as email, phone, ip, address or password) are left out and listed in the response’s warnings as group_trait_dropped, unless the source’s Allow personal data in group traits setting is on. With ?strict=true they refuse the item instead.
  • A project keeps at most 5 group types, in the order they first arrive. A sixth is left off the event, and the rest of the event is kept.
  • An event sent without $groups never counts for a group later. Use opaque IDs such as cmp_311, not domains or email addresses.

Group analytics is on for every project. The Companies pages and the Count by controls appear in the app once the project has received its first group. Companies and groups explains the model and what reports do with it.

Timestamps

  • Send ISO 8601 with a Z or ±HH:MM offset (2026-09-25T14:03:11.402Z), or epoch milliseconds (1790345000000). Leave it out to use the time the request arrives.
  • Timestamps are trusted 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 event lands on its own day in UTC, and reports include a late event after their next refresh.
  • Backfilling history older than 7 days needs a separate import endpoint, which is coming soon.

Idempotency

Networks fail after a request has landed, so retries are normal. Give every item an insert_id and reuse it when you retry:

  • A repeat of the same insert_id on the same source is accepted and stored once, however many times you send it.
  • The same insert_id on two different sources is two events.
  • Twice in one request, the later item is refused with duplicate_insert_id.
  • Without an insert_id, every retry that lands is stored again.

A good insert_id comes from the thing that happened: inv_2291_paid for an invoice, or a UUID created once and saved with the job that sends it. The Node SDK adds one for you.

Responses

Items are checked one by one. Valid items are accepted; invalid ones are reported with their index in items, and don’t stop the rest.

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…"
}
  • 202 when at least one item was accepted or dropped. accepted counts stored items, errors lists refused ones with a code, and dropped lists valid items the source’s policy set aside, with a reason.
  • 400 all_items_invalid when none was.
  • warnings appears only when an accepted item was changed, today only for a personal-looking group trait left out, as { index, code, field, message }.
  • Every response has a request_id. Include it if you contact support.

Dry runs and strict mode

  • ?validate=true answers 200 with every item exactly as it would be stored (its person, event ID, time, session and final properties) plus every error. Nothing is stored or counted toward your plan. It does count toward the per-key request limit.
  • ?strict=true refuses the whole batch with 400 strict_item_invalid if any item is invalid, and stores nothing. The response names the first invalid item. Useful in CI and in tests. One check runs after items are accepted (the 8 KiB limit, counted with ClickClacks’ own properties), so that error can still come back in a 202.
response
HTTP/1.1 200 OK

{
  "valid": false,
  "items": [
    { "index": 0, "event_name": "Subscription started", "person_id": "per_u_…",
      "event_id": "evt_sub_started_7f3a91", "ts": "2026-09-25T14:03:11.402Z",
      "session_id": "ses_server", "properties": { … } }
  ],
  "dropped": [],
  "errors": [{ "index": 1, "code": "missing_identity", "message": "…" }],
  "request_id": "…"
}

Batching and rate 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 (60,000 a minute)
Events per project, burst5,000 per second (50,000 in 10 seconds)
Requests per key100 per second
Timestamps accepted as sent7 days back to 10 minutes ahead

The limits are the same on every plan. The per-project event limits are counted in blocks of 100 events: each request uses one block per 100 valid items, rounded up. So a request with 1 item uses as much as a request with 100, and a service that sends one event per request reaches the sustained limit at 10 requests a second. Batch your events. The Node SDK does this for you.

  • Over a limit, the API answers 429 rate_limited with Retry-After: 10 seconds for the per-key and burst limits, 60 for the sustained one. Nothing in that request is stored or counted.
  • Limits are counted per Cloudflare location, so a sender in several regions at once may briefly exceed them.
  • Server events count toward your monthly events like any other; identify calls are free.

Retries

  • Retry network errors, timeouts, 429 and 5xx, with exponential backoff and jitter, and honour Retry-After when it’s there.
  • Resend the whole request after a 503 collection_unavailable, with the same insert_ids.
  • Split and resend after 413 payload_too_large.
  • Don’t retry other 4xx answers unchanged: fix the request first.

Any language has complete retry loops for Python, Go and Ruby.

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_limited",
    "request_id": "8c1f…"
  }
}

Request errors

The whole request was refused. Nothing was stored.

HTTPCodeWhenRetry
400invalid_jsonThe body isn’t valid JSON, or a gzip body is corrupt.No
400invalid_bodyThe top-level shape is wrong: items is missing or not an array.No
400batch_emptyitems is empty.No
400batch_too_many_itemsMore than 500 items.No: split the batch
400all_items_invalidNo item was accepted or dropped; errors lists what was wrong.No
400strict_item_invalidWith ?strict=true, an item was invalid, so nothing was stored. errors holds the first invalid item.No: fix the items and resend
400invalid_queryA query parameter other than validate or strict, or a value other than true or false.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, Sec-Fetch-Site or Sec-Fetch-Mode: it came from a browser.No
404not_foundThe API isn’t available for this project.No
405method_not_allowedNot POST.No
410api_version_retiredReserved for a retired API version. Not sent today.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 says when to try again.Yes, after Retry-After
500internal_errorSomething unexpected.Yes, with backoff
503collection_unavailableEvents couldn’t be stored durably. Retry-After: 5.Yes: resend the whole request

Item errors

In errors, as { index, code, field, message }. The rest of the batch was still processed.

CodeWhen
invalid_itemThe item isn’t a JSON object, or a group item carries a distinct_id, anonymous_id or session_id.
invalid_item_typetype isn’t track, identify or group.
item_type_not_yet_supportedalias, which isn’t available yet.
missing_event_nameA track item has no event.
invalid_event_nameevent isn’t a string of 1–128 characters, or is only whitespace.
reserved_event_nameevent starts with $ (apart from the $agent_run and $agent_step trace events).
missing_identityA track item has neither distinct_id nor anonymous_id, or an identify item has no distinct_id.
invalid_distinct_iddistinct_id isn’t a string of 1–128 characters, or has control characters.
invalid_anonymous_idanonymous_id isn’t a per_… browser ID.
invalid_session_idsession_id isn’t a ses_… ID.
invalid_insert_idinsert_id isn’t 1–80 characters of A–Z a–z 0–9 _ -.
duplicate_insert_idThe same insert_id twice in one request; the later item is refused.
invalid_timestamptimestamp isn’t ISO 8601 with an offset, or epoch milliseconds.
timestamp_too_oldMore than 7 days old. Backfilling older history is coming soon.
timestamp_in_futureMore than 10 minutes ahead of the server clock.
timestamp_before_retentionReserved for backfills older than the project’s retention. Not sent by this endpoint.
invalid_propertiesproperties isn’t a JSON object, or nests deeper than 5 levels.
properties_too_largeOver 8 KiB of JSON, counted after ClickClacks adds its own properties.
too_many_propertiesMore than 255 keys.
reserved_propertyA $ key the API doesn’t accept.
invalid_reserved_propertyAn accepted $ key with a bad value, such as $revenue without $currency.
invalid_groupA group item whose group_type isn’t 1–64 characters of a–z 0–9 _, or whose group_id isn’t a string of 1–255 characters.
group_trait_droppedWith ?strict=true only: a group trait looks like personal data. Without strict mode the trait is left out and listed in warnings.

Dropped items

In dropped, as { index, reason }. The item was valid but set aside, so retrying won’t help.

ReasonWhen
bot_filtered$user_agent is a known bot and the source filters bots.
collection_pausedThe item was valid, but collection is paused because the organization is over its plan limit.

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, which is off by default.
  • $user_agent is used for browser, OS, device and bot filtering, then discarded; it’s never stored raw.
  • Never put secrets, tokens or payment details in properties.

Next steps