> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://statpad.dev/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://statpad.dev/docs/_mcp/server.

# Errors

> Statpad's REST problem responses and stream error messages, every error code with its fix, and the WebSocket close codes.

Every error carries a `code`. Branch on it: `code` values are stable, while `title`, `detail` and `message` are for people and can change.

## REST errors

A failed request answers with an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem, as `application/problem+json`:

```json
{
  "type": "https://statpad.dev/docs/errors/not_found",
  "title": "Not found",
  "status": 404,
  "code": "not_found",
  "detail": "No game 01a1b987-64e7-74e3-8db1-b939d352190d in nfl.",
  "request_id": "01a1b9878f0a7e21"
}
```

`type` links to this page's entry for the code. `request_id` is also in every response's `X-Request-Id` header; include it when you report a problem. `resync_required` adds `min_seq`, and `quota_exhausted` adds `resets_at`.

Errors don't count against your monthly allowance. Only `2xx` and `304` responses do.

## Stream errors

On the WebSocket, an error is a message, and the connection stays open:

```json
{
  "type": "error",
  "id": "sub-2",
  "stream": "game:01a1b987-644f-7459-b8e0-810b314f71da",
  "code": "resync_required",
  "message": "resume seq 40 is older than the retained history, which starts at 1200; subscribe again for a snapshot",
  "min_seq": 1200
}
```

`id` echoes the `id` of the message that caused it, if you sent one. With `stream` set, only that subscription is affected, and you're no longer subscribed to it. Problems with the connection itself close the socket instead; see [close codes](#close-codes).

## Error codes

| `code`                                              | REST status | On the stream      | Retry?                                            |
| --------------------------------------------------- | ----------- | ------------------ | ------------------------------------------------- |
| [`bad_request`](#bad_request)                       | `400`       | Yes                | No, fix the request                               |
| [`unauthorized`](#unauthorized)                     | `401`       | Closes with `1008` | No, fix the key                                   |
| [`forbidden`](#forbidden)                           | `403`       | Yes                | No, check your plan                               |
| [`not_found`](#not_found)                           | `404`       |                    | No                                                |
| [`resync_required`](#resync_required)               | `410`       | Yes                | Yes, from a fresh position                        |
| [`invalid_resume`](#invalid_resume)                 |             | Yes                | Yes, from a fresh position                        |
| [`slow_consumer`](#slow_consumer)                   |             | Yes                | Yes, at once                                      |
| [`too_many_subscriptions`](#too_many_subscriptions) |             | Yes                | After you unsubscribe                             |
| [`unknown_stream`](#unknown_stream)                 |             | Yes                | No, fix the stream name                           |
| [`rate_limited`](#rate_limited)                     | `429`       |                    | After `Retry-After`                               |
| [`quota_exhausted`](#quota_exhausted)               | `429`       |                    | When the period resets                            |
| [`internal`](#internal)                             | `500`       | Yes                | Yes, with backoff                                 |
| [`unavailable`](#unavailable)                       | `503`       | Yes                | Yes, after a short wait (`Retry-After` over REST) |

### `bad_request`

The request is malformed. Over REST, a parameter is missing or invalid: a date that doesn't parse, a `limit` out of range, a cursor that isn't a `next_cursor` from the same list, an `after_seq` beyond the stream's latest change, or a required parameter left out, such as `since` on `/transactions`. On the stream, a message isn't a JSON object with a known `type`, its `id` is over 64 characters, a `subscribe` names no streams or too many, or a stream name isn't a well-formed `game:<game id>` or `league:<alias>`. `detail` or `message` says which. Sending the same request again fails the same way.

### `unauthorized`

The request has no API key, or the key is unknown or revoked. Send it as `Authorization: Bearer <key>`. The response carries a `WWW-Authenticate` header, with `error="invalid_token"` when a key was sent but not accepted. On the stream, a bad key closes the connection with `1008` before `welcome`. Check that the key is current on the Keys page of [your account](https://app.statpad.dev), and see [API keys](/docs/keys).

### `forbidden`

The key is valid, but the account can't read this. Either the account has no active access, because its trial ended, its subscription lapsed or it's suspended, or its plan doesn't include the league or feature. A league alias Statpad doesn't serve also gets `forbidden`, over REST and on the stream, so a typo in the league looks the same as a league you don't have. On the stream, a plan without streaming, such as Developer, gets `forbidden` and then a `1008` close, and losing access to a league ends its subscriptions with `forbidden`. Check your plan and leagues in [your account](https://app.statpad.dev); retrying won't help until they change.

### `not_found`

No such entity: the game, team, player or season in the path doesn't exist in that league. A path that isn't an endpoint at all answers `404` too. Depth charts also answer `404` today; see [Coverage by league](/docs/coverage#not-filled-yet).

### `resync_required`

The position you asked to continue from is older than the history Statpad still keeps for that stream. `min_seq` is the oldest `seq` still held. Over REST, `GET /games/{id}/changes` answers `410` when the changes after `after_seq` are no longer all kept. To recover a game, read it over REST and continue from the `seq` it returns. For any stream, you can also subscribe again without `resume` to get a fresh snapshot. Game streams keep their history for at least 7 days after the game ends, and league streams for at least 30.

### `invalid_resume`

A `resume` seq is beyond the stream's latest change, so it can't be a position the stream gave you. This usually means a seq saved for a different stream. Subscribe without `resume` for a snapshot, or read the game over REST and resume from its `seq`.

### `slow_consumer`

Your client fell behind and the subscription's send buffer filled, so the server dropped you from that stream. `resume_from` is the last `seq` it delivered. Subscribe again with `resume` set to it (or to the last seq you applied) and the journal catches you up. If it keeps happening, handle messages faster: apply changes and hand slow work to a queue instead of doing it in the message handler.

### `too_many_subscriptions`

There are two limits. The account's plan caps subscriptions across every key and connection on the account: 10 on the trial, 20 on Pro and 150 on Business. Separately, one connection holds at most 100 subscriptions, whatever the plan; `welcome` reports it as `max_subscriptions`. Unsubscribe from streams you've finished with, such as games that have ended. Past the account limit, move to a plan with more; past the connection limit, open another connection.

### `unknown_stream`

No game has the id in a `game:<game id>` stream name. Check it against `GET /v1/{league}/games`. A league alias Statpad doesn't serve gets [`forbidden`](#forbidden) instead, and a name that isn't well formed gets [`bad_request`](#bad_request).

### `rate_limited`

You sent requests faster than your plan's rate. Wait `Retry-After` seconds, then send more slowly. See [Rate limits](/docs/get-started/rate-limits).

### `quota_exhausted`

The account has used this billing period's request allowance. `resets_at` says when the next period starts, and `Retry-After` gives the same moment in seconds. Wait for it, or upgrade your plan. See [Rate limits](/docs/get-started/rate-limits).

### `internal`

Something failed on Statpad's side. Retry with exponential backoff. On the stream, the subscription has ended: subscribe again, resuming from the last seq you applied. If it persists, report it with the `request_id`, or for the stream, the `connection_id` from `welcome`.

### `unavailable`

Statpad couldn't check your account or its limits, so it didn't serve the request rather than serve it unmetered. Over REST, wait `Retry-After` seconds and retry. On the stream, the capacity check for a subscription failed and the connection stays open: subscribe again after a short wait.

## Close codes

When the server closes the WebSocket, the close code says why and whether to reconnect. Reconnect with backoff and resume from the last seq you applied, except where the table says not to.

| Code   | Meaning                                                                       | What to do                                                    |
| ------ | ----------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `1001` | The server is restarting.                                                     | Reconnect and resume.                                         |
| `1008` | The key is invalid or revoked, or the account has no active streaming access. | Don't reconnect until the key or plan is fixed.               |
| `1009` | A message you sent was over 64 KiB.                                           | Send smaller messages, such as fewer streams per `subscribe`. |
| `1011` | The server couldn't verify your access or capacity.                           | Reconnect with backoff.                                       |
| `4008` | The account already has as many connections as its plan allows.               | Close a connection you don't need, or reconnect later.        |
| `4009` | Your client didn't answer the server's pings.                                 | Reconnect and resume.                                         |

The [Quickstart](/docs/quickstart#reconnect-without-missing-anything) client's reconnect loop follows this table.