A valid request URL is required to generate request examples{
"accepted": 1,
"rejected": []
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}Track events from your backend
The server-side half of track(): a batch of up to 100 events (256 KiB) that your own backend sends, each one naming the contact it belongs to.
It exists for the facts that never happen in a browser — a payment confirmed by a webhook, a contract countersigned, an export a worker finished. Those are exactly the facts an onboarding step is usually waiting on, and until this door existed they could only arrive through POST /v1/events, which cannot name anybody: an event with no contact advances no checklist for anybody, silently.
Every line must name a contact, with external_id or contact_id — one of the two. Both on one line is ambiguous_contact and neither is contact_required: a line naming two people is one we would have to guess about, and a line naming nobody is the failure this endpoint exists to end. The key’s environment scopes the lookup, so a live external_id sent with a test key resolves to nothing and comes back as unknown_contact.
A line may also name the TEAM it happened inside, with customer_id or customer_external_id — one of the two, never both (ambiguous_customer). It is optional, and leaving it out is an answer: the fact is then the person’s own, and the staff dossier lists it whichever of their teams somebody is reading. A team you name and we cannot resolve in this environment is unknown_customer and the line is refused rather than filed as teamless — saying which team and being ignored is worse than not saying. Membership is not checked: your backend is the authority on which of your teams a fact belongs to, the same claim that lets it name the contact.
It never creates a contact. Identify is one call away (POST /v1/contacts), and an ingest endpoint that minted contacts would add rows to your active-contact meter — which is billed — as a side effect of sending analytics.
202 means accepted, never durable, and validation is per event — both exactly as POST /v1/events describes them. The line codes are that endpoint’s plus the two above: invalid_id, reserved_name (the $ prefix is UserKit’s own namespace, refused here as firmly as anywhere), invalid_name, properties_too_large, invalid_properties, invalid_occurred_at, occurred_at_out_of_range, contact_required, ambiguous_contact, unknown_contact, ambiguous_customer, unknown_customer.
Send an Idempotency-Key if a client, a queue or a deploy might retry this call — the retry replays the first response instead of counting a funnel twice.
Rate limited per key (1000/min) with a 3000/min per-environment ceiling behind it, and the same 6000/min analytics ceiling POST /v1/events spends.
A valid request URL is required to generate request examples{
"accepted": 1,
"rejected": []
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}{
"error": {
"code": "forbidden",
"message": "your role does not allow this action"
}
}Authorizations
An organization API key, uk_sk_live_… or uk_sk_test_…. The environment is resolved from the stored key row on every request, never from a request parameter.
Every route behind this credential is rate limited per key — 1000 requests a minute — with a second ceiling of 3000 a minute across all the keys of one environment, so splitting your traffic across keys isolates it and minting more keys does not buy more of it. A test key can never spend a live key's allowance. Every response carries the current state in headers; see the rate-limits guide.
Headers
A key you choose — a UUID, or your own identifier for the operation — that makes this write safe to retry. Send the same key again and you get the same response back: the same status, the same body, and Idempotent-Replay: true. The handler does not run a second time.
The key is scoped to your API key and the route, so keys never collide between environments or tenants. It is remembered for 24 hours. Reusing it for a different request is refused (422), and a second request arriving while the first is still running is refused too (409) — retry that one in a moment.
A request that failed with a 5xx or was rate limited leaves the key spendable: retry it with the same key.
255Body
100Show child attributes
Show child attributes