Reference
HTTP API
The public API is versioned in the path, takes and returns snake_case JSON, and speaks UTC ISO-8601. Every request carries a write key in x-loop-key, and the project is derived from that key, never from the body.
Authentication
x-loop-key: wk_live_YOUR_KEY
content-type: application/jsonNot Authorization: Bearer.
The guard reads x-loop-key and nothing else. Omit it and you get 401 unauthenticated with the message: missing x-loop-key header.
Three ways a request is refused before it reaches any handler:
- 401: no header, an unknown key, a revoked key, or a prefix that does not match the stored kind.
- 403: a public key from an origin that is not on its allowlist, or a secret key presented from a browser. Both are recorded against the origin that caused them.
- 403: a secret key on anything other than /v1/events and /v1/identities. A secret key is an ingest credential and may only append. Use a public key or a console session for everything else.
Endpoints
| Endpoint | Keys | OK | What it is for |
|---|---|---|---|
| POST /v1/events | public, secret | 202 | Ingest. Accepts and enqueues. |
| POST /v1/identities | public, secret | 202 | Anonymous to known, and the stitch. |
| GET /v1/events/health | public | 200 | Is data arriving? |
| POST /v1/decisions | public | 200 | What this person should see, and which arms they are in. |
| POST /v1/surfaces/{id}/interactions | public | 202 | Views, clicks, dismissals, tour steps and tour ends. |
| POST /v1/consent | public | 202 | Consent state, which gates everything above. |
| POST /v1/experiments/{id}/exposure | public | 200 | Record that this person was exposed, and get their arm back. |
| GET /v1/messages/o/{id} | none | 200 | Open pixel. Loop puts this in an email; you never call it. |
| GET /v1/messages/c/{id} | none | 302 | Signed click redirect. Same: Loop generates the link. |
| GET /loop.js | none | 200 | The browser SDK bundle. |
POST /v1/events
Send one event, or an array of up to 500. The response is always 202: your users' page must never wait on our storage.
curl -X POST https://api.loop.app/v1/events \
-H "x-loop-key: wk_live_YOUR_KEY" \
-H "content-type: application/json" \
-d '[
{
"name": "checkout_started",
"distinct_id": "user_8412",
"properties": { "plan": "pro", "seats": 4 },
"timestamp": "2026-07-29T10:14:22.000Z",
"event_id": "1f2b6f2e-1a2b-4c3d-8e4f-5a6b7c8d9e0f"
}
]'{ "accepted": 1, "rejected": 0, "request_id": "7c1e…" }| Field | Rule |
|---|---|
| name | Required. snake_case, 1–200 characters. The dollar prefix is reserved for events Loop generates. |
| distinct_id | Required. 1–255 characters. Who this happened to. |
| properties | Any JSON object, subject to the limits below. |
| timestamp | ISO-8601 with an offset. Defaults to arrival. Always send it for replays and backfills. |
| event_id | A UUID, and the idempotency key: the same id sent twice is stored once. Anything that is not a valid UUID fails validation. |
Validation is per batch, not per event.
One malformed event fails the whole request, so a single bad event_id takes every event in it down. If you are minting ids yourself, mint real UUIDs.
| Limit | Value |
|---|---|
| Events per request | 1–500 |
| Bytes per event | 32,768 (32KB), measured over the whole event as UTF-8 |
| Properties per event | 255 |
| Property nesting depth | 3 |
POST /v1/identities
{
"distinct_id": "user_8412",
"anonymous_id": "anon_9f2c1d3e-…",
"traits": { "email": "ada@example.com", "plan": "pro" }
}traits is required; send an empty object if you have none. anonymous_id is optional and is what stitches the pre-signup history onto this person; a server-side identify has no anonymous history to offer. The worker refuses to fold an anonymous id that already belongs to a different known person, because a false merge fuses two real humans irreversibly while a missed stitch only costs attribution.
POST /v1/consent
{
"distinct_id": "user_8412",
"channel": "surface",
"status": "granted"
}channel is analytics, messaging or surface; status is granted or denied. It answers 202. This is the record the decision engine and the messaging pipeline enforce, and it is not accepted from a secret key. Decisions, interactions, consent and exposure each consume your key's daily quota alongside ingest; past it you get 429 rate_limited, which is retryable, so back off rather than dropping the event.
Errors
Every failure has the same envelope, and the code is stable, so translate on it, never on the message.
{
"error": {
"code": "validation_failed",
"message": "invalid identify payload",
"details": { "issues": [] }
}
}| Code | Status | Retry? |
|---|---|---|
| validation_failed | 400 | No |
| unauthenticated | 401 | No |
| forbidden | 403 | No |
| not_found | 404 | No |
| conflict | 409 | No |
| payload_too_large | 413 | No |
| rate_limited | 429 | Yes |
| internal | 500 | Yes |
| overloaded | 503 | Yes |