Skip to content
Docs/API

Errors and limits

Everything both public APIs share: the error shape, what each status means, the rate limits, and how to retry without making things worse.

Every non-2xx response is JSON:

{ "error": "a valid email is required" }

The error string is meant for your logs, not your users, and its wording can change. Branch on the status code.

StatusWhat it meansWhat to do
400The request was malformed — bad JSON, a missing required query parameter.Fix the request. Retrying won’t help.
401The key is missing, unknown, revoked, or the wrong type for this API.Check the key and which API you’re calling. Don’t retry in a loop — repeated failures throttle your IP.
403You called a server-side API from a browser (an Origin header was present), or a publishable key’s origin allowlist rejected the page.Move the call to your server, or add the origin to the key.
404No such record — in your factory.Nothing to retry.
405Wrong HTTP method. The Allow header lists what’s accepted.Fix the method.
413Body over the 512 KiB cap.Send less. Split large payloads.
422The JSON parsed but the content is invalid — a malformed email, an unknown enum value.Fix the data. Retrying identical input fails identically.
429Rate limited.Wait Retry-After seconds, then retry.
503We couldn’t complete the operation right now.Retry with backoff.

A 503 is deliberately not a 401. A database blip on our side must never tell you your key is bad — that’s a lie you might act on by rotating a perfectly good credential.

The public APIs don’t distinguish “no such key”, “revoked key”, “key for the other API”, or “wrong tenant”. They’re all 401 with the same message. Anything more specific would let someone with a harvested key learn things about your account by watching how the errors differ.

Same reasoning behind 404 instead of 403 on records that exist but aren’t yours.

LimitDefaultApplies to
Per publishable key6,000/min (100/s)the bug-reporting API
Per secret key30,000/min (500/s)the contacts API
Per IP, bug reporting1,200/min (20/s)the bug-reporting API
Per IP, contacts30,000/min (500/s)the contacts API
Failed keys per IP30/minboth, shared — a leaked-key probe gets shut down fast

Limits are fixed windows. When you exceed one:

HTTP/1.1 429 Too Many Requests
Retry-After: 23
{ "error": "key rate limit" }

Retry-After is in seconds: how long until the current window rolls over. Wait that long rather than guessing — retrying sooner just burns the budget you’re waiting for.

The two APIs have separate per-IP budgets, so a busy website doesn’t eat into your backend’s throughput — and a single server can actually reach the 500/s a secret key allows. The failed-key budget is shared: repeatedly presenting bad keys to either API throttles both from that address.

Need more headroom for a legitimate workload? Ask — the ceilings are configurable, and telling us about a bulk import beforehand is easier than discovering it as a 429 storm.

The contacts upsert is idempotent. The contact id is derived from the email, so retrying after a timeout, a 429, or a 503 updates the same record instead of creating a duplicate. You don’t need an idempotency key.

Error ingest is idempotent on the eventId your SDK sends, and identical errors are grouped server-side regardless — a flood becomes one issue, not thousands.

A retry policy that works for both:

  1. Retry on 429, 503, and network/timeout errors. Nothing else.
  2. On 429, sleep exactly Retry-After. On 503, exponential backoff with jitter starting around one second.
  3. Cap at a handful of attempts and log the failure. Don’t retry forever — an infinite retry loop against a 429 is indistinguishable from an attack, and it will be treated like one.
  4. Never retry 4xx other than 429. The request is wrong; sending it again won’t make it right.

Both APIs cap request bodies at 512 KiB and answer 413 above it. The bug-reporting API also caps a batch at 50 events; anything beyond that is explicitly rejected in the per-event acknowledgements rather than silently dropped, so your ack list always covers everything you sent.

Responses from the secret API carry Cache-Control: no-store. They’re per-caller data behind a credential and must not land in a shared cache.