> ## Documentation Index
> Fetch the complete documentation index at: https://docs.traceten.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and status codes

> Every status code the Traceten API returns, what causes it, and how to fix it.

## 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

| Code          | Meaning                                                                | Retry?                 |
| ------------- | ---------------------------------------------------------------------- | ---------------------- |
| `200` / `201` | Success.                                                               | No                     |
| `400`         | The request body is malformed.                                         | No, fix the request    |
| `401`         | We could not authenticate you.                                         | No, fix the credential |
| `403`         | We authenticated you, but you are not allowed to do this.              | No, see below          |
| `404`         | The thing does not exist, or your key cannot reach it.                 | No                     |
| `409`         | Conflicts with something that already exists.                          | No                     |
| `422`         | The request is well-formed but a value is invalid.                     | No, fix the value      |
| `429`         | You are sending requests too quickly.                                  | Yes, after backing off |
| `500`         | Something broke on our side.                                           | Yes                    |
| `503`         | We could not complete the request because a dependency is unavailable. | Yes                    |

## 401: authentication failed

```json theme={null}
{ "error": "Unauthorized" }
```

The ingestion endpoints return a lowercase variant:

```json theme={null}
{ "error": "unauthorized" }
```

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](/api/authentication#permissions) this endpoint requires.

```json theme={null}
{
  "status": "error",
  "error": "insufficient_scope",
  "message": "This API key does not carry the \"ingest:write\" scope.",
  "required_scope": "ingest:write",
  "statusCode": 403
}
```

**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](/api/authentication#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:

| You authenticated with | Response        |
| ---------------------- | --------------- |
| A dashboard session    | `403 Forbidden` |
| An API key             | `404 Not found` |

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:

```json theme={null}
{
  "error": "Validation failed",
  "statusCode": 422,
  "fields": [{ "field": "site_id", "message": "Invalid uuid" }]
}
```

Rejected inside a goals or funnels handler:

```json theme={null}
{
  "error": "validation_failed",
  "message": "Invalid uuid",
  "field": "site_id"
}
```

Endpoints added from now on use the [standard envelope](#the-response-envelope):

```json theme={null}
{
  "status": "error",
  "error": "validation_failed",
  "message": "Invalid uuid",
  "field": "site_id",
  "statusCode": 422
}
```

**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`:

```json theme={null}
{
  "status": "error",
  "error": "rate_limited",
  "message": "Rate limit exceeded, retry in 12 seconds",
  "statusCode": 429
}
```

The ingestion endpoints on `ingest.traceten.com` return the bare form instead:

```json theme={null}
{ "error": "rate_limited" }
```

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:

| Header                  | Meaning                                                                |
| ----------------------- | ---------------------------------------------------------------------- |
| `x-ratelimit-limit`     | The size of your bucket.                                               |
| `x-ratelimit-remaining` | Requests left in it right now.                                         |
| `x-ratelimit-reset`     | Seconds until the bucket is **full** again.                            |
| `retry-after`           | Seconds until the **next** request is allowed. Sent only with a `429`. |

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

```json theme={null}
{ "error": "unavailable" }
```

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:

```json theme={null}
{
  "status": "success",
  "data": { "visitors": 1284, "sessions": 1502 }
}
```

Success with pagination:

```json theme={null}
{
  "status": "success",
  "data": [{ "country": "US", "visitors": 812 }],
  "pagination": { "limit": 50, "offset": 0, "has_more": true }
}
```

Failure:

```json theme={null}
{
  "status": "error",
  "error": "validation_failed",
  "message": "`from` and `to` must be supplied together",
  "field": "to",
  "statusCode": 422
}
```

`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

* [API keys and permissions](/api/authentication)
* [Goals](/api/goals)
* [Funnels](/api/funnels)
