# Errors & limits

Every non-2xx response uses one envelope with a stable machine-readable code, and every authenticated response carries rate-limit headers.

## The error envelope

```json
{
  "error": {
    "code": "insufficient_credits",
    "message": "insufficient credits",
    "details": { "balance": 120, "cost": 636 }
  }
}
```

`code` is stable, so branch on it. `message` is human-readable and may change. `details` appears only when there's structured context to give.

## Error codes

| Code | Status | Meaning | Retry? |
| --- | --- | --- | --- |
| `unauthorized` | 401 | Missing, invalid or revoked API key. | Don't retry: fix the key. |
| `invalid_request` | 400 | Malformed JSON or invalid fields (bad inputs, oversized schema, bad webhookUrl, bad limit/cursor…). The message says which. | Don't retry: fix the request. |
| `invalid_url` | 400 | The url field isn't a parseable URL. | Don't retry: fix the URL. |
| `unsupported_url` | 400 | The URL parses but isn't a supported video platform link. | Don't retry with the same URL. |
| `insufficient_credits` | 402 | Your balance can't cover the quoted price. details.balance and details.cost are in credits. | Retry after topping up credits. |
| `rate_limited` | 429 | Per-minute request window exceeded for this endpoint. | Retry after the Retry-After header (seconds). |
| `not_found` | 404 | No such extraction/file/schema belongs to your account. | Don't retry: check the id. |
| `conflict` | 409 | Idempotency-Key reused with a different request body. | Don't retry: use a new key or the original body. |
| `internal_error` | 500 / 502 | Something failed on our side (502 when the video couldn't be fetched right now). | Safe to retry with backoff; use an Idempotency-Key on submits. |

## Rate limits

Limits are per account, per endpoint, on a fixed one-minute window. Every authenticated response includes:

```bash
X-RateLimit-Limit: 60       # ceiling for this window
X-RateLimit-Remaining: 58   # requests left
X-RateLimit-Reset: 1765459200  # unix seconds when the window resets
```

| Endpoint | Limit |
| --- | --- |
| POST /v1/extractions (and /v1/extract) | 6 / minute |
| POST /v1/probe | 10 / minute |
| GET /v1/extractions/:id and /:id/result (shared bucket) | 60 / minute |
| All other endpoints | 60 / minute each |

On `429 rate_limited` a `Retry-After` header (seconds) tells you exactly how long to wait. Separately from request limits, at most **2 extractions** run per account at once. Extra submissions are accepted with status `queued` and start automatically when a slot frees.

## Idempotency

Submits are not safely retryable by default: a retried `POST /v1/extractions` creates (and charges) a second extraction. Send an `Idempotency-Key` header (any unique string, e.g. a UUID) to make them safe:

- First request with a key: normal behaviour, `201`.
- Retry with the **same key and same body**: the original extraction is returned with `200`, and nothing new is created or charged.
- Same key, **different body**: `409 conflict`.

## Pagination

List endpoints use cursor pagination. Pass `limit` (1–100, default 20) and the `next_cursor` from the previous page as `cursor`:

```json
{
  "data": [ ... ],
  "has_more": true,
  "next_cursor": "eyJ0IjoiMjAyNi0wNi0xMVQ..."
}
```

Cursors are opaque, so never construct or modify them. Results are ordered newest first.
