Skip to main content

What this lets you do

Tell the difference between a request you should fix, a request you should retry, and a credential you need to replace.

Status codes at a glance

401: authentication failed

The ingestion endpoints return a lowercase variant:
Both are deliberately vague. A missing key, a malformed key, a revoked key, an expired key, and a key scoped to a different site all produce the same response, so you cannot use it to work out which keys exist. How to fix it. Check that you are sending Authorization: Bearer <key>, that the key has not been revoked in Settings → API keys, and that it has not passed its expiry date.

403: missing permission

The key is real, but it does not carry the permission this endpoint requires.
How to fix it. Permissions are fixed when a key is created. Create a replacement key with the permission listed in required_scope, deploy it, then revoke the old one. We name the missing permission on purpose. You have already proved you hold the key, so this tells you nothing you could not read off your own settings page. It says nothing about which sites exist. Not every endpoint answers 403. /v1/ingest/*, /v1/consortium/* and /v1/privacy/* do. Two other groups behave differently:
  • /v1/server/events and /v1/server/conversions return a bare 401. A key is mandatory there and failures are deliberately indistinct.
  • /v1/events and /v1/conversions return no error at all. They are shared with the browser snippet, so a key is optional and is never a reason to reject. A key lacking ingest:write is ignored: the request succeeds and the events are stored, but the request is metered on the bucket shared with all traffic sent under your public site id instead of your key’s own. That lost isolation is the only observable consequence. See permissions.

403 vs 404: why they differ by credential

Ask about a site you do not own and the answer depends on how you authenticated: This is deliberate, not an inconsistency. A signed-in user is already known to us, so 403 is the honest and more useful answer. An API key holder is not, and if a key could tell “this site exists but is not yours” apart from “this site does not exist”, one leaked key could enumerate every site id on the platform. Key callers therefore get the same 404 for both. Do not write code that treats a 404 from an API key as proof that a site was deleted.

422: validation failed

Three shapes exist today. Which one you get depends on where the request was rejected. Rejected by the shared request validator:
Rejected inside a goals or funnels handler:
Endpoints added from now on use the standard envelope:
Branch on error, not on the surrounding shape. The machine-readable code is stable across all three. The message text is written for humans and may be reworded between releases, so do not parse it.

429: rate limited

Back off and retry. Read Retry-After for how long to wait. On api.traceten.com:
The ingestion endpoints on ingest.traceten.com return the bare form instead:
The error code is rate_limited on both, so you can branch on it without knowing which host you hit. On api.traceten.com, every response from a rate-limited endpoint carries these headers, not just the refusals, so you can see your headroom before you run out: The ingestion endpoints send only Retry-After, and only on a 429. That is deliberate: those buckets are per site, and your site id is public to anyone who can read your page source. Publishing a running remaining there would let a stranger read your real-time traffic volume off the rate it drops.

How the limit behaves

Limits are token buckets, not fixed windows. Your bucket holds a minute’s worth of requests and refills one request at a time, spread evenly across the minute. You can spend the whole bucket at once (a burst is fine), and then you are shaped to the refill rate until it recovers. Those last two are different numbers, and mixing them up will cost you throughput. If you exhaust a 120/min bucket, retry-after is about a second (one token), while x-ratelimit-reset is 60, because that is how long a full bucket takes to come back. Retry on retry-after; use x-ratelimit-reset to decide when you have headroom for a burst again. The trickle is the part that matters. A fixed window lets you spend a full allowance just before it resets and another immediately after, briefly doubling your real rate. Because tokens come back one at a time rather than all at once on the minute, there is no such seam to aim at: recovering a full bucket always takes a full minute. Reads and writes are metered separately. One event you send is a cheap append; one analytics query is an aggregate over your whole date range, so no single limit is correct for both. Buckets are per credential. An API key is metered on its own key, and a dashboard session on its own user, so one leaked or noisy key cannot spend another’s headroom. On the ingestion path the limit applies to a bucket tied to your key, sized the same as your plan’s per-site allowance and separate from it. Traffic sent under your public site id cannot consume the quota your authenticated calls rely on. If we cannot reach the store that holds the buckets, requests are allowed, not refused. Rate limiting is a protection against abuse, and an outage in it must not become an outage for you.

503: we could not check

This is not an authentication failure. It means a dependency we needed was unreachable, so we refused rather than guessed. Retry with backoff. /v1/events and /v1/conversions are the exception. They are shared with the browser snippet and never reject, so an unreachable dependency there accepts the request as anonymous rather than returning 503.

The response envelope

Endpoints added as part of the programmable API return a consistent wrapper. Success:
Success with pagination:
Failure:
status is a literal string rather than a boolean or the HTTP code, so you can branch on the body alone. That matters when the status line is not available to you, which is the case for an MCP tool result or a piped CLI response. pagination is omitted entirely when a response is not paginated. It is never null. Endpoints that predate this wrapper return their payload directly and are not being changed, because doing so would break every existing caller for no benefit.

Next