Any language over HTTP

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.

Updated

The 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

  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.
terminal
export CLICKCLACKS_SERVER_KEY=cks_live_example

2. Do a 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:

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"}}]}'
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

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.

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"}}]}'
response
HTTP/1.1 202 Accepted

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

4. Find it in the app

  1. Open Sources and the Pipeline service source.
  2. Open Events, pick Deal created, and expand the row.

5. Send a 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:

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" }
    }
  ]
}
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.

6. Read 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:

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": "…"
}
  • 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.

7. Use strict mode in CI

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:

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
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

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:

response
HTTP/1.1 401 Unauthorized

{
  "error": {
    "code": "invalid_key",
    "message": "…",
    "docs_url": "https://app.clickclacks.io/docs/api#invalid_key",
    "request_id": "…"
  }
}
AnswerWhat to do
400, 401, 403, 404, 405, 415Fix the request. Don’t retry it unchanged.
413 payload_too_largeSplit the batch and resend both halves.
429 rate_limitedWait for Retry-After seconds, then resend. Nothing in that request was stored.
500, 503 collection_unavailableResend the whole request with the same insert_ids, with backoff.

Include the request_id if you contact support. The full list is under Request errors.

9. Retry safely

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

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"

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. For big batches, gzip the body; the 1 MiB limit applies after decompression (Large batches).

Common 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

That completes the developer track. Hand the data to your analysts, or see it through their eyes: Find where signups drop off. If you still need to agree what to track, start with the tracking plan.