Loopdocs

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

every request
x-loop-key: wk_live_YOUR_KEY
content-type: application/json

Not 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

The public surface. Everything under /api/v1/* is the console's own API and is not documented here.
EndpointKeysOKWhat it is for
POST /v1/eventspublic, secret202Ingest. Accepts and enqueues.
POST /v1/identitiespublic, secret202Anonymous to known, and the stitch.
GET /v1/events/healthpublic200Is data arriving?
POST /v1/decisionspublic200What this person should see, and which arms they are in.
POST /v1/surfaces/{id}/interactionspublic202Views, clicks, dismissals, tour steps and tour ends.
POST /v1/consentpublic202Consent state, which gates everything above.
POST /v1/experiments/{id}/exposurepublic200Record that this person was exposed, and get their arm back.
GET /v1/messages/o/{id}none200Open pixel. Loop puts this in an email; you never call it.
GET /v1/messages/c/{id}none302Signed click redirect. Same: Loop generates the link.
GET /loop.jsnone200The 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.

request
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"
    }
  ]'
202 Accepted
{ "accepted": 1, "rejected": 0, "request_id": "7c1e…" }
The event body.
FieldRule
nameRequired. snake_case, 1–200 characters. The dollar prefix is reserved for events Loop generates.
distinct_idRequired. 1–255 characters. Who this happened to.
propertiesAny JSON object, subject to the limits below.
timestampISO-8601 with an offset. Defaults to arrival. Always send it for replays and backfills.
event_idA 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.

Ingest limits: part of the contract, enforced at the edge.
LimitValue
Events per request1–500
Bytes per event32,768 (32KB), measured over the whole event as UTF-8
Properties per event255
Property nesting depth3

POST /v1/identities

request body
{
  "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.

one channel per call
{
  "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 envelope
{
  "error": {
    "code": "validation_failed",
    "message": "invalid identify payload",
    "details": { "issues": [] }
  }
}
The whole registry. Codes are never invented inline.
CodeStatusRetry?
validation_failed400No
unauthenticated401No
forbidden403No
not_found404No
conflict409No
payload_too_large413No
rate_limited429Yes
internal500Yes
overloaded503Yes