# Any language over HTTP

> A step-by-step walkthrough of POST /api/v1/batch with curl: a server key, a dry run with ?validate=true, a real send, a batch with identify and insert_id, reading item errors, strict mode, request errors, and safe retries that never count twice.

- Canonical URL: https://clickclacks.io/docs/walkthroughs/http
- Section: Walkthroughs
- Last updated: 2026-09-26

No SDK for your stack? The server API is one POST with JSON. This walkthrough sends Acme CRM’s deal events with curl, from a first dry run to a production-ready retry, so you can port it to any language with confidence.

## The goal {#goal}

Acme’s deal pipeline runs in a service written in a language without a ClickClacks SDK. By the end you will have sent `Deal created` events, read every kind of answer the API gives, and have a send that is safe to retry. Everything here is plain HTTP, so it translates directly to Python, Go, Ruby, Java or anything else.

The whole contract: `POST https://app.clickclacks.io/api/v1/batch`, a server key as a bearer token, `Content-Type: application/json`, and a body of `{ "items": [...] }` with up to 500 items and 1 MiB.

## 1. Get a server key {#key}

1. Open **Sources**, click **Add source**, choose **Server**, name it _Pipeline service_ and click **Create server source**.
2. Create a key and copy it from **Copy your server key**. It starts `cks_live_` and is shown once.
3. Put it in your shell’s environment for this walkthrough, and in your service’s secrets for production.

```bash title="terminal"
export CLICKCLACKS_SERVER_KEY=cks_live_example
```

> **From a server, never a browser**
>
> A request with browser headers (`Origin`, `Sec-Fetch-Site` or `Sec-Fetch-Mode`) is refused with `403 browser_not_allowed`, so don’t try this from your browser’s console. The key belongs on your servers only.

## 2. Do a dry run {#dry-run}

Add `?validate=true` and the API checks your items and shows each one exactly as it would be stored, without storing or counting anything:

```bash title="terminal"
curl "https://app.clickclacks.io/api/v1/batch?validate=true" \
  -H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"event":"Deal created","distinct_id":"user_4821","properties":{"value_cents":120000,"stage":"qualified"}}]}'
```

> **What you should see**
>
> `200 OK` with `"valid": true` and your item as it would be stored: the person it lands on, its time, and the final properties, including the ones ClickClacks adds.

```http title="response"
HTTP/1.1 200 OK

{
  "valid": true,
  "items": [
    { "index": 0, "event_name": "Deal created", "person_id": "per_u_…",
      "event_id": "evt_…", "ts": "2026-09-26T09:14:02.118Z",
      "session_id": "ses_server", "properties": { "value_cents": 120000, "stage": "qualified", … } }
  ],
  "dropped": [],
  "errors": [],
  "request_id": "…"
}
```

## 3. Send it for real {#send}

Drop the query parameter and add an `insert_id`. It makes the send idempotent: a repeat of the same `insert_id` on the same source is stored once. Derive it from the thing that happened, here the deal’s ID.

```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":"Deal created","distinct_id":"user_4821","insert_id":"deal_9913_created","properties":{"value_cents":120000,"stage":"qualified"}}]}'
```

> **What you should see**
>
> `202 Accepted` with `"accepted": 1`. Run the same command again: it still answers `202`, but the app shows one event, because the `insert_id` matched.

```http title="response"
HTTP/1.1 202 Accepted

{ "accepted": 1, "dropped": [], "errors": [], "request_id": "…" }
```

## 4. Find it in the app {#check}

1. Open **Sources** and the _Pipeline service_ source.
2. Open **Events**, pick `Deal created`, and expand the row.

> **What you should see**
>
> The source reads _Receiving_, and its **Server API health** card counts the event. In Events, **Your properties** shows `value_cents` and `stage`, on the person `user_4821`: the same person the browser identifies at sign-in.

## 5. Send a batch {#batch}

Real services send many items per request. A batch can mix `track` items (the default type) and `identify` items, which set a person’s traits and are free. Save this as `batch.json`; the last item has a deliberate mistake:

```json title="batch.json"
{
  "items": [
    {
      "type": "identify",
      "distinct_id": "user_4821",
      "properties": { "plan": "growth", "seats": 5 }
    },
    {
      "event": "Deal created",
      "distinct_id": "user_4821",
      "insert_id": "deal_9914_created",
      "timestamp": "2026-09-26T09:20:00Z",
      "properties": { "value_cents": 48000, "stage": "lead" }
    },
    {
      "event": "Deal created",
      "insert_id": "deal_9915_created",
      "properties": { "value_cents": 9000, "stage": "lead" }
    }
  ]
}
```

```bash title="terminal"
curl https://app.clickclacks.io/api/v1/batch \
  -H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
  -H "Content-Type: application/json" \
  --data @batch.json
```

- `timestamp` is ISO 8601 with `Z` or an offset, or epoch milliseconds. Leave it out to use the arrival time. The API accepts 7 days back to 10 minutes ahead.
- Batch as much as you can. Rate limits count events in blocks of 100 per request, so one item per request runs out a hundred times sooner. See [Batching and rate limits](https://clickclacks.io/docs/api.md#rate-limits).

## 6. Read item errors {#item-errors}

Items are checked one by one. Valid items are stored; invalid ones are listed with their `index` in your `items` array, and don’t stop the rest:

```http title="response"
HTTP/1.1 202 Accepted

{
  "accepted": 2,
  "dropped": [],
  "errors": [
    { "index": 2, "code": "missing_identity", "field": "distinct_id",
      "message": "Send distinct_id, anonymous_id or both." }
  ],
  "request_id": "…"
}
```

> **What you should see**
>
> `202` with `"accepted": 2` (the identify and the first deal), and one error for item `2`: `missing_identity`, because a `track` item needs `distinct_id`, `anonymous_id` or both.

- Log each error with its `index` and `code`. Resending an item unchanged won’t help: fix it.
- `dropped` lists valid items the source set aside, such as a known bot’s user agent. Retrying won’t help those either.
- If no item is accepted or dropped, the whole request answers `400 all_items_invalid`, with the same `errors` list.

Every item code is listed under [Item errors](https://clickclacks.io/docs/api.md#item-errors).

## 7. Use strict mode in CI {#strict}

When a partial success is worse than none, such as in a test or a migration script, add `?strict=true`. One invalid item refuses the whole batch and nothing is stored:

```bash title="terminal"
curl "https://app.clickclacks.io/api/v1/batch?strict=true" \
  -H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
  -H "Content-Type: application/json" \
  --data @batch.json
```

```http title="response"
HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "strict_item_invalid",
    "message": "strict=true refuses the whole batch when any item is invalid; nothing was stored.",
    "docs_url": "https://app.clickclacks.io/docs/api#strict_item_invalid",
    "request_id": "…"
  },
  "errors": [
    { "index": 2, "code": "missing_identity", "field": "distinct_id", "message": "…" }
  ]
}
```

- The response names the first invalid item only. Fix it and send again to find the next.
- If every item is invalid, the code is `all_items_invalid` instead.
- One check runs after items are accepted (the 8 KiB properties limit, counted with ClickClacks’ own properties), so that error can still come back in a `202`.
- Combine it with `?validate=true` in CI to check payloads without storing anything.

## 8. Handle request errors {#request-errors}

When the whole request is refused, the answer has the same shape every time: a stable `code`, a `message` that may change, a `docs_url` and a `request_id`. Try it with a wrong key:

```http title="response"
HTTP/1.1 401 Unauthorized

{
  "error": {
    "code": "invalid_key",
    "message": "…",
    "docs_url": "https://app.clickclacks.io/docs/api#invalid_key",
    "request_id": "…"
  }
}
```

| Answer | What to do |
| --- | --- |
| `400`, `401`, `403`, `404`, `405`, `415` | Fix the request. Don’t retry it unchanged. |
| `413 payload_too_large` | Split the batch and resend both halves. |
| `429 rate_limited` | Wait for `Retry-After` seconds, then resend. Nothing in that request was stored. |
| `500`, `503 collection_unavailable` | Resend the whole request with the same `insert_id`s, with backoff. |

Include the `request_id` if you contact support. The full list is under [Request errors](https://clickclacks.io/docs/api.md#request-errors).

## 9. Retry safely {#retries}

Networks fail after a request has landed, so retries are normal. They are safe as long as every retry carries the same `insert_id`s, so build the body once and resend those same bytes. With curl, that is a few flags:

```bash title="send-batch.sh"
#!/usr/bin/env bash
# send-batch.sh: send batch.json, retrying only what is worth retrying.
set -euo pipefail

# --retry retries timeouts, 408, 429, 500, 502, 503 and 504, and honours Retry-After.
# The body is the same file every time, so every retry carries the same insert_ids.
response=$(curl --silent --show-error --fail-with-body \
  --retry 6 --retry-max-time 300 --max-time 10 \
  https://app.clickclacks.io/api/v1/batch \
  -H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
  -H "Content-Type: application/json" \
  --data @batch.json) || { echo "ClickClacks refused the batch: $response" >&2; exit 1; }

# A 202 can still list refused items. Log them; resending them unchanged won't help.
echo "$response"
```

> **What you should see**
>
> On success, the `202` body with `accepted`, `dropped` and `errors`. On a refused request, the error body and exit code 1. On a brief outage or a `429`, a short pause, then the same success, with nothing counted twice.

For the same policy in Python, Go, Ruby and PHP (exponential backoff with jitter, up to 30 seconds, `Retry-After` honoured up to 5 minutes, 6 retries), see [With safe retries](https://clickclacks.io/docs/http.md#retries). For big batches, gzip the body; the 1 MiB limit applies after decompression ([Large batches](https://clickclacks.io/docs/http.md#gzip)).

## Common mistakes {#mistakes}

- **A new `insert_id` on each retry.** A random ID made inside the retry loop defeats the point: every retry that lands is stored again. Make it once, with the item.
- **Retrying a `400`.** It will fail the same way. Read `code`, fix the request, then send.
- **Treating `202` as “all stored”.** Check `errors` and `dropped` in the body.
- **Empty properties as a JSON array.** Some languages encode an empty map as `[]`, which is refused as `invalid_properties`. Leave `properties` out, or send `{}`.
- **Sending to your custom tracker domain.** The server API is served on `app.clickclacks.io` only.
- **Backfilling old history.** Items older than 7 days are refused with `timestamp_too_old`. A separate import endpoint is coming soon.

## Next {#next}

That completes the developer track. Hand the data to your analysts, or see it through their eyes: [Find where signups drop off](https://clickclacks.io/docs/walkthroughs/activation-funnel.md). If you still need to agree what to track, start with the [tracking plan](https://clickclacks.io/docs/walkthroughs/tracking-plan.md).

## Related {#related}

- [Send events](https://clickclacks.io/docs/api.md): Items, properties, limits and every error code.
- [Any language](https://clickclacks.io/docs/http.md): Python, Go, Ruby and PHP with retries.
- [Identity from the server](https://clickclacks.io/docs/server-identity.md): Which person each item lands on.
- [Limits](https://clickclacks.io/docs/limits.md): Every limit in one table.
