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
- Open Sources, click Add source, choose Server, name it Pipeline service and click Create server source.
- Create a key and copy it from Copy your server key. It starts
cks_live_and is shown once. - Put it in your shell’s environment for this walkthrough, and in your service’s secrets for production.
export CLICKCLACKS_SERVER_KEY=cks_live_example2. 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:
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"}}]}'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.
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"}}]}'HTTP/1.1 202 Accepted
{ "accepted": 1, "dropped": [], "errors": [], "request_id": "…" }4. Find it in the app
- Open Sources and the Pipeline service source.
- 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:
{
"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" }
}
]
}curl https://app.clickclacks.io/api/v1/batch \
-H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
-H "Content-Type: application/json" \
--data @batch.jsontimestampis ISO 8601 withZor 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:
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
indexandcode. Resending an item unchanged won’t help: fix it. droppedlists 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 sameerrorslist.
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:
curl "https://app.clickclacks.io/api/v1/batch?strict=true" \
-H "Authorization: Bearer $CLICKCLACKS_SERVER_KEY" \
-H "Content-Type: application/json" \
--data @batch.jsonHTTP/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_invalidinstead. - 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=truein 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:
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_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:
#!/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_idon 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. Readcode, fix the request, then send. - Treating
202as “all stored”. Checkerrorsanddroppedin the body. - Empty properties as a JSON array. Some languages encode an empty map as
[], which is refused asinvalid_properties. Leavepropertiesout, or send{}. - Sending to your custom tracker domain. The server API is served on
app.clickclacks.ioonly. - 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.