# Send events

> The complete guide to POST /api/v1/batch: authentication, items, timestamps, idempotent retries with insert_id, dry runs, strict mode, rate limits and errors.

- Canonical URL: https://clickclacks.io/docs/api
- Section: Server-side
- Last updated: 2026-10-17

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.

New to the server API? Start with the [overview](https://clickclacks.io/docs/server-side.md), which covers creating a server key. The [canonical reference](https://app.clickclacks.io/docs/api) in the app is the source of truth for error codes; every error response links to it.

## The request {#send}

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

```http title="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](https://clickclacks.io/docs/custom-domain.md) carries browser traffic, not server calls.
- Only `POST` is allowed. The only query parameters are `validate` and `strict`.

**curl**

```bash title="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}}]}'
```

**Node**

```ts title="server.ts"
// npm install @clickclacks/node
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' },
})

process.on('SIGTERM', () => clickclacks.shutdown())
```

**Python**

```python title="send.py"
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 title="send.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
```

Prefer a library? The [Node SDK](https://clickclacks.io/docs/node.md) and the [PHP SDK](https://clickclacks.io/docs/php.md) batch, compress and retry for you, with an `insert_id` on every item.

## Items {#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](https://clickclacks.io/docs/api.md#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](https://clickclacks.io/docs/server-identity.md). |
| `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](https://clickclacks.io/docs/api.md#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](https://clickclacks.io/docs/server-identity.md) 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](https://clickclacks.io/docs/guides/traces.md).

## Properties {#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](https://clickclacks.io/docs/api.md#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 {#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:

```title="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](https://clickclacks.io/docs/groups.md) explains the model and what reports do with it.

## Timestamps {#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 {#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](https://clickclacks.io/docs/node.md) adds one for you.

## Responses {#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 title="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](https://clickclacks.io/docs/api.md#item-errors), and `dropped` lists valid items the source’s policy set aside, with a [reason](https://clickclacks.io/docs/api.md#dropped).
- **`400 all_items_invalid`** when none was.
- `warnings` appears only when an accepted item was changed, today only for a personal-looking [group trait](https://clickclacks.io/docs/api.md#groups) 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}

- **`?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`.

```http title="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 {#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_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 {#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_id`s.
- **Split and resend** after `413 payload_too_large`.
- **Don’t retry** other `4xx` answers unchanged: fix the request first.

[Any language](https://clickclacks.io/docs/http.md) has complete retry loops for Python, Go and Ruby.

## Errors {#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 title="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 {#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 {#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 {#dropped}

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 {#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 {#next}

- [Identity from the server](https://clickclacks.io/docs/server-identity.md): join server events to browser history.
- [Node SDK](https://clickclacks.io/docs/node.md): batching, retries and Workers support built in.
- [Limits](https://clickclacks.io/docs/limits.md): every limit across ClickClacks.
