# Errors, status codes & rate limits

> Every status code the API returns, the stable error.code envelope you switch on, rate-limit headers, and the backoff strategy to implement against 429.

- **HTML:** https://offendersearch.app/docs/errors
- **Base URL:** https://api.offendersearch.app
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **All documentation as markdown:** https://offendersearch.app/docs.md

## Status codes

The API uses conventional HTTP status codes. A `200` can still carry a `partial` result: when you set a `deadlineMs` bound, the sources that completed within it are returned and every source is labelled in `sourceStatus`, rather than failing the whole call.

| Status | Meaning | When you see it |
| --- | --- | --- |
| `200` | OK | Search completed — or returned partial results under the deadline (check status). |
| `202` | Accepted | Asynchronous search accepted; poll the results URL or await the webhook. |
| `400` | Bad Request | Invalid or missing parameters (e.g. no lastName and no q/location). |
| `401` | Unauthorized | Missing or invalid X-API-Key header, or a request outside the key’s scope. |
| `402` | Payment Required | Billing is not enabled on the account (required for all accounts, including verification reports). |
| `404` | Not Found | The search id or record id does not exist. |
| `422` | Unprocessable Entity | The request was understood and DECLINED, and the body says why in a plain sentence you can show a user. Two causes. (a) THE QUERY CANNOT BE ANSWERED COMPLETELY, so it is declined rather than answered with a subset that would read as the whole answer: a query carrying nothing to narrow on cannot be guaranteed to return the same full result twice. Send at least one of lastName, dob, age, city, zipcode, a lat/lng radius, or q — a firstName on its own is not enough to narrow a search, so pair it with a surname or a date of birth. A name + DOB query is NEVER refused, whatever the name. (b) A parameter is malformed or unrecognised — an unknown jurisdiction code, or a filter placed at the top level of the body instead of inside query. |
| `429` | Too Many Requests | Rate limit or plan quota exceeded. Back off and retry after the Retry-After header. |
| `504` | Deadline Exceeded | Returned only when the deadline is hit and onDeadline = "error". |

## The error envelope

Every non-2xx response returns a JSON body with a stable `error.code` you can switch on and a human-readable `error.message` you can surface to a user. The shape is identical on every endpoint, so one error handler covers the whole API.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "query requires at least one of lastName, q, or a lat/lng radius."
  }
}
```

`422` is a deliberate contract, not a failure. A query with nothing to narrow on cannot be guaranteed to return the same full result twice, so it is declined with a sentence you can show a user rather than answered with a subset that would read as the whole answer. A name + date-of-birth query is never declined, whatever the name.

```json
{
  "error": {
    "code": "unprocessable_query",
    "message": "A firstName on its own cannot be answered completely. Pair it with a lastName, a dob, or an age."
  }
}
```

```javascript
const res = await fetch(url, { method: "POST", headers, body });

if (!res.ok) {
  const { error } = await res.json();
  switch (res.status) {
    case 401: throw new Error("Check X-API-Key: " + error.message);
    case 402: throw new Error("Billing is not enabled on this account.");
    case 422: return { declined: error.message };          // show the user
    case 429: return retryAfter(res.headers.get("Retry-After"));
    default:  throw new Error(error.code + ": " + error.message);
  }
}

const body = await res.json();
// A 200 can still be partial - read status and counts, never records alone.
if (body.status === "partial") {
  // counts.sourcesComplete of counts.sourcesQueried finished
}
```

## Rate limits & quotas

Requests are rate limited per API key. When you exceed your plan’s sustained rate or monthly quota the API responds with `429 Too Many Requests` and a `Retry-After` header (seconds). Every response also carries the current limit state, so you can throttle proactively.

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Requests allowed in the current window. |
| `X-RateLimit-Remaining` | Requests left in the current window. |
| `X-RateLimit-Reset` | Unix time when the window resets. |
| `Retry-After` | On a 429, seconds to wait before retrying. |

Concurrency and monthly volume are set by your plan. For large jobs, prefer the asynchronous endpoint or batch and spread submissions, rather than issuing thousands of synchronous calls at once. Handle `429` with exponential backoff that respects `Retry-After`.

```python
import time, requests

def search(payload, key, attempts=5):
    for attempt in range(attempts):
        r = requests.post(
            "https://api.offendersearch.app/v1/search",
            headers={"X-API-Key": key},
            json=payload,
            timeout=130,
        )
        if r.status_code != 429:
            r.raise_for_status()
            return r.json()
        wait = int(r.headers.get("Retry-After", 2 ** attempt))
        time.sleep(wait)
    raise RuntimeError("rate limited after %d attempts" % attempts)
```

## Retrying safely

A retry should never start — or bill — a second search. On `POST /v1/searches` send an `Idempotency-Key` header and a repeated key returns the original job rather than creating a new one.

When a response comes back `partial`, the field to read is `sourceStatus[].incompleteReason`: it is a closed enum, and each value says whether retrying the identical request will change the answer.

---

## Related

- Previous: [Authentication, keys & security](https://offendersearch.app/docs/authentication.md)
- Next: [Search — POST /v1/search](https://offendersearch.app/docs/search.md)
- Index: [Offendersearch API documentation](https://offendersearch.app/docs.md)
