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

# Error codes

> HTTP status codes, error shapes, and how to handle failures.

# Error codes

All errors use standard HTTP status codes with a JSON `detail` body. The shape of
`detail` varies by endpoint, but most authenticated endpoints use a structured
object.

## Error shape

```json theme={null}
{
  "detail": {
    "error": "unauthorized",
    "message": "Invalid API key or missing X-PAYMENT header"
  }
}
```

For scrape/extract failures, `detail` includes additional context:

```json theme={null}
{
  "detail": {
    "error": "timeout",
    "message": "Navigation timed out after 20000ms: https://slow.example.com",
    "url": "https://slow.example.com",
    "payment_charged": false
  }
}
```

## Status codes

| Code  | Meaning               | When it happens                                                                     |
| ----- | --------------------- | ----------------------------------------------------------------------------------- |
| `400` | Bad request           | Invalid body, unknown `output`/`mode`, missing required fields, connection failures |
| `401` | Unauthorized          | Invalid or missing API key / `X-PAYMENT` header                                     |
| `402` | Payment required      | x402 payment failed or insufficient (reserved for payment flows)                    |
| `404` | Not found             | Unknown `job_id`, or a resource you don't own                                       |
| `429` | Rate limited          | Per-IP or tier rate limit exceeded                                                  |
| `500` | Internal server error | Unexpected engine failure                                                           |
| `504` | Timeout               | Navigation or fetch timed out                                                       |

## Structured `detail.error` values

The `detail.error` field is a machine-readable code you can branch on.

| `detail.error`            | Status | Endpoints       | Meaning                                                    |
| ------------------------- | ------ | --------------- | ---------------------------------------------------------- |
| `invalid_output`          | 400    | scrape          | `output` not in `markdown`/`screenshot`/`pdf`/`csv`/`html` |
| `invalid_mode`            | 400    | crawl           | Unknown crawl mode                                         |
| `invalid_request`         | 400    | extract, crawl  | Missing required fields                                    |
| `connection_failed`       | 400    | scrape, extract | DNS/connection failure (incl. invalid host)                |
| `unauthorized`            | 401    | scrape, extract | Missing/invalid credentials                                |
| `rate_limited`            | 429    | scrape, extract | Per-IP sliding window exceeded                             |
| `scrape_failed`           | 500    | scrape          | Unexpected engine failure                                  |
| `extract_failed`          | 500    | extract         | Unexpected extraction failure                              |
| `webhook_delivery_failed` | 502    | webhooks/test   | Test webhook delivery failed                               |
| `timeout`                 | 504    | scrape, extract | Navigation timed out                                       |

## Retrying

* **`429` rate limited** — back off and retry. Check the `Retry-After` header if
  present, otherwise wait \~1s and retry with exponential backoff.
* **`500`/`504`** — safe to retry with exponential backoff. The scrape engine already
  retries internally up to 3 times before surfacing these.
* **`400`** — do **not** blindly retry; fix the request. The exception is
  `connection_failed` on a transient network error.
* **`401`** — fix authentication; retrying won't help.

<CodeGroup>
  ```python Python — retry with backoff theme={null}
  import time, httpx

  def scrape_with_retry(url: str, headers: dict, attempts: int = 3):
      for i in range(attempts):
          resp = httpx.post(
              "https://api.tazpal.com/v1/scrape",
              headers=headers,
              json={"url": url},
          )
          if resp.status_code == 429 or resp.status_code >= 500:
              time.sleep(2 ** i)          # 1s, 2s, 4s
              continue
          resp.raise_for_status()
          return resp.json()
      raise RuntimeError("Max retries exceeded")
  ```

  ```javascript Node.js — retry with backoff theme={null}
  async function scrapeWithRetry(url, headers, attempts = 3) {
    for (let i = 0; i < attempts; i++) {
      const resp = await fetch("https://api.tazpal.com/v1/scrape", {
        method: "POST",
        headers,
        body: JSON.stringify({ url }),
      });
      if (resp.status === 429 || resp.status >= 500) {
        await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
        continue;
      }
      if (!resp.ok) throw new Error(await resp.text());
      return resp.json();
    }
    throw new Error("Max retries exceeded");
  }
  ```
</CodeGroup>

## Request IDs

Every response includes an `X-Request-ID` header. Pass your own `X-Request-ID` on the
request to correlate logs, or read the server-generated one from the response.

```bash theme={null}
curl -i https://api.tazpal.com/health
# HTTP/1.1 200 OK
# X-Request-ID: 3f9c1a2b-8d4e-4f6f-a0b7-5c3d9e1f2a4b
```
