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.
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 as the worked example because its error catalogue is small, public and runnable: a sandbox answers every request below with a test key and no card. The rules transfer to any REST API you design or consume.
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; the Sex Offender API's own page is /docs/errors and the Criminal Records API's is /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:
{ "detail": "Unknown match 'strick'. Allowed: balanced, broad, strict." }{
"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" }
]
}{ "detail": { "code": "query_too_broad",
"message": "Add a first name, a date of birth or a state." } }
{ "detail": { "code": "overloaded", "retryAfterSeconds": 7 } }{ "error": { "code": "query_too_broad",
"message": "Add a first name, a date of birth or a state." } }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. |
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.
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/1.1 429 Too Many Requests
Retry-After: 7
Content-Type: application/json
{"detail": {"code": "overloaded", "retryAfterSeconds": 7}}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 rTwo 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 and async endpoints avoid the limit entirely.
What to retry and what never to retry
- Safe to retry: every
GET; a synchronous search without aliveblock; any429afterRetry-After; a5xxon submission (no job was accepted). - Retry with care: a synchronous search *with* a
liveblock. 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 thesearchIdfrom the202and 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. |
// 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:
{ "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.
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.
{
"detail": [
{ "type": "missing",
"loc": ["body", "query", "lastName"],
"msg": "Field required",
"input": {"firstName": "Jordan", "dob": "1988-04-12"} }
]
}Because detail is a string in some 422s 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[].
{
"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 if you need it later.
Try an API error in the sandbox
Send a deliberately wrong match value and read the 422:
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 lists every scenario the test host answers, and the Postman workspace 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 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
200with anerrorsarray by design. - gRPC status codes.
- Webhook delivery failures, which are the sender's errors, not the API's: see the webhook example and our retry schedule.
- The exact per-field validation rules of each endpoint: those are on the API reference.