Developer docs · v1.0.0

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.

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:

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

A sentence (declined value)
{ "detail": "Unknown match 'strick'. Allowed: balanced, broad, strict." }
A list (field validation)
{
  "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)
{ "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
{ "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:

CodeStatusMeansClient action
query_too_broad422The 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.
overloaded429The service is at capacity for this request. Nothing ran.Sleep retryAfterSeconds, send the same request again.
incomplete_no_results504No 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_shape422A Map API request parameter is out of range or malformed.Fix the named parameter.
route_too_many_people422A 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_limit429A daily map budget (account or one end user) is used up.Stop for the day for that user; clusters still work.
map_busy429Too many map requests at once. Retry-After: 1.Retry after one second.
One handler for the whole API
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}}
Backoff that respects Retry-After
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 and async 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 memberIn the OffenderSearch bodyNote
typedetail.code (criminal: error.code)A stable string instead of a URI; same purpose: the class of problem.
titlethe code, humanisedNot sent separately; the code is the title.
statusthe HTTP status lineNever duplicated in the body.
detaildetail.message / detail (string) / error.messageThe sentence for this occurrence.
instancethe request id header on every responseQuote 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.
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.

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

StatusLogShow the user
401 / 402 / 403The status and detail; alert the on-call.A generic "service unavailable"; never the key or the billing state.
422The request id and detail.The message sentence, verbatim. It was written to be shown.
429A counter, not a line per event.Nothing; retry silently.
5xxStatus, request id, elapsed time."Try again in a moment."
200 partialsourceStatus 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 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 and our retry schedule.
  • The exact per-field validation rules of each endpoint: those are on the API reference.