# Errors

Every error is a small JSON problem object. The status tells you whether to fix, wait or retry.

## The problem format

When a request fails, the API answers with an HTTP error status and a body of type `application/problem+json`:

| Field | What it holds |
| --- | --- |
| `type` | A URI that names the kind of problem, such as `https://api.sockodds.com/problems/404`. Use it or `status` to decide what to do; you do not need to open it. |
| `title` | A short summary that is the same for every problem of this kind, such as `Not found`. |
| `status` | The HTTP status code, repeated in the body. |
| `detail` | What went wrong with this request, in plain words, such as `Invalid starts_after`. |
| `instance` | The path and query of the request that failed. |

Here is a request for an event id that is well formed but does not exist:

```bash
curl -sS "https://api.sockodds.com/v1/events/ev_000000000000" \
  -H "Authorization: Bearer $SOCKODDS_API_KEY"
```

```javascript
const response = await fetch("https://api.sockodds.com/v1/events/ev_000000000000", {
  headers: { Authorization: `Bearer ${process.env.SOCKODDS_API_KEY}` },
});
console.log(JSON.stringify(await response.json(), null, 2));
```

```python
import json, os, urllib.error, urllib.request

request = urllib.request.Request(
    "https://api.sockodds.com/v1/events/ev_000000000000",
    headers={"Authorization": "Bearer " + os.environ["SOCKODDS_API_KEY"]},
)
try:
    with urllib.request.urlopen(request) as response:
        body = json.load(response)
except urllib.error.HTTPError as error:
    body = json.load(error)  # the problem object
print(json.dumps(body, indent=2))
```

Response (from a local copy of the API with invented test prices):

```json
{
  "type": "https://api.sockodds.com/problems/404",
  "title": "Not found",
  "status": 404,
  "detail": "Event not found.",
  "instance": "/v1/events/ev_000000000000"
}
```

And a parameter the API cannot read. Times must be UTC, written like `2026-10-15T08:00:00Z`:

```bash
curl -sS "https://api.sockodds.com/v1/odds?league=epl&starts_after=tomorrow" \
  -H "Authorization: Bearer $SOCKODDS_API_KEY"
```

```javascript
const response = await fetch("https://api.sockodds.com/v1/odds?league=epl&starts_after=tomorrow", {
  headers: { Authorization: `Bearer ${process.env.SOCKODDS_API_KEY}` },
});
console.log(JSON.stringify(await response.json(), null, 2));
```

```python
import json, os, urllib.error, urllib.request

request = urllib.request.Request(
    "https://api.sockodds.com/v1/odds?league=epl&starts_after=tomorrow",
    headers={"Authorization": "Bearer " + os.environ["SOCKODDS_API_KEY"]},
)
try:
    with urllib.request.urlopen(request) as response:
        body = json.load(response)
except urllib.error.HTTPError as error:
    body = json.load(error)
print(json.dumps(body, indent=2))
```

Response (from a local copy of the API with invented test prices):

```json
{
  "type": "https://api.sockodds.com/problems/400",
  "title": "Invalid request",
  "status": 400,
  "detail": "Invalid starts_after",
  "instance": "/v1/odds?league=epl&starts_after=tomorrow"
}
```

## The statuses the API returns

| Status | Title | What it means | What to do |
| --- | --- | --- | --- |
| 400 | `Invalid request` | A parameter is malformed or out of range. `detail` names it. | Fix the request. Retrying it unchanged gets the same answer. |
| 401 | `Unauthorised` | The key is missing, malformed, unknown or rotated. | Check the `Authorization` header and your current key. See [Authentication](https://sockodds.com/docs/authentication/). |
| 401 | `Access changed` | The key was rotated, or its plan changed, while the request was in flight. | Retry with your active key. |
| 403 | `Plan restriction` | Your plan does not include this league or feature (history, snapshots, streaming). | Choose something your plan covers, or change plan on [Pricing](https://sockodds.com/pricing/). |
| 404 | `Not found` | The event does not exist. | Check the id. Do not retry. |
| 426 | `Upgrade required` | You called the stream with plain HTTP. | Connect with a WebSocket. See [Streaming](https://sockodds.com/docs/streaming/). |
| 429 | `Rate limit reached` | Too many requests this minute. | Wait for the `Retry-After` header (in seconds), then retry. |
| 429 | `Monthly allowance reached` | Your plan's objects for this month are used. | Do not retry: it will not clear this month. Upgrade on [Pricing](https://sockodds.com/pricing/). |
| 429 | `Stream limit reached` | This key already has its maximum of open streams. | Close a stream first. |
| 503 | `Service unavailable` | The API could not answer just now. | Retry after a short wait, backing off each time. |

### Common 400 details

| `detail` | Cause |
| --- | --- |
| `Invalid event ID.` or `Invalid event_id` | The id is not in the `ev_` format. See [Identifiers](https://sockodds.com/docs/identifiers/). |
| `Invalid starts_after`, `Invalid starts_before`, `Invalid from`, `Invalid to` | Not a UTC time ending in `Z`, or a date that does not exist. Offsets such as `+10:00` are refused: convert to UTC first. |
| `Invalid league`, `Invalid markets`, `Invalid bookmakers`, `Invalid regions` | A key with capitals or other characters a key cannot have, or more than 20 keys in one list. Keys are lower case, such as `epl` or `total_goals`. |
| `Invalid alternates` | Use `true` or `false`. |
| `Invalid pagination` | `limit` is outside the range for the route. See [Pagination](https://sockodds.com/docs/pagination/). |
| `Invalid cursor` | The cursor came from a different query. Follow `links.next` unchanged, or start again from the first page. |
| `Invalid snapshots: request snapshots for one event.` | Snapshots work on `/v1/events/{event_id}/odds` only. See [History and snapshots](https://sockodds.com/docs/history/). |
| `Choose a league for this plan.` | On the free Developer plan, teams, players and results need a `league`. |
| `Use a valid event and a history window of at most 24 hours ...` | The history window is too long, ends before it starts, or the event id is malformed. |

### What is not an error

- A filter that matches nothing returns `200` with an empty `data` list. A league key that is well formed but unknown, or a bookmaker that is not quoting, does the same. An empty list is not evidence that a price or result is zero.
- A price older than ten minutes is left out of current responses, not reported as an error. See [Reading prices](https://sockodds.com/docs/odds/).
- A path that does not exist returns 404 too, but its body is not a problem object. Check the path against the [API reference](https://sockodds.com/docs/).

## Retrying safely

Only two kinds of failure are worth retrying on their own: `429 Rate limit reached`, which sends a `Retry-After` header, and `503 Service unavailable`. Everything else needs a change first.

- On `429` with `Retry-After`, wait that many seconds before the next request.
- A `429` without `Retry-After` is the monthly allowance or the stream limit. Retrying will not help.
- On `503`, back off: wait one second, then two, then four, and give up after a few attempts.
- Every request you send counts towards your per-minute limit, including ones refused with 400 or 403. Errors are not charged as objects. See [Limits](https://sockodds.com/docs/limits/).

This program wraps requests in a small helper that does exactly that, then asks for an event that does not exist:

```javascript
const API = "https://api.sockodds.com/v1";

async function get(path) {
  for (let attempt = 1; ; attempt += 1) {
    const response = await fetch(API + path, {
      headers: { Authorization: `Bearer ${process.env.SOCKODDS_API_KEY}` },
    });
    if (response.ok) return response.json();
    const problem = await response.json();
    const retryAfter = response.headers.get("retry-after");
    const retry = response.status === 503 || (response.status === 429 && retryAfter !== null);
    if (!retry || attempt === 3) throw new Error(`${problem.status} ${problem.title}: ${problem.detail}`);
    const seconds = retryAfter !== null ? Number(retryAfter) : 2 ** (attempt - 1);
    await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
  }
}

try {
  await get("/events/ev_000000000000");
} catch (error) {
  console.log(error.message);
}
```

```python
import json, os, time, urllib.error, urllib.request

API = "https://api.sockodds.com/v1"


def get(path):
    attempt = 1
    while True:
        request = urllib.request.Request(
            API + path,
            headers={"Authorization": "Bearer " + os.environ["SOCKODDS_API_KEY"]},
        )
        try:
            with urllib.request.urlopen(request) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            problem = json.load(error)
            retry_after = error.headers.get("Retry-After")
            retry = error.code == 503 or (error.code == 429 and retry_after is not None)
            if not retry or attempt == 3:
                raise RuntimeError(f"{problem['status']} {problem['title']}: {problem['detail']}")
            time.sleep(float(retry_after) if retry_after is not None else 2 ** (attempt - 1))
            attempt += 1


try:
    get("/events/ev_000000000000")
except RuntimeError as error:
    print(error)
```

Output (from a local copy of the API with invented test prices):

```text
404 Not found: Event not found.
```

Errors on an open stream arrive as an `error` frame holding the same problem object, followed by a close code. See [Streaming](https://sockodds.com/docs/streaming/).
