Sex Offender Search API · v1.0.0

Selecting registries and jurisdictions

By default a search asks all 58 US registries. Here is how to ask fewer on purpose, what that costs you in coverage, and how to tell a filter from a scope.

When to use this

Three different questions get confused under "search Texas": *is this person registered by Texas*, *does this person live in Texas*, and *only ask the Texas registry*. The API answers each with a different field — jurisdictions, query.state and locationScoped — and reports what each one did to the fan-out in counts and warnings. You will build a search that uses the right one for the question you are actually asking, and a reader that notices when a scope has hidden something.

Before you start

  • A test key on https://sandbox.offendersearch.app. The sandbox person Out State is registered by Oklahoma's registry and lives in Texas (registrationState: "OK", addressStates: ["TX"]) — exactly the case these fields are about.
  • Codes: the 58 registry ids (AK … WY, plus DC, PR, GU, AS, CNMI, USVI, TRIBAL and the national index) and any two-letter code a registry covers, in any case. GET /v1/sources lists them, no key required.
  • An unrecognised code is a 422, never an empty result: ["XX"] and ["TEXAS"] are refused; ["tx"] is accepted.
The one thing people get wrong. query.state does not choose registries. It keeps records whose registrationState or one of whose addressStates is that state, and still asks all 58 registries. Only jurisdictions or locationScoped: true narrow the fan-out — and when they do, a registry that was never asked contributes a zero that means *unknown*, not *no match*.

Step by step

  1. Filter by state. Search Out State with "state": "TX". One record: the person lives in Texas. sourcesQueried is 58 — nothing was narrowed; the Oklahoma registry that holds the record was asked.
  2. Scope by state. Add "locationScoped": true. Now only the registries covering Texas run; sourcesQueried drops to 2, sourcesSkippedByScope is 56, and the record is gone — it is held by a registry that was not asked. A NARROWED SEARCH: warning says so.
  3. Name a registry. Drop state and send "jurisdictions": ["OK"]. The Oklahoma registry (and a tribal registry whose coverage lists Oklahoma) run; the record is back, sourcesQueried 2, nothing skipped by scope.
  4. Look up a code. Call GET /v1/sources to see each registry's id, the codes it covers, its legal status and health.

Steps 1–3 — three requests, three answers

# 1. filter: all 58 registries asked, records kept if registered OR resident in TX
curl -s https://sandbox.offendersearch.app/v1/search -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Out", "lastName": "State", "state": "TX"}}'

# 2. scope: only the registries that cover TX are asked
curl -s https://sandbox.offendersearch.app/v1/search -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Out", "lastName": "State", "state": "TX"}, "locationScoped": true}'

# 3. name the registry: the Oklahoma roster
curl -s https://sandbox.offendersearch.app/v1/search -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Out", "lastName": "State"}, "jurisdictions": ["OK"]}'
Step 2 response (trimmed) — the scope hid the record
{
  "searchId": "srch_5e1443b11d9ab4002bed9443",
  "status": "complete",
  "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 2, "sourcesComplete": 2, "sourcesIncomplete": 0, "sourcesSkippedByScope": 56 },
  "sourceStatus": [
    { "source": "TRIBAL", "status": "ok", "matched": 0, "fromCache": true, "incomplete": false, "incompleteReason": null },
    { "source": "TX",     "status": "ok", "matched": 0, "fromCache": true, "incomplete": false, "incompleteReason": null }
  ],
  "records": [],
  "warnings": [
    "NARROWED SEARCH: `locationScoped: true` ran only the 2 registries that cover the requested state and did NOT query the other 56. A person REGISTERED by another state's registry while RESIDING in the one you asked for is not in this answer — 7.0% of our records list a residence outside the registry holding them. …"
  ],
  "sandbox": true
}

status is complete and records is empty, and yet the person is registered. The scope, not the registries, produced the zero — which is why sourcesSkippedByScope and the warning exist. If you scope, carry them with the outcome.

Step 3 response (trimmed) — the registry named, the record back
{
  "counts": { "records": 1, "sourcesQueried": 2, "sourcesComplete": 2, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "sourceStatus": [
    { "source": "OK",     "status": "ok", "matched": 1, "fromCache": true, "incomplete": false, "incompleteReason": null },
    { "source": "TRIBAL", "status": "ok", "matched": 0, "fromCache": true, "incomplete": false, "incompleteReason": null }
  ],
  "records": [ { "recordId": "rec_21b7a06132139de2ba35", "registrationState": "OK", "addressStates": ["TX"], "matchConfidence": 0.9, "matchState": null } ],
  "warnings": []
}

Step 4 — the catalog

curl -s https://api.offendersearch.app/v1/sources | head -c 600
GET /v1/sources → 200 (two of 58 entries)
[
  { "id": "AL", "name": "Alabama Sex Offender Registry (ALEA)", "covers": ["AL"], "registries": 1,
    "scope": "state", "status": "live", "legal": { "commercialUse": "restricted" },
    "health": { "lastSuccessAt": "2026-10-25T03:12:40+00:00", "typicalLatencyMs": 820, "ageSeconds": 39600, "freshFor": ["daily", "weekly"] } },
  { "id": "TX", "name": "Texas DPS Sex Offender Registry", "covers": ["TX"], "registries": 1,
    "scope": "state", "status": "live", "legal": { "commercialUse": "unknown" },
    "health": { "lastSuccessAt": "2026-10-25T02:58:03+00:00", "typicalLatencyMs": 1210, "ageSeconds": 40500, "freshFor": ["daily", "weekly"] } }
]

On the sandbox the same call returns the production catalog with every health field null, because the sandbox holds no ingest; the health values above are illustrative of the production shape. Read ids and names from the call, never from a hard-coded list.

Reading the result

FieldValuesWhat it tells you
counts.sourcesQueriedinteger58 on a default search, including one with query.state. Smaller only with jurisdictions or locationScoped: true.
counts.sourcesSkippedByScopeintegerRegistries not asked because of locationScoped. Above 0, a zero in records is not a clear.
warnings[]NARROWED SEARCH: …Present whenever a scope reduced the fan-out.
sourceStatus[].sourceregistry idWhich registries actually ran; matched is each one's contribution.
sourceStatus[].statusok, error, restricted, no_coveragerestricted: a legal gate kept this registry out of a commercial search — surfaced, not dropped. no_coverage: the registry does not cover this query.
records[].registrationStatetwo-letter codeThe registry that holds the person.
records[].addressStates[]codesWhere the person's addresses are. The two together say which half of a state filter matched.
SourceInfo.legal.commercialUserestricted, prohibited, unknown (allowed documented, not in use)A registry marked prohibited or not billable is excluded from commercial scopes; a search restricted to such registries meters nothing.

Edge cases and pitfalls

  • Full state names belong in `query.state`, not in `jurisdictions`. "state": "Florida" is accepted and means FL; "jurisdictions": ["FLORIDA"] is a 422. "state": "Narnia" is also a 422: Unknown state 'Narnia'.
  • A code can select more than one registry. ["OK"] ran two registries above because a tribal registry's coverage lists Oklahoma. Count sourceStatus rows rather than assuming one.
  • To narrow to residents, filter on addressStates in your own code after a state search; to narrow to a registry's roster, use jurisdictions: ["FL"]. state alone gives you the union.
  • Scoping is cheaper but hides out-of-state registrants: about 7% of records list a residence outside the registry that holds them. Reserve locationScoped for cases where the registration state is genuinely known.
  • `jurisdictions` in a batch envelope applies to every row; a row cannot widen it back.
  • `live.jurisdictions` is a different list — it names registries to re-check live, and does not narrow the Regular fan-out. See Regular vs. live searches.
  • A `jurisdictions`-only request is valid: {"query": {}, "jurisdictions": ["WY"]} is a scoped sweep and paginates at 500 records per page via the API; a nameless search is metered per record under the location-record policy.

Production checklist

  • Default searches send no jurisdictions and no locationScoped; scopes are an explicit product decision.
  • sourcesSkippedByScope > 0 is carried with the outcome and shown to reviewers.
  • Codes come from GET /v1/sources, not from a hard-coded list; a 422 on jurisdictions is treated as a bug, not an empty result.
  • registrationState and addressStates are both stored with a hit.

Product overview