# Errors

One shape, for every failure:

```json
{
  "error": {
    "code": "SUBSCRIPTION_REQUIRED",
    "message": "An active subscription is required for this resource.",
    "request_id": "req_4f8a2b1c9d0e3f5a67"
  }
}
```

**`code` is the contract.** It is frozen and it is what your integration should
branch on.

**`message` is prose.** It is for a human reading a log, and it may be reworded
between releases. An integration that matches on message text will break.

**`request_id`** appears in the body and in the `X-Request-Id` header. It is in
our logs against the same request. Quote it.

## The codes

### 400 — the request

| Code | Meaning |
|---|---|
| `BAD_REQUEST` | The request could not be understood. |
| `INVALID_PARAMETER` | A query parameter is out of range or malformed. The message names what. |
| `INVALID_CURSOR` | The cursor is not valid for this collection, or it is older than the retained window. |
| `REQUEST_TOO_LARGE` | 413. The body exceeds the limit. |

### 401 / 403 — identity and entitlement

| Code | Status | Meaning |
|---|---|---|
| `INVALID_API_TOKEN` | 401 | No token, or one we do not recognise. Identical for an unknown token and one that never existed. |
| `CLIENT_SUSPENDED` | 403 | The account is suspended. Reversible by an administrator. |
| `CLIENT_REVOKED` | 403 | The account is revoked. Final. |
| `SUBSCRIPTION_REQUIRED` | 403 | No active subscription for this product: none, expired, or not started. |
| `FEATURE_NOT_ENABLED` | 403 | The product is active but this add-on is not. |
| `ORIGIN_NOT_ALLOWED` | 403 | The request's `Origin` is not in the account's allowlist. |

`SUBSCRIPTION_REQUIRED` covers "never subscribed", "expired" and "starts
tomorrow" with one code on purpose. Which of the three it is appears on
`/account`, where you are authenticated to see it, rather than on every refusal.

### 404 — not here

`NOT_FOUND`, `FIXTURE_NOT_FOUND`, `MARKET_NOT_FOUND`.

A fixture that belongs to another account is `FIXTURE_NOT_FOUND`, the same as one
that does not exist. There is no response that confirms another account holds a
given fixture.

### 405 / 409

| Code | Status | Meaning |
|---|---|---|
| `METHOD_NOT_ALLOWED` | 405 | The API is read-only. `Allow` says what is accepted. |
| `CONFLICT` | 409 | The request conflicts with current state. |
| `PROVIDER_NOT_CONNECTED` | 409 | No usable provider credential. Connect one in the panel. |

### 429

`RATE_LIMITED`, with `Retry-After` in seconds. Per account, not per address.

### 5xx

| Code | Status | Meaning |
|---|---|---|
| `INTERNAL_ERROR` | 500 | Ours. The detail is in our logs under your `request_id`. |
| `UPSTREAM_UNAVAILABLE` | 502 | Your provider could not be reached. |
| `DATA_NOT_READY` | 503 | No processed data yet for this account, or a fixture is outside the bounded live detail set. |
| `SERVICE_UNAVAILABLE` | 503 | Temporary. |

## What an error never contains

No stack trace. No filesystem path. No provider URL. No upstream error body. No
credential, in any form.

An internal failure returns `INTERNAL_ERROR` and a request id, and nothing else.
This is deliberate: an error message is the most reliable way to learn the inside
of a service, and a message that helps you debug our code helps an attacker map
it.

## How to retry

| Code | Retry? |
|---|---|
| `RATE_LIMITED` | Yes, after `Retry-After`. |
| `UPSTREAM_UNAVAILABLE` | Yes, with exponential backoff. |
| `DATA_NOT_READY` | Yes, in minutes. |
| `SERVICE_UNAVAILABLE` | Yes, with backoff. |
| `INTERNAL_ERROR` | Once, then report it with the request id. |
| `INVALID_CURSOR` | No. Re-bootstrap from `/fixtures`. |
| `INVALID_API_TOKEN` | No. The token is wrong until you change it. |
| `CLIENT_SUSPENDED`, `CLIENT_REVOKED` | No. An administrator must act. |
| `SUBSCRIPTION_REQUIRED`, `FEATURE_NOT_ENABLED` | No. Nothing changes until the subscription does. |
| `ORIGIN_NOT_ALLOWED` | No. Add the origin to your allowlist. |
| `PROVIDER_NOT_CONNECTED` | No. Connect a provider credential first. |
| `BAD_REQUEST`, `INVALID_PARAMETER`, `REQUEST_TOO_LARGE`, `METHOD_NOT_ALLOWED`, `CONFLICT`, `NOT_FOUND`, `FIXTURE_NOT_FOUND`, `MARKET_NOT_FOUND` | No. Fix the request. |

## Handling it once

```js
async function get(path) {
  const response = await fetch(BASE + path, {
    headers: { Authorization: 'Bearer ' + TOKEN },
  });
  const body = await response.json();
  if (response.ok) return body;

  const { code, request_id } = body.error;
  switch (code) {
    case 'RATE_LIMITED':
      await sleep(Number(response.headers.get('Retry-After') || 60) * 1000);
      return get(path);
    case 'DATA_NOT_READY':
    case 'UPSTREAM_UNAVAILABLE':
    case 'SERVICE_UNAVAILABLE':
      throw new RetryableError(code, request_id);
    case 'INVALID_CURSOR':
      throw new ResetCursorError(code, request_id);
    default:
      throw new PermanentError(code, request_id);
  }
}
```

The full list is also available at runtime from `GET /api/v1/schema` as
`error_codes`, and in [`openapi-v1.yaml`](openapi-v1.yaml) as the `code` enum —
both generated from the same source as the server's own contract.
