Check if someone is a registered sex offender with an API
A sex offender check API call, done properly: name plus date of birth to POST /v1/search, the 58-registry fan-out and what an incomplete registry means, scoping by state without hiding out-of-state registrants, match modes, aliases, typo tolerance, and the shape of a hit and of a clear answer.
Updated November 3, 2026
How a sex offender check API answers "is this person registered?"
To check if someone is a registered sex offender programmatically you send one request, POST /v1/search, and the platform searches 58 US registries for that name, verifies each candidate against the date of birth you sent, and returns the records that survive with a per-registry status. The sex offender API is one endpoint, one header (X-API-Key), one JSON body. Python and Node.js SDKs ship with the first registry release; see SDKs. Until then the raw HTTP calls below are the integration.
There are three possible outcomes, and your code needs to distinguish all three. A hit: a record with matchState: "dob_match". A clear answer: status: "complete", counts.sourcesIncomplete: 0, records: []. And a review: records that matched on name but could not be verified, or registries that did not answer. Collapsing the third outcome into either of the first two is the mistake this page exists to prevent.
All examples run on the sandbox with the magic names: Exact Match (a verified hit), Name Only (three name-only leads in three states), Out State (registered by one state, living in another), Alias Match (matched through an alias) and Clear Visitor (zero records). Any other name on the sandbox returns a complete answer with zero records. A test key is free and limited to 60 calls a minute and 2,000 a day.
The sex offender check request: name plus date of birth
Send firstName, lastName and dob inside query. The date of birth is what turns a name match into an identification. Without it, a record that matched on name comes back with matchState: null, because there was nothing to verify against; with it, the same record comes back as dob_match when the registry's full date agrees, and is dropped when it contradicts. Send Exact Match with 1990-05-05 and you get zero records: the sample registrant was born in 1980, so that record is not this person.
# Is this person on a registry? Name + date of birth, against the sandbox (free)
curl -X POST https://sandbox.offendersearch.app/v1/search \
-H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}, "match": "balanced"}'The Python and Node versions wrap the call in a function that returns one of the three verdicts. Note what they do not do: they never return "clear" while a registry is incomplete, and they never count a year_match, age_match or no_dob_age_year record as a hit. Try Name Only with any date of birth: three records in three states come back, none of which publishes a date, so all three are no_dob_age_year leads and the function answers "review".
A query must constrain something; a body with only firstName, only state, or nothing at all is 422 with a detail sentence. If you have no date of birth, age plus ageTolerance (default 1) is the next best verifier, and a city, county, state or zipcode narrows the answer. The search reference lists every query field.
The 58-registry fan-out: sourceStatus, and why incomplete is not "no match"
Every search fans out to the registries in scope, 58 by default, and the envelope reports on each of them before it reports any record:
{
"searchId": "srch_sbx_so_exact",
"status": "complete",
"counts": {
"records": 1,
"sourcesQueried": 58,
"sourcesIncomplete": 0,
"sourcesComplete": 58,
"sourcesSkippedByScope": 0,
"recordsReturned": 1
},
"warnings": [],
"sourceStatus": [
{ "source": "AK", "status": "complete", "matched": 0, "fromCache": true,
"note": null, "incomplete": false, "incompleteReason": null },
{ "source": "OH", "status": "complete", "matched": 1, "fromCache": true,
"note": null, "incomplete": false, "incompleteReason": null },
/* … 56 more rows, every one "status": "complete", "matched": 0 */
],
"records": [ /* … */ ]
}counts.sourcesQueried is how many registries were in scope; counts.sourcesComplete and counts.sourcesIncomplete split them into those that answered and those that did not; sourceStatus[] has one row per registry with status, matched, fromCache, incomplete and incompleteReason. When any registry is incomplete, status at the top is partial.
partial answer with records: [] is not a clear answer. The person could be on the registry that did not answer. Show the reviewer which registry it was (sourceStatus[].source), retry, or hold the decision; never report "no match" from an incomplete fan-out.By default a search that cannot finish inside the deadline (deadlineMs, default 120000) answers with whatever is in hand, marked partial. If your product would rather fail than show an incomplete answer, send "onDeadline": "error" and handle the 504. The incomplete results guide and the result completeness reference have the full decision tree.
Scoping a registered sex offender check: jurisdictions and locationScoped
The instinct is to search only the state the person lives in. The reason not to is that registration and residence are different things: a person can be registered by one state and live in another. The sandbox name Out State makes the point. Search it with state: "TX" and the record comes back with registrationState: "OK" and addressStates: ["TX"]: Oklahoma holds the registration, the person lives in Texas.
# A registrant can be registered by one state and live in another.
# "state" is a filter on the ANSWER (either registrationState or addressStates), not on the fan-out.
curl -X POST https://sandbox.offendersearch.app/v1/search \
-H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" -H "Content-Type: application/json" \
-d '{"query": {"firstName": "Out", "lastName": "State", "state": "TX"}}'
# records[0]: registered by Oklahoma, resident in Texas
{ "name": { "first": "Out", "last": "State", /* … */ },
"registrationState": "OK",
"addressStates": ["TX"],
/* … */ }
# Only the Texas registry's own roster — the registration-only filter:
# "jurisdictions": ["TX"] → Out State is NOT returned (OK holds the record)
# Narrow the fan-out itself (cheaper, narrower, opt-in):
# "query": {"state": "TX"}, "locationScoped": trueSo query.state does not change which registries run. All 58 are searched, and state keeps a record when either one of its addressStates is that state or its registrationState is. Every record carries both keys, so you can tell which half matched without a second call. If you want a single registry's own roster, jurisdictions: ["TX"] is the registration-only filter, and it would not return Out State. counts.sourcesSkippedByScope tells you how many registries a scope left out.
locationScoped: true is the opt-in that narrows the fan-out itself to the registries implied by the place in your query. It is cheaper and faster, and it is exactly the search that hides an out-of-state registrant, which is why it is off by default. Send it only when you have decided that a narrower search is what you want. The jurisdictions reference lists the codes.
Match modes, aliases and dobTolerance in a sex offender check
Three settings decide how far from an exact name and date the search will reach.
| Setting | Values | What it does |
|---|---|---|
match | strict, balanced (default), broad | strict is exact on both names; balanced allows nicknames, variants, initials and one-character typos on the first name with an exact last name; broad also allows a prefix or typo on the last name. Each transformation used is reported in matchDetail.strategies. |
| aliases | always on | A query is matched against each record's registered aliases as well as its legal name. matchedName.type says which produced the hit (legal or alias); an alias hit also carries alias:exact in matchBasis. |
dobTolerance | 0 (default) to 2 | Keeps a record whose full date is a likely typo of yours (year off by up to the tolerance, or day and month swapped) as matchState: "dob_near_match", always unverified: true, instead of dropping it. |
The sandbox name Alias Match demonstrates the alias path: the record comes back with matchedName.type: "alias" and matchBasis containing alias:exact. Show matchedName.type to the reviewer; a name that matched only through an alias deserves a second look even when the date verifies it. dobTolerance exists for the case where the date you hold was typed by a person: it never upgrades a near match to dob_match, it only stops the record from being silently dropped. The matching and confidence guide and the date of birth reference go deeper.
Reading a sex offender check result: the clear answer and the hit
The clear answer first, because it is the one most integrations get wrong. Clear Visitor returns this: a complete fan-out, zero records, nothing incomplete. Those three facts together are what "clear" means.
{
"searchId": "srch_sbx_so_clear",
"status": "complete",
"counts": { "records": 0, "sourcesQueried": 58, "sourcesIncomplete": 0,
"sourcesComplete": 58, "sourcesSkippedByScope": 0, "recordsReturned": 0 },
"warnings": [],
"sourceStatus": [ /* 58 rows, all "complete" */ ],
"records": []
}A hit is one person in one registry. The Exact Match record, with the physical description trimmed:
{
"recordId": "rec_28db9639d712c4c61e8e",
"personRef": "prs_28db9639d712c4c61e8e",
"recordType": "sex_offender",
"matchConfidence": 0.9,
"matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "firstName:exact", "name_match"],
"matchedName": { "value": "Exact Sample Match", "type": "legal" },
"name": { "first": "Exact", "middle": "Sample", "last": "Match", "suffix": "", "full": "Exact Sample Match" },
"aliases": [],
"dob": "1980-01-01", "dobPrecision": "exact", "age": "46", "sex": "M",
"addresses": [
{ "type": "residence", "line1": "100 Sample St", "city": "Sampleville", "county": "Sample", "state": "OH", "zipcode": "00000", "lat": null, "lng": null },
{ "type": "employment", "line1": "200 Example Ave", "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", "offenseDate": "2003-11-02", "registrationDate": "2008-02-01",
"releaseDate": "2008-01-15", "caseNumber": "SBX-2003-000101", "victimAge": "Minor", "convictionCounty": "Sample" } ],
"flags": { "absconder": false, "predator": false },
"images": [ { "url": "https://sandbox.offendersearch.app/v1/sandbox/photos/1.svg" } ],
"housingStatus": "housed",
"sources": [ { "jurisdiction": "OH", "registryName": "Sandbox sample registry (OH)",
"recordUrl": "https://offendersearch.app/docs/sandbox",
"recordedAt": "2025-01-10T00:00:00+00:00", "lastCheckedAt": "2026-10-01T00:00:00+00:00",
"sourceUpdatedAt": "2026-10-01T00:00:00+00:00" } ],
"dobVerification": "dob_match",
"matchState": "dob_match",
"unverified": false
}- `matchState` and `dobVerification` say how the identity was verified; `matchConfidence` (0 to 1) and `matchBasis` say how the name matched.
- `offenses[]` carries the registered offenses:
crime,statute,tier,convictionDate,offenseDate,registrationDate,caseNumber,victimAge,convictionCounty.offenseis the first of them. - `images[]` carries photo URLs; `addresses[]` carries typed addresses (
residence,employment, and so on) withlatandlngwhen the registry publishes them; `flags` carriesabsconderandpredator; `housingStatus` ishomelessfor the sandbox nameHomeless Registrant. - `sources[]` is the citation:
jurisdiction,registryName,recordUrl,recordedAt,lastCheckedAt,sourceUpdatedAt. A person on two registries has two entries (sandboxMulti State: one record, sources from FL and GA). - `registrationState` and `addressStates` tell you who holds the record and where the person lives.
Store searchId, recordId, personRef and every sources[].recordUrl with its lastCheckedAt; GET /v1/records/{recordId} fetches the record again later. The record object reference documents every key, and include: ["stateData"] adds the registry's own status fields.
Check if someone is a registered sex offender with the API: the procedure
- Mint a test key and point at the sandbox. Create an
os_test_key in the dashboard, export it asOFFENDERSEARCH_TEST_KEY, and usehttps://sandbox.offendersearch.app. Nothing on the sandbox is billed. - Send name and date of birth to POST /v1/search. Put
firstName,lastNameanddob(YYYY-MM-DD) insidequery. Leavematchat the default "balanced". Sandbox: "Exact Match" with 1980-01-01. - Read status, counts and sourceStatus.
status"complete" withcounts.sourcesIncomplete0 means each registry in scope answered. AnysourceStatusrow withincomplete: truemeans "not clear", never "no match". - Read matchState on each record.
dob_matchis the only automatic identification.year_match,age_matchandno_dob_age_yearare leads for a reviewer; a record whose DOB contradicts yours is not returned at all. - Scope with state or jurisdictions, not locationScoped.
query.statefilters the answer by residence or registration;jurisdictionslists registries to search. SendlocationScoped: trueonly when you want a narrower search on purpose. - Store the citation and keep checking. Persist
searchId,recordId,personRefandsources[].recordUrlwithlastCheckedAt. For an ongoing check, create a person monitor withPOST /v1/monitors.
Going to production is a host and key swap: https://api.offendersearch.app and an os_live_ key. Pricing is $0.15 a call, $0.11 past 2,000 calls in a month, 25 free calls, no card. The background check API tutorial walks through the swap and the retry rules.
An ongoing sex offender check: monitors instead of repeated searches
A search answers "today". If the question is "tell me if this person ever appears", create a person monitor instead of re-running the search on a schedule: POST /v1/monitors with product: "sex-offender", type: "person", the person (firstName, lastName required, dob), a minConfidence such as dob_match, and channels.webhookUrl. The platform evaluates it continuously and sends a signed monitor.alert with event: "new", "changed" or "removed" when something changes. On the sandbox, a monitor for Exact Match with 1980-01-01 fires one new alert at once so you can test the receiver. Person monitors are $3 a month per person monitor, $10 per location monitor; the monitor a person with webhooks tutorial builds the receiver, and sex offender monitoring describes the product.
Sex offender check API FAQ
Can I check if someone is a registered sex offender with just a name?
You can search with a name, and you will get the records that match it, but none of them can be identified as the person without a date of birth or an age. They come back as matchState: null (no verifier sent) or no_dob_age_year (the registry publishes none), which are leads for a reviewer, not hits.
Why did a record I expected not come back?
Most often because the date of birth you sent contradicts the registry's, so the record was dropped as a different person. Check the date; if it may have been mistyped, send dobTolerance: 1 and look for dob_near_match. The other common cause is jurisdictions or locationScoped narrowing the search past the registry that holds the record.
Is a `partial` answer with no records a clear result?
No. At least one registry did not answer, and sourceStatus[] names it. Retry, or tell the reviewer which registry is outstanding. Only status: "complete" with records: [] is clear.
Should I search only the state where the person lives?
Search all 58 registries and filter with query.state if you need to. A state filter keeps anyone who lives there or is registered there; a jurisdictions list or locationScoped: true would miss a person registered elsewhere.
Can I use this check for hiring or tenant screening?
The standard search is informational and is not a consumer report. A hiring, tenant, credit or insurance decision runs as an FCRA order on the FCRA Partner API, which records consent and handles adverse action. Whether your use is a permissible purpose is a question for your counsel.
What this sex offender check tutorial does not cover
- Place-based searches (
address,lat,lng,radiusMiles) and the map product: see sex offender map API. - Asynchronous searches with
POST /v1/searchesand awebhookUrl: see async and webhooks. - Live re-verification with the
liveblock and PDF evidence ($0.02 per completed source): see live evidence. - Criminal records, which are a separate endpoint with a different record shape: search criminal records by name and date of birth.