# API errors: designing codes a client can act on

> A good API error tells a program what to do next and a person what went wrong. Every example here is a real response from a public-records API, so you can run it against the sandbox.

- **HTML:** https://offendersearch.app/docs/learn/api-errors
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## What an API error is, and what a client needs from it

An API error is any response that says the request did not do what it asked. A client needs three things from it, in this order: an **HTTP status** it can branch on without reading the body, a **machine-readable code** that names the specific reason, and a **message** a developer can log or a user can read. Most API errors fail on the second point: they return `400` with a sentence and leave the client to parse English.

This guide uses the [OffenderSearch API](/background-check-api) as the worked example because its error catalogue is small, public and runnable: a [sandbox](/sandbox) answers every request below with a test key and no card. The rules transfer to any REST API you design or consume.

> **Run everything here.** Create a free key, use `https://sandbox.offendersearch.app` with an `os_test_` key, and send the requests as written. The sandbox returns the same status codes and bodies as production.

## HTTP status codes first: the catalogue a client branches on

Use standard status codes as the first layer. A client that only knows HTTP should already be able to tell "fix the request" (`4xx`) from "try again" (`429`, `5xx`) from "it worked" (`2xx`). This is the catalogue the API returns:

| Status | Meaning | Retry? |
| --- | --- | --- |
| `200` | The answer. Read `status` (`complete` or `partial`) before `records`. | No |
| `202` | Accepted: an async job with an id to poll (`POST /v1/searches`, `live.evidence`, a criminal search with more than two live sources). | No |
| `400` / `422` | The request was refused: a missing field, an invalid value, a query too broad to answer completely. `detail` (criminal: `error.code`) says which. | No — fix the request |
| `401` | Authentication failed: the key is missing, wrong, or a test key was sent to the live host. | No — fix the key or host |
| `402` | No paid entitlement and no free-trial searches left. | No — enable billing |
| `403` | Not entitled: the Map API is not turned on, the account is not an FCRA partner, or a consumer account hit a business route. | No |
| `404` | Unknown id or path. Also returned for an id that no identifier could ever hold. | No |
| `409` | A record id matches records in more than one jurisdiction; `detail` names them. | No — disambiguate |
| `429` | Rate limited or at capacity. Nothing was searched or billed. `Retry-After` carries 1–120 seconds. | Yes — after `Retry-After` |
| `504` | The deadline was hit with `onDeadline: "error"`, or nothing was found before some registries finished (`incomplete_no_results`). | Yes, with care |
| `5xx` | The platform or a dependency failed. Nothing was searched or billed. | Yes, with backoff, for requests that are safe to repeat |

The full list with each product's exceptions is on [Errors, rate limits & retries](/docs/platform/errors-and-retries); the Sex Offender API's own page is [/docs/errors](/docs/errors) and the Criminal Records API's is [/docs/criminal/errors](/docs/criminal/errors).

## The API error response body: one shape per product

Every non-`2xx` response is JSON. The Sex Offender, Monitoring, Map and FCRA APIs put the reason under one key, `detail`; the Criminal Records API uses an `error` object with `code` and `message`. `detail` takes three forms, and a client should type-check it before reading it:

*A sentence (declined value)*
```json
{ "detail": "Unknown match 'strick'. Allowed: balanced, broad, strict." }
```

*A list (field validation)*
```json
{
  "detail": [
    { "type": "date_from_datetime_parsing",
      "loc": ["body", "query", "dob"],
      "msg": "Input should be a valid date or datetime, input is too short",
      "input": "1980" }
  ]
}
```

*An object (a coded refusal, or a 429)*
```json
{ "detail": { "code": "query_too_broad",
              "message": "Add a first name, a date of birth or a state." } }

{ "detail": { "code": "overloaded", "retryAfterSeconds": 7 } }
```

*Criminal Records API refusal*
```json
{ "error": { "code": "query_too_broad",
             "message": "Add a first name, a date of birth or a state." } }
```

> **Branch on the status and the code, never on the sentence.** Sentences are for people and are edited over time. `code` values and status codes are the contract. A client that does `if (msg.includes("too broad"))` breaks the day the wording improves.

## API error codes a client can switch on

A machine-readable code names the reason more precisely than the status does. These are the coded reasons the APIs return today:

| Code | Status | Means | Client action |
| --- | --- | --- | --- |
| `query_too_broad` | `422` | The query has nothing to narrow on, so a complete answer cannot be guaranteed. | Ask for a first name, a date of birth or a state; show `message`. |
| `overloaded` | `429` | The service is at capacity for this request. Nothing ran. | Sleep `retryAfterSeconds`, send the same request again. |
| `incomplete_no_results` | `504` | No match was found before some registries finished, and you did not ask for a partial answer. | Retry, or resend with `onDeadline: "partial"` and read `sourceStatus`. |
| `invalid_bbox`, `invalid_center`, `invalid_radius`, `invalid_zoom`, `invalid_filter`, `invalid_format`, `invalid_detail`, `invalid_page`, `invalid_shape` | `422` | A Map API request parameter is out of range or malformed. | Fix the named parameter. |
| `route_too_many_people` | `422` | A route has more than 300 pins at `detail=full`; `counts` still holds the exact counts. | Ask for `detail=count`, or shorten the route. |
| `map_sweep_limit`, `map_end_user_limit`, `map_end_user_route_limit`, `map_open_limit` | `429` | A daily map budget (account or one end user) is used up. | Stop for the day for that user; clusters still work. |
| `map_busy` | `429` | Too many map requests at once. `Retry-After: 1`. | Retry after one second. |

*One handler for the whole API*
```javascript
const res = await fetch(url, { method: "POST", headers, body });
if (!res.ok) {
  const err = await res.json().catch(() => ({}));
  // Sex Offender / Monitoring / Map / FCRA: err.detail (string | list | object)
  // Criminal Records refusals:             err.error  ({ code, message })
  const d = err.detail ?? err.error ?? {};
  const code = typeof d === "object" && !Array.isArray(d) ? d.code : undefined;
  const reason = typeof d === "string" ? d : JSON.stringify(d);
  switch (res.status) {
    case 401: throw new Error("Check X-API-Key and the host: " + reason);
    case 402: throw new Error("Billing or free-trial searches needed: " + reason);
    case 422: return { declined: reason };                       // show the sentence to the user
    case 429: return retryAfter(Number(res.headers.get("Retry-After") ?? d.retryAfterSeconds ?? 5));
    case 504: if (code === "incomplete_no_results") return { incomplete: true }; // fall through
    default:  throw new Error(`${res.status} ${code ?? ""}: ${reason}`);
  }
}
const body = await res.json();
// A 200 can still be a partial answer — read status before records.
if (body.status === "partial") console.warn(body.counts.sourcesComplete, "of", body.counts.sourcesQueried, "sources finished");
```

Notice what the handler does **not** do: it never retries a `422`, because the same request will be refused the same way, and it never retries an async submission blindly, because [every accepted submission is a new, billed job](/docs/platform/async-and-webhooks).

## 429 and Retry-After: rate limit errors done right

A rate limit error has one job: tell the client exactly how long to wait. The API answers `429 Too Many Requests` with a `Retry-After` header of 1 to 120 seconds, and the Sex Offender API repeats it in the body as `detail.retryAfterSeconds` with `detail.code: "overloaded"`. A `429` is never a partial answer: nothing was searched and nothing was billed, so the same request can be sent again unchanged.

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 7
Content-Type: application/json

{"detail": {"code": "overloaded", "retryAfterSeconds": 7}}
```

*Backoff that respects Retry-After*
```python
import time, requests

def call(method, url, **kw):
    """Retry only what is safe to retry; obey Retry-After on a 429."""
    for attempt in range(4):
        r = requests.request(method, url, timeout=60, **kw)
        if r.status_code == 429:
            wait = int(r.headers.get("Retry-After") or r.json()["detail"]["retryAfterSeconds"])
            time.sleep(wait)                      # 1–120 seconds; nothing was searched or billed
            continue
        if 500 <= r.status_code < 600 and method == "GET":
            time.sleep(2 ** attempt)              # 1, 2, 4 s — GETs are always safe to repeat
            continue
        return r
    return r
```

Two details matter when you design this yourself. First, put the wait in a header **and** the body: proxies strip headers and some HTTP clients hide them. Second, a criminal-records `429` can arrive from the gateway after the request has queued for up to 45 seconds and may carry no JSON body at all, so a client must read the header before it tries to parse JSON. For large jobs, the [batch](/docs/batch) and [async](/docs/async-and-webhooks) endpoints avoid the limit entirely.

## What to retry and what never to retry

- **Safe to retry:** every `GET`; a synchronous search without a `live` block; any `429` after `Retry-After`; a `5xx` on submission (no job was accepted).
- **Retry with care:** a synchronous search *with* a `live` block. A repeat re-checks the sources again and bills $0.02 per completed source again. Read the error first.
- **Never retry blindly:** `POST /v1/searches`. There is no idempotency header; every accepted request is a new, billed job. Persist the `searchId` from the `202` and recover by polling it.
- **Never retry:** `400`, `401`, `402`, `403`, `404`, `422`. The same request produces the same refusal. Fix it.

## RFC 9457 problem details, and how this body maps to it

RFC 9457 ("Problem Details for HTTP APIs", the 2023 revision of RFC 7807) standardises an error body with five members: `type` (a URI naming the problem class), `title` (a short human summary that does not change per occurrence), `status`, `detail` (the occurrence-specific explanation) and `instance` (a URI for this occurrence). It is served as `application/problem+json`. Few APIs ship it verbatim, but every well-designed error body can be mapped onto it:

| RFC 9457 member | In the OffenderSearch body | Note |
| --- | --- | --- |
| `type` | `detail.code` (criminal: `error.code`) | A stable string instead of a URI; same purpose: the class of problem. |
| `title` | the code, humanised | Not sent separately; the code is the title. |
| `status` | the HTTP status line | Never duplicated in the body. |
| `detail` | `detail.message` / `detail` (string) / `error.message` | The sentence for this occurrence. |
| `instance` | the request id header on every response | Quote it to support. |

```json
// RFC 9457 "problem details" body
{
  "type": "https://api.example.com/problems/query-too-broad",
  "title": "Query too broad",
  "status": 422,
  "detail": "Add a first name, a date of birth or a state.",
  "instance": "/v1/search/req_01hz…"
}

// The same refusal from the Sex Offender API
{ "detail": { "code": "query_too_broad",
              "message": "Add a first name, a date of birth or a state." } }
```

If you are designing a new API, use RFC 9457 as written: the shape is good and clients increasingly recognise the media type. If you are consuming one that predates it, the mapping above is what your error class should normalise into.

## Designing API error messages: specific, readable, actionable, consistent

The message is the part a person reads. It should say what went wrong, in words a user could be shown, and say what to do next. Compare the refusal the API sends for a query with nothing to narrow on:

```json
{ "detail": "This query is too broad to be answered completely: a last name alone matches more people than one search can read in full. Add a first name, a date of birth or a state." }
```

- **Specific:** it names the field that caused the refusal, not "invalid request".
- **Readable:** no stack trace, no class name, no SQL.
- **Actionable:** it says what to add.
- **Consistent:** every declined value uses the same pattern (`Unknown X 'y'. Allowed: a, b, c.`), so a developer learns it once.

> **A 422 is a 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 plus date-of-birth query is never declined. See [result completeness](/docs/result-completeness).

## Field validation errors: loc, msg and type

When a request fails schema validation the body is a list, one entry per field, each with `loc` (the path to the field), `msg` (the human reason), `type` (the machine reason) and `input` (what was received). A client can map `loc` straight back onto a form field. This is the Pydantic shape FastAPI emits, and it is a good one to copy: it answers "which field, why, and what did you send" in one object.

```json
{
  "detail": [
    { "type": "missing",
      "loc": ["body", "query", "lastName"],
      "msg": "Field required",
      "input": {"firstName": "Jordan", "dob": "1988-04-12"} }
  ]
}
```

Because `detail` is a string in some `422`s and a list in others, type-check before you read it. The handler above does exactly that with `typeof d === "string"`.

## A 200 that is not a success: partial results and source status

The hardest error to design is the one that is not an error. A search across 58 US registries can finish with one registry still unanswered. Returning `500` would throw away the registries that did answer; returning `200` with nothing to say about the gap would be a lie. The API returns `200` with `status: "partial"`, `counts.sourcesComplete` of `counts.sourcesQueried`, and a `sourceStatus[]` entry per registry saying `complete`, `timeout` or `error`. The records from the registries that finished are in `records[]`.

```json
{
  "status": "partial",
  "counts": { "records": 1, "sourcesQueried": 58, "sourcesComplete": 57 },
  "sourceStatus": [
    { "jurisdiction": "TX", "status": "complete", "records": 1 },
    { "jurisdiction": "CA", "status": "timeout", "records": 0 }
  ],
  "records": [ { "recordId": "rec_ab12cd34ef56gh78ij90", "name": { "first": "JORDAN", "last": "RIVERA" }, "matchState": "dob_match" } ]
}
```

The rule for any API that aggregates: **an empty result is only an answer when every source finished.** Say which ones did. If you would rather the API refuse than answer partially, send `onDeadline: "error"` and you get the `504` above instead.

## Logging errors and showing them to users

| Status | Log | Show the user |
| --- | --- | --- |
| `401` / `402` / `403` | The status and `detail`; alert the on-call. | A generic "service unavailable"; never the key or the billing state. |
| `422` | The request id and `detail`. | The `message` sentence, verbatim. It was written to be shown. |
| `429` | A counter, not a line per event. | Nothing; retry silently. |
| `5xx` | Status, request id, elapsed time. | "Try again in a moment." |
| `200` partial | `sourceStatus` entries that are not `complete`. | Which sources did not answer, in plain words. |

Never put the API key, the full request body or another person's record into a log line. Public-records responses carry names and addresses; log the request id and the counts, fetch the body from the [search history](/docs/platform/requests) if you need it later.

## Try an API error in the sandbox

Send a deliberately wrong `match` value and read the `422`:

```bash
curl -X POST https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OS_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12"}, "match": "strick"}'

# HTTP/1.1 422 Unprocessable Entity
# {"detail": "Unknown match 'strick'. Allowed: balanced, broad, strict."}
```

Then drop `firstName` and `dob` to see `query_too_broad`, and send an `os_test_` key to the live host to see the `401`. The [sandbox page](/sandbox) lists every scenario the test host answers, and the [Postman workspace](/docs/learn/postman-api-documentation) has each error as a saved request.

## API errors: questions developers ask

### What are API errors?

Responses that say a request did not do what it asked. A well-designed one carries an HTTP status to branch on, a machine-readable code naming the reason, and a message a person can read. Errors in the `4xx` range mean the request must change; `429` and `5xx` mean the request can be sent again.

### What does it mean when an HTTP status code is updated?

That the API changed which status it returns for a situation, which is a breaking change for any client that branches on it. A real example from this API: until 2026-10-19 a query too broad to read in full returned `200` with a `truncated` partial answer; since then it returns `422` with `query_too_broad`. Changes like that belong in a [changelog](/docs/changelog) with a date, and a client should branch on the code as well as the status so the reason survives a status change.

### Should an API return 200 with an error in the body?

No. HTTP caches, proxies, SDK retry logic and monitoring all read the status line. The one legitimate `200` that is not a full success is a partial result, and it must say so in a field the client is told to read first (`status: "partial"`).

### Which API error codes should be retried?

`429` after the `Retry-After` wait, and `5xx` for requests that are safe to repeat. Never `4xx` other than `429`, and never an async submission without checking whether a job was already accepted.

### Is RFC 9457 required?

No, but it is the only standard shape, and a new API should use it. For an existing API, normalise its body into the five RFC members inside your client so the rest of your code handles one shape.

## What this page does not cover

- GraphQL errors, which travel in a `200` with an `errors` array by design.
- gRPC status codes.
- Webhook delivery failures, which are the sender's errors, not the API's: see the [webhook example](/docs/learn/webhook-example) and our retry schedule.
- The exact per-field validation rules of each endpoint: those are on the [API reference](/docs/api-reference).

## Related

- [Errors, rate limits & retries (platform reference)](https://offendersearch.app/docs/platform/errors-and-retries)
- [Sex Offender API errors](https://offendersearch.app/docs/errors)
- [Criminal Records API errors](https://offendersearch.app/docs/criminal/errors)
- [Result completeness: partial vs complete](https://offendersearch.app/docs/result-completeness)
- [Async jobs & webhooks](https://offendersearch.app/docs/async-and-webhooks)
- [RESTful API example](https://offendersearch.app/docs/learn/restful-api-example)
- [API token example](https://offendersearch.app/docs/learn/api-token-example)
- [Webhook example](https://offendersearch.app/docs/learn/webhook-example)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Background check API](https://offendersearch.app/background-check-api)
- [Criminal records API](https://offendersearch.app/criminal-records-api)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
