Sex Offender Search API · v1.0.0

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 carries Retry-After.
  • The envelope: {"detail": …} where detail is a sentence on most errors, a list of {loc, msg, type, input} when a field failed validation, or an object with a `code` on 429, on the 504 incomplete_no_results, on 403 location_quota_paused, and on the clearance 404/409.
  • Two exceptions to the envelope: a personal (consumer) account calling a business route gets 403 with a bare {"error": "use_background_check", "message": …, "path": …}; and an unavailable evidence service answers 503 with {"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

  1. Parse the body defensively. Read detail if present; otherwise error (string or object). Keep the raw body for logs.
  2. Branch on the status. 4xx other than 429 is yours to fix (request or account). 429 is wait-and-retry with Retry-After. 5xx means the service did not answer; retry a Regular search, be careful with a live one.
  3. Map `422` to the field. A list-shaped detail names loc (["body", "query", "dob"]); a sentence names the rule. Fix the request; do not retry unchanged.
  4. Never resubmit an async job on error. If the 202 was 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

StatusWhenBodyRetry?
400/v1/report with neither searchId nor querysentenceNo — fix the request.
401Missing/invalid key; test key on the live host or live key on the sandboxsentence (branch on the status, not the wording)No — fix the key or host.
402No subscription; trial exhausted; trial query without a name; proof without billing; batch larger than the trialsentenceNo — fix billing or the request.
403Location-record quota paused; consumer account on a business routeobject with code, or the bare {error, message, path}No.
404Unknown/foreign searchId, proof token, clearance; record id never issued or aged out; report for a search not on the accountsentence; search_not_found object on clearancesNo — says nothing about the person.
409Ambiguous registry uuid; clearance cites a search that did not show the personsentence; person_not_in_search objectNo — use recordId / the right searchId.
410Stored results erased (14 days); report window passed (7 days)sentenceNo — run a new search.
413Batch over 1,000 rowssentenceNo — split the batch.
422Validation: unknown key, wrong type, out of range, unknown enum value, query that narrows nothing, unknown jurisdiction/state, unsafe webhookUrl, evidence ruleslist or sentenceNo — fix the field named.
429Overloaded; slot queue full; evidence capacity; sandbox rate limitobject with retryAfterSeconds, or a sentence; always Retry-AfterYes, after Retry-After. Not billed.
503Evidence service unavailable; clearances store unavailable{error: {code, message}} for evidence; sentence otherwiseYes, later. Nothing ran or was recorded.
504incomplete_no_results; onDeadline: "error" hitobject with code, or sentenceincomplete_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… vs Provide a valid X-API-Key or session token.). Branch on the status and, where present, on detail.code.
  • `429` is never billed and always carries `Retry-After` (1–120 seconds). A body with queueDepth was 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 a 200 with status: "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 5xx is safe (nothing ran); for a live search, re-run and reconcile usage rather 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 a 200. Inspect rows, not just the status code.
  • Validation runs before load shedding, so a 422 is 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-After and your request id; none rendered as "no records".
  • 429 backoff honours Retry-After; 5xx retried only for Regular searches; 4xx never retried unchanged.
  • Async searchId persisted before polling; no resubmission on poll errors.
  • Alerts on 402 (billing) and on repeated incomplete_no_results for the same registries.

Product overview