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.
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-Typemust beapplication/json(UTF-8).Content-Encoding: gzipis accepted, and worth it for large batches. The 1 MiB limit is counted after decompression.- The endpoint is served on
app.clickclacks.ioonly. A custom tracker domain carries browser traffic, not server calls. - Only
POSTis allowed. The only query parameters arevalidateandstrict.
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.
| Field | Rule |
|---|---|
type | "track" (the default), "identify" or "group" (see groups). alias isn’t available yet. |
event | Required for track: 1–128 characters. Names starting with $ are reserved. |
distinct_id | Your 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_id | A browser’s per_… ID, to attach the item to that browser. See identity from the server. |
session_id | A browser’s ses_… session ID, to count the event in that session. Without it, server events never touch session counts. |
timestamp | When it happened: ISO 8601 with Z or ±HH:MM, or epoch milliseconds. Defaults to arrival time. |
insert_id | 1–80 characters of A–Z a–z 0–9 _ -. Makes retries safe; see idempotency. |
properties | A 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:
| Key | Rule |
|---|---|
$ip | A valid IPv4 or IPv6 address. Kept only when the source has Record IP address on; otherwise dropped. |
$user_agent | Up to 1,024 characters. Read for browser, OS, device and bot filtering, then discarded. |
$country | Two letters (ISO 3166-1 alpha-2), any case. Server events have no country otherwise. |
$current_url | Up to 2,048 characters. Kept, and its utm_* parameters become campaign properties. |
$revenue | A number with at most 6 decimal places. Needs $currency. |
$currency | Three upper-case letters, such as USD or EUR. |
$groups | Up 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_id | Accepted 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:
{
"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
groupitem needsgroup_type(1–64 characters ofa–z 0–9 _) andgroup_id(a string of 1–255 characters), and takesproperties,timestampandinsert_id. It has no person, so it refusesdistinct_id,anonymous_idandsession_id. Group items are free. - The newest
groupitem 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,addressorpassword) are left out and listed in the response’swarningsasgroup_trait_dropped, unless the source’s Allow personal data in group traits setting is on. With?strict=truethey 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
$groupsnever counts for a group later. Use opaque IDs such ascmp_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
Zor±HH:MMoffset (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 withtimestamp_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_idon the same source is accepted and stored once, however many times you send it. - The same
insert_idon 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.
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…"
}202when at least one item was accepted or dropped.acceptedcounts stored items,errorslists refused ones with a code, anddroppedlists valid items the source’s policy set aside, with a reason.400 all_items_invalidwhen none was.warningsappears 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=trueanswers200with 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=truerefuses the whole batch with400 strict_item_invalidif 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 a202.
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
| 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 (60,000 a minute) |
| Events per project, burst | 5,000 per second (50,000 in 10 seconds) |
| 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. 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_limitedwithRetry-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,
429and5xx, with exponential backoff and jitter, and honourRetry-Afterwhen it’s there. - Resend the whole request after a
503 collection_unavailable, with the sameinsert_ids. - Split and resend after
413 payload_too_large. - Don’t retry other
4xxanswers 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.
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.
| HTTP | Code | When | Retry |
|---|---|---|---|
| 400 | invalid_json | The body isn’t valid JSON, or a gzip body is corrupt. | No |
| 400 | invalid_body | The top-level shape is wrong: items is 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 | No item was accepted or dropped; errors lists what was wrong. | No |
| 400 | strict_item_invalid | With ?strict=true, an item was invalid, so nothing was stored. errors holds the first invalid item. | No: fix the items and resend |
| 400 | invalid_query | A query parameter other than validate or strict, or a value other than true or false. | 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, Sec-Fetch-Site or Sec-Fetch-Mode: it came from a browser. | No |
| 404 | not_found | The API isn’t available for this project. | No |
| 405 | method_not_allowed | Not POST. | No |
| 410 | api_version_retired | Reserved for a retired API version. Not sent today. | 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 says when to try again. | Yes, after Retry-After |
| 500 | internal_error | Something unexpected. | Yes, with backoff |
| 503 | collection_unavailable | Events 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.
| Code | When |
|---|---|
invalid_item | The item isn’t a JSON object, or a group item carries a distinct_id, anonymous_id or session_id. |
invalid_item_type | type isn’t track, identify or group. |
item_type_not_yet_supported | alias, which isn’t available yet. |
missing_event_name | A track item has no event. |
invalid_event_name | event isn’t a string of 1–128 characters, or is only whitespace. |
reserved_event_name | event starts with $ (apart from the $agent_run and $agent_step trace events). |
missing_identity | A track item has neither distinct_id nor anonymous_id, or an identify item has no distinct_id. |
invalid_distinct_id | distinct_id isn’t a string of 1–128 characters, or has control characters. |
invalid_anonymous_id | anonymous_id isn’t a per_… browser ID. |
invalid_session_id | session_id isn’t a ses_… ID. |
invalid_insert_id | insert_id isn’t 1–80 characters of A–Z a–z 0–9 _ -. |
duplicate_insert_id | The same insert_id twice in one request; the later item is refused. |
invalid_timestamp | timestamp isn’t ISO 8601 with an offset, or epoch milliseconds. |
timestamp_too_old | More than 7 days old. Backfilling older history is coming soon. |
timestamp_in_future | More than 10 minutes ahead of the server clock. |
timestamp_before_retention | Reserved for backfills older than the project’s retention. Not sent by this endpoint. |
invalid_properties | properties isn’t a JSON object, or nests deeper than 5 levels. |
properties_too_large | Over 8 KiB of JSON, counted after ClickClacks adds its own properties. |
too_many_properties | More than 255 keys. |
reserved_property | A $ key the API doesn’t accept. |
invalid_reserved_property | An accepted $ key with a bad value, such as $revenue without $currency. |
invalid_group | A 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_dropped | With ?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.
| Reason | When |
|---|---|
bot_filtered | $user_agent is a known bot and the source filters bots. |
collection_paused | The 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 inidentifytraits, and only if you need it. $ipis kept only when the source records IP addresses, which is off by default.$user_agentis 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
- Identity from the server: join server events to browser history.
- Node SDK: batching, retries and Workers support built in.
- Limits: every limit across ClickClacks.