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 onhttps://sandbox.offendersearch.app; a live key (os_live_…) works only onhttps://api.offendersearch.app. Either host refuses the other kind with a401that names the right host. - The header is
X-API-Key. Bodies are JSON. The search endpoint isPOST /v1/searchon both hosts. - The sandbox answers from fixed test people. This guide uses Clear Visitor (no match), Exact Match with
dob1980-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.
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
- 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.
- Search for a confirmed registrant. Change the name to Exact Match and keep
dob1980-01-01. One record comes back withmatchState: "dob_match"— the registry publishes a full birth date and it equals yours. - Search a common name. Send Name Only with a
dobof1988-04-12. Three candidates come back, eachunverified: truewithmatchState: "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. - Move to production. Replace the host with
https://api.offendersearch.appand the key with a live key. The request body and the response shape are identical; thesandboxkey 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"}}'{
"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
{ "query": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" } }{
"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
{ "query": { "firstName": "Name", "lastName": "Only", "dob": "1988-04-12" } }{
"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 keysThe 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
| Field | Values | What it decides here |
|---|---|---|
status | complete, partial | partial means the answer is a lower bound; never report it as clear. |
counts.records | integer | Total matches before paging. 0 with complete is the clear answer. |
counts.sourcesComplete / sourcesQueried | integers | How 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[].matchState | dob_match, year_match, age_match, no_dob_age_year, age_mismatch, null | How the birth date agreed. null only when you sent no dob or age. |
records[].unverified | boolean | true when the record could not be checked against the identifiers you sent. |
records[].personRef | prs_ + 20 hex | The stable key for the person — store it, clear on it. |
records[].recordId | rec_ + 20 hex | The handle for this result — cite it, fetch it with GET /v1/records/{recordId}. |
records[].sources[].recordUrl | URL 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.
firstNamealone orstatealone is refused with422and a sentence beginningThis query has nothing to narrow on. Send a surname, a date of birth or age, a city, county, ZIP, radius orq. - `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[]andimages[]are all populated without asking;omittedFieldsis[]. For a lighter payload on big result sets sendexclude: ["stateData"]— the response then lists it inomittedFields, so anullthere is never ambiguous. - A record with `matchState: null` was not checked against a birth date — you sent none. Exact Match without
dobstill scores 0.9; onlymatchStatesays 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`,
429or504. 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}/resultsre-opens the stored records (up to 200) in that window and answers410after 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,recordIdandpersonRefpersisted with the outcome for the audit trail.- The remaining checks in the Go-live checklist.
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