Sex Offender Search API · v1.0.0

Regular vs. live searches

Regular answers from the continuously updated copy in under a second. Live asks the named registries now, costs a little more, and tells you exactly which ones answered.

When to use this

Every search is Regular unless you say otherwise: the request is answered from the continuously updated copy of all 58 US registries, and each record carries sources[].lastCheckedAt so you know when its registry was last read. A live search re-verifies the registries you name at query time and merges what they return through the same matcher. Use it for a contested or high-stakes decision, for a point-in-time answer you will file, or when a reviewer asks "is this still true right now".

You will build a search function that sends a live block, handles the two ways it can answer (inline, or as a background job you poll), and reads the live rows and the charge.

Before you start

  • A test key on https://sandbox.offendersearch.app. The sandbox answers a live block deterministically: each named registry completes and adds its row, with the production usage quote; nothing is billed.
  • The live block has three fields: jurisdictions (registry ids or two-letter codes to re-check), scope: "matched" (re-check whichever registries produced the Regular hits) and evidence (none or pdf, covered in Evidence and supporting documents). Omit live for Regular; live: {} resolves no sources and is also Regular.
  • Price: the search itself plus $0.02 per completed live source, at most $2.00 per search. A source that fails is reported and not billed. Live reporting adds usage and meterEvents to the response (pricing).
The one thing people get wrong. A live search does not always answer inline. When the block resolves to more than two sources, or to a registry that can only be re-checked in the background, POST /v1/search answers HTTP 200 with status: "pending", mode: "async" and a poll path — not a SearchResponse. Branch on body.status === "pending" and poll GET /v1/searches/{searchId} with your API key.

Step by step

  1. Run a Regular search. Search Exact Match with dob 1980-01-01 and no live block. No usage key, no meterEvents key; every sourceStatus row has fromCache: true.
  2. Re-check one registry live. Add "live": {"jurisdictions": ["OH"]}. One source resolves, the search runs inline, and the response gains a live row for OH plus usage.
  3. Re-check the registries that matched. Send "live": {"scope": "matched"} instead. The Regular pass finds the person in OH, so OH is re-checked — without you naming it.
  4. Ask for three registries and poll. Send "live": {"jurisdictions": ["OH", "TX", "FL"]}. The answer is the 200 acknowledgement; poll until status is complete or partial.

Step 2 — one registry live, inline

curl -s https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "live": {"jurisdictions": ["OH"]}}'
Response (trimmed: the 58 cached rows cut, the live row kept)
{
  "searchId": "srch_4bd856ef486fc1bff31f8454",
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "sourceStatus": [
    { "source": "OH", "status": "complete", "matched": 1, "fromCache": false, "note": null,
      "incomplete": false, "incompleteReason": null, "cappedOmitted": false }
  ],
  "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "personRef": "prs_28db9639d712c4c61e8e", "matchState": "dob_match", "matchConfidence": 0.9 } ],
  "warnings": [],
  "usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false, "display": "1 live checks · $0.02" },
  "meterEvents": [],
  "sandbox": true
}

The live row is appended after the cached rows: fromCache: false, status: "complete". In production meterEvents carries [{ "kind": "live_source", "quantity": 1, "chargeCeilingUsd": 2.0 }]; the sandbox bills nothing, so it is [] — present, because it is present on every live answer.

Step 4 — three registries: the acknowledgement, then the poll

POST /v1/search → 200 (not a SearchResponse)
{
  "searchId": "srch_e4370c7d8000e31a71105937",
  "status": "pending",
  "mode": "async",
  "liveSourcesRequested": 3,
  "poll": "/v1/searches/srch_e4370c7d8000e31a71105937",
  "reason": "3 live sources (> 2 or not sync-eligible) — running asynchronously",
  "sandbox": true
}
GET /v1/searches/{searchId} → 200 when finished (trimmed)
{
  "searchId": "srch_e4370c7d8000e31a71105937",
  "status": "complete",
  "sourceStatus": [
    { "source": "OH", "status": "complete", "matched": 1, "fromCache": false, "incomplete": false, "incompleteReason": null },
    { "source": "TX", "status": "complete", "matched": 0, "fromCache": false, "incomplete": false, "incompleteReason": null },
    { "source": "FL", "status": "complete", "matched": 0, "fromCache": false, "incomplete": false, "incompleteReason": null }
  ],
  "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "matchState": "dob_match" } ],
  "usage": { "liveChecks": 3, "liveChargeUsd": 0.06, "capped": false, "display": "3 live checks · $0.06" },
  "meterEvents": []
}

While the job runs the poll body is {"searchId", "status": "pending" | "running", "counts": {"records": 0, "sourcesQueried": N, "sourcesIncomplete": 0}, "sourceStatus": [], "records": [], "proof": {"status": "none"}}. Poll every 2–5 seconds and cap your wait at a few minutes. The sandbox finishes before the first poll.

Reading the result

FieldValuesWhat it means on a live search
sourceStatus[].fromCachetrue on Regular rows, false on live rowsThe live rows are the ones you paid for. Filter on this.
sourceStatus[].status (live row)complete, partialpartial comes with incomplete: true and an incompleteReason (unavailable, error, not_searched, …). Regular rows use ok, error, restricted, no_coverage.
status (envelope)complete, partialAny failed live source makes the whole answer partial.
usage.liveChecksintegerSources that completed — the true count even when the charge is capped.
usage.liveChargeUsdnumbermin(liveChecks × 0.02, 2.00); usage.capped says whether the ceiling applied.
meterEvents[][{kind: "live_source", quantity, chargeCeilingUsd}]The billable units, for your own reconciliation. Present only when a live block ran.
records[].sources[].lastCheckedAtISO timestampOn a live hit, now. On a Regular hit, when the registry was last read.

Which registries can be re-checked live, and each registry's health, are published by GET /v1/sources (no key needed) and explained on Regular & Live.

Edge cases and pitfalls

  • A two-letter code can resolve to more than one source. live.jurisdictions expands a code to each registry that covers it, which can include a national index. Read liveSourcesRequested on the acknowledgement, or the live rows on the result, rather than assuming one source per code.
  • The poll needs an API key. GET /v1/searches/{searchId} does not accept a dashboard session, and it answers 404 for another account's id, an unknown id, or a job older than about an hour. Collect the result promptly; the stored records stay readable at GET /v1/searches/{searchId}/results for 14 days.
  • Live records win. When a live source returns the same person the Regular pass found, the live copy replaces the cached one in records[].
  • A failed source is not billed and shows as a partial live row. Decide in advance whether a partial live answer is acceptable for your use case or should be retried.
  • Batch does not take `live`. POST /v1/batch is Regular only; a live key in a row is a 422. Run POST /v1/search or POST /v1/searches for a live check.
  • Inside `POST /v1/searches` a live block runs in the job and is billed when the sources complete; the 202 arrives before anything is charged.
  • `live: {}` is Regular. Only jurisdictions or scope resolve sources.

Production checklist

  • Every call that sends live handles both the inline SearchResponse and the 200 acknowledgement with poll.
  • Polling uses the API key, a 2–5 second interval and a hard cap.
  • usage and meterEvents logged per live search and reconciled against Dashboard → Usage.
  • Live reserved for the decisions that need it; Regular for volume.

Product overview