Error handling
Every refusal is JSON with a `detail`, with two documented exceptions. Here is what each status means, what it looks like, and whether a retry can change it.
What you will build
One error handler for every Sex Offender API call: it parses the body, classifies the status into fix the request, fix the account, wait and retry, or not an answer about the person, and never turns a refusal into a clear result. The sandbox produces the 401, 404, 409, 410, 413 and 422 cases for real (never a 402: nothing there is billed); 429 and 504 are shown in their production shapes because the sandbox does not simulate load.
Before you start
- A test key. The sandbox returns real validation errors — identical to production — for malformed requests. Its own
429(60 requests a minute, 2,000 a day per key, 120 a minute per IP) is a real protection of the sandbox and carriesRetry-After. - The envelope:
{"detail": …}wheredetailis a sentence on most errors, a list of{loc, msg, type, input}when a field failed validation, or an object with a `code` on429, on the504 incomplete_no_results, on403 location_quota_paused, and on the clearance404/409. - Two exceptions to the envelope: a personal (consumer) account calling a business route gets
403with a bare{"error": "use_background_check", "message": …, "path": …}; and an unavailable evidence service answers503with{"error": {"code": "unavailable", "message": …}}.
The one thing people get wrong. A non-2xx answer says nothing about the person.
401, 402, 422, 429 and 504 must surface as errors in your workflow, never as "no records found". The only clear answer is 200 with status: "complete" and counts.records: 0.Step by step
- Parse the body defensively. Read
detailif present; otherwiseerror(string or object). Keep the raw body for logs. - Branch on the status.
4xxother than429is yours to fix (request or account).429is wait-and-retry withRetry-After.5xxmeans the service did not answer; retry a Regular search, be careful with a live one. - Map `422` to the field. A list-shaped
detailnamesloc(["body", "query", "dob"]); a sentence names the rule. Fix the request; do not retry unchanged. - Never resubmit an async job on error. If the
202was received, poll it. Resubmitting creates a second billed job.
The handler
import os, time, requests
BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}
class ApiError(Exception):
def __init__(self, status, body, retry_after=None):
super().__init__(f"HTTP {status}: {body}")
self.status, self.body, self.retry_after = status, body, retry_after
@property
def detail(self):
return self.body.get("detail", self.body.get("error")) if isinstance(self.body, dict) else self.body
@property
def code(self):
d = self.detail
return d.get("code") if isinstance(d, dict) else None
def call(method, path, json=None, attempts=4):
for attempt in range(attempts):
r = requests.request(method, BASE + path, headers=H, json=json, timeout=90)
if r.status_code < 400:
return r
try:
body = r.json()The bodies, from the sandbox
// no key, or an unknown key
{ "detail": "Missing or invalid API key. Send it in the X-API-Key header." }
// a live key sent to the sandbox (the live host answers the mirror image for a test key)
{ "detail": "This is a live key. The sandbox at https://sandbox.offendersearch.app accepts test keys (os_test_…) only — create one in the dashboard under Developers → API keys. Send live keys to https://api.offendersearch.app." }The bodies the sandbox does not produce (production shapes)
{ "detail": "No active subscription for this account. Start a subscription (POST /v1/billing/checkout-session) or update your payment method (POST /v1/billing/portal-session) to use the API." }
{ "detail": "Your free trial is used up (25 of 25 searches). Add a payment method to keep going — $0.15 per search, …" }
{ "detail": "Free-trial searches must include a name (first or last). …" }
{ "detail": "Billing required for proof documents." }Reading the result
| Status | When | Body | Retry? |
|---|---|---|---|
400 | /v1/report with neither searchId nor query | sentence | No — fix the request. |
401 | Missing/invalid key; test key on the live host or live key on the sandbox | sentence (branch on the status, not the wording) | No — fix the key or host. |
402 | No subscription; trial exhausted; trial query without a name; proof without billing; batch larger than the trial | sentence | No — fix billing or the request. |
403 | Location-record quota paused; consumer account on a business route | object with code, or the bare {error, message, path} | No. |
404 | Unknown/foreign searchId, proof token, clearance; record id never issued or aged out; report for a search not on the account | sentence; search_not_found object on clearances | No — says nothing about the person. |
409 | Ambiguous registry uuid; clearance cites a search that did not show the person | sentence; person_not_in_search object | No — use recordId / the right searchId. |
410 | Stored results erased (14 days); report window passed (7 days) | sentence | No — run a new search. |
413 | Batch over 1,000 rows | sentence | No — split the batch. |
422 | Validation: unknown key, wrong type, out of range, unknown enum value, query that narrows nothing, unknown jurisdiction/state, unsafe webhookUrl, evidence rules | list or sentence | No — fix the field named. |
429 | Overloaded; slot queue full; evidence capacity; sandbox rate limit | object with retryAfterSeconds, or a sentence; always Retry-After | Yes, after Retry-After. Not billed. |
503 | Evidence service unavailable; clearances store unavailable | {error: {code, message}} for evidence; sentence otherwise | Yes, later. Nothing ran or was recorded. |
504 | incomplete_no_results; onDeadline: "error" hit | object with code, or sentence | incomplete_no_results is an answer (unfinished registries) — re-run or send onDeadline: "partial"; the other is your own setting. |
Edge cases and pitfalls
- Do not match on `detail` text. Wordings change and differ between routes (
Missing or invalid API key…vsProvide a valid X-API-Key or session token.). Branch on the status and, where present, ondetail.code. - `429` is never billed and always carries `Retry-After` (1–120 seconds). A body with
queueDepthwas refused before anything ran. Retrying sooner is simply refused again. - `504 incomplete_no_results` looks like an error and is really a partial result with no records. Sending
"onDeadline": "partial"explicitly turns it into a200withstatus: "partial"that you can read with Working with incomplete results. - 5xx on a live search may have billed completed sources. Retrying a Regular search after a
5xxis safe (nothing ran); for a live search, re-run and reconcileusagerather than retrying blindly. - A test key on the live host is a `401`, not a `403`, and the sentence names the sandbox URL. Catch it in CI by asserting the host matches the key prefix.
- Batch rows swallow their own errors: a refused row is
status: "error"inside a200. Inspect rows, not just the status code. - Validation runs before load shedding, so a
422is always about your request, never about capacity. - The sandbox echoes `"sandbox": true` on error bodies too; strip it before comparing bodies with production.
Production checklist
- Every non-2xx logged with status,
detail/error,Retry-Afterand your request id; none rendered as "no records". 429backoff honoursRetry-After;5xxretried only for Regular searches;4xxnever retried unchanged.- Async
searchIdpersisted before polling; no resubmission on poll errors. - Alerts on
402(billing) and on repeatedincomplete_no_resultsfor the same registries.
Related products
Product overview
- Sex offender search API
- Sex offender registry API
- Offender search API
- Background check API
- Bulk sex offender search
- Background check API pricing
- Background check API integration
- Integrations
- Free background check API
- Pay-as-you-go background check API
- Background check API in Python
- Background check API in Node.js
- NSOPW API