Sex Offender Search API · v1.0.0

Performing your first search

One request, one envelope. This guide runs three sandbox searches, reads each answer field by field, and moves the same code to production.

What you will build

A function that takes a first name, a last name and a date of birth, calls POST /v1/search, and returns one of three outcomes: clear (the search finished and matched nobody), candidates (one or more registrants to review) or incomplete (at least one registry did not finish). That is the whole job of a first integration; everything else in this product is a refinement of it.

You will run it three times against the sandbox — once for a person who is not registered, once for a confirmed registrant, once for a common name that returns candidates without a date of birth — and then switch the host and key to production without changing a line of logic.

Before you start

  • A test key (os_test_…) from Dashboard → API keys. Test keys work only on https://sandbox.offendersearch.app; a live key (os_live_…) works only on https://api.offendersearch.app. Either host refuses the other kind with a 401 that names the right host.
  • The header is X-API-Key. Bodies are JSON. The search endpoint is POST /v1/search on both hosts.
  • The sandbox answers from fixed test people. This guide uses Clear Visitor (no match), Exact Match with dob 1980-01-01 (one confirmed registrant in OH) and Name Only (three same-name registrants in AZ, GA and MI, none publishing a birth date). Any other name returns a complete answer with zero records. The full list is on the Sandbox page.
  • Nothing you send to the sandbox is billed or metered; every sandbox body carries "sandbox": true.
The one thing people get wrong. An empty records array is only a clear answer when status is "complete". When status is "partial", some registries did not finish and the list is a lower bound. Read status before records, every time.

Step by step

  1. Search for a person who is not registered. Send the smallest useful query: first name, last name and date of birth. The sandbox name Clear Visitor matches nobody.
  2. Search for a confirmed registrant. Change the name to Exact Match and keep dob 1980-01-01. One record comes back with matchState: "dob_match" — the registry publishes a full birth date and it equals yours.
  3. Search a common name. Send Name Only with a dob of 1988-04-12. Three candidates come back, each unverified: true with matchState: "no_dob_age_year": their registries publish no birth date, so the name is the only evidence. This is the shape your review queue must handle.
  4. Move to production. Replace the host with https://api.offendersearch.app and the key with a live key. The request body and the response shape are identical; the sandbox key disappears.

Step 1 — the request

curl -s https://sandbox.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Clear", "lastName": "Visitor", "dob": "1988-04-12"}}'
Step 1 — response (trimmed: 58 sourceStatus rows cut to one)
{
  "searchId": "srch_e27d123a7dca9a9351cb1869",
  "status": "complete",
  "elapsedMs": 0,
  "counts": {
    "records": 0, "recordsReturned": 0,
    "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0
  },
  "capped": false, "cappedReason": null, "cappedLimit": null, "cappedOmittedSources": [],
  "omittedFields": [],
  "page": 1, "perPage": 0, "totalPages": 1,
  "sourceStatus": [
    { "source": "AK", "status": "ok", "matched": 0, "fromCache": true, "note": null,
      "incomplete": false, "incompleteReason": null, "cappedOmitted": false }
  ],
  "records": [],
  "proof": { "status": "none" },
  "warnings": [],
  "sandbox": true
}

status: "complete", sourcesComplete equal to sourcesQueried, warnings: [] and records: [] together are a clear answer. Keep the searchId: it is what a verification report or a clearance cites later. (One sandbox difference: its cached sourceStatus rows read status: "complete"; production writes ok, error, restricted or no_coverage on cached rows and reserves complete/partial for live rows.)

Step 2 — a confirmed registrant

POST /v1/search body
{ "query": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" } }
Response (trimmed to the fields that decide the outcome)
{
  "searchId": "srch_7a89158926d834f43f66856f",
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "records": [
    {
      "recordId": "rec_28db9639d712c4c61e8e",
      "personRef": "prs_28db9639d712c4c61e8e",
      "uuid": "SBX-OH-100001",
      "matchConfidence": 0.9,
      "matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "firstName:exact", "name_match"],
      "matchDetail": {
        "strategies": { "lastName": "exact", "firstName": "exact" },
        "fieldsPresent": ["lastName", "firstName", "dob"],
        "signals": ["matchedLegalName"], "matchedNameType": "legal", "verification": "dob_match"
      },
      "matchedName": { "value": "Exact Sample Match", "type": "legal" },
      "name": { "first": "Exact", "middle": "Sample", "last": "Match", "suffix": "", "full": "Exact Sample Match" },
      "dob": "1980-01-01", "birthYear": 1980, "dobPrecision": "exact", "age": "46",
      "addresses": [ { "type": "residence", "line1": "100 Sample St", "city": "Sampleville", "county": "Sample", "state": "OH", "zipcode": "00000", "lat": null, "lng": null } ],
      "registrationState": "OH", "addressStates": ["OH"],
      "offenses": [ { "crime": "Sexual battery", "statute": "SAMPLE 2907.03", "tier": "Tier III", "convictionDate": "2004-05-12", "registrationDate": "2008-02-01" } ],
      "images": [ { "url": "https://sandbox.offendersearch.app/v1/sandbox/photos/1.svg" } ],
      "sources": [ { "jurisdiction": "OH", "registryName": "Sandbox sample registry (OH)", "recordUrl": "https://offendersearch.app/docs/sandbox", "lastCheckedAt": "2026-10-01T00:00:00+00:00" } ],
      "matchState": "dob_match",
      "unverified": false
    }
  ],
  "warnings": [],
  "sandbox": true
}

Read matchState first: dob_match means the registry publishes a full date of birth and it equals the one you sent. Then matchDetail.strategies: both name fields matched exact on the registered legal name. matchConfidence 0.9 is the score; it is not the gate (see Matching and confidence). sources[].recordUrl is the registry page a reviewer opens.

Step 3 — candidates without a birth date

POST /v1/search body
{ "query": { "firstName": "Name", "lastName": "Only", "dob": "1988-04-12" } }
Response (trimmed to one of three records)
{
  "status": "complete",
  "counts": { "records": 3, "recordsReturned": 3, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "records": [
    {
      "recordId": "rec_fec5ef71d9deef5bc8c4",
      "personRef": "prs_fec5ef71d9deef5bc8c4",
      "matchConfidence": 0.6,
      "matchBasis": ["lastName", "firstName", "unverified_no_dob_or_age", "lastName:exact", "firstName:exact", "name_match"],
      "matchDetail": { "strategies": { "lastName": "exact", "firstName": "exact" }, "fieldsPresent": ["lastName", "firstName"],
                       "signals": ["unverifiable", "matchedLegalName"], "matchedNameType": "legal", "verification": "unverified_no_dob_or_age" },
      "dob": null, "birthYear": null, "dobPrecision": "unknown", "age": "",
      "registrationState": "AZ",
      "matchState": "no_dob_age_year",
      "unverified": true
    }
  ],
  "warnings": []
}

Three people share the name and none of their registries publishes a birth date, so your dob could neither confirm nor rule them out. They are returned, labelled unverified: true, at a lower score. This is the candidates outcome: a person reviews them against the photo, age and address, and records the verdict with a clearance so the next search for the same subject is quiet.

Step 4 — production

export OFFENDERSEARCH_BASE=https://api.offendersearch.app
export OFFENDERSEARCH_KEY=os_live_...        # from Dashboard → API keys

The same code now searches the continuously updated copy of all 58 US registries. Each live search is billed as one call (pricing); a new account has a free trial of 25 searches capped at 50 records each, and a trial search must carry a first or last name.

Reading the result

FieldValuesWhat it decides here
statuscomplete, partialpartial means the answer is a lower bound; never report it as clear.
counts.recordsintegerTotal matches before paging. 0 with complete is the clear answer.
counts.sourcesComplete / sourcesQueriedintegersHow much of the fan-out answered. Equal means complete.
warnings[][], or sentences starting INCOMPLETE SEARCH:, NARROWED SEARCH:, CAPPED RESULT:[] is the cheapest completeness check there is.
records[].matchStatedob_match, year_match, age_match, no_dob_age_year, age_mismatch, nullHow the birth date agreed. null only when you sent no dob or age.
records[].unverifiedbooleantrue when the record could not be checked against the identifiers you sent.
records[].personRefprs_ + 20 hexThe stable key for the person — store it, clear on it.
records[].recordIdrec_ + 20 hexThe handle for this result — cite it, fetch it with GET /v1/records/{recordId}.
records[].sources[].recordUrlURL or ""The registry page a reviewer verifies against.

The full field list is in the Record object; the order to read the envelope in is Understanding search results.

Edge cases and pitfalls

  • A query must narrow something. firstName alone or state alone is refused with 422 and a sentence beginning This query has nothing to narrow on. Send a surname, a date of birth or age, a city, county, ZIP, radius or q.
  • `state` filters, it does not scope. {"query": {"lastName": "Match", "state": "OH"}} still queries all 58 registries and keeps records registered or resident in OH. Narrowing the fan-out is opt-in (locationScoped: true); see Selecting registries and jurisdictions.
  • The full record is the default. stateData, offenses[] and images[] are all populated without asking; omittedFields is []. For a lighter payload on big result sets send exclude: ["stateData"] — the response then lists it in omittedFields, so a null there is never ambiguous.
  • A record with `matchState: null` was not checked against a birth date — you sent none. Exact Match without dob still scores 0.9; only matchState says the date was verified.
  • Timeouts. Healthy searches answer in well under a second, but set an HTTP timeout of at least 60 seconds: deadlineMs (default 120000) is a target and a search that can finish keeps working up to the ~40 seconds the gateway allows.
  • The sandbox never answers `partial`, 429 or 504. Handle those branches from the contract — Working with incomplete results and Error handling — before you go live.
  • Searches are kept 14 days. GET /v1/searches/{searchId}/results re-opens the stored records (up to 200) in that window and answers 410 after it.

Production checklist

  • Live key in a secret store; base URL switched; test key removed from production config.
  • status !== "complete" handled as its own outcome, never as clear.
  • Candidates go to a human review step; nothing auto-declines on a name match.
  • searchId, recordId and personRef persisted with the outcome for the audit trail.
  • The remaining checks in the Go-live checklist.

Product overview