# 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.

- **HTML:** https://offendersearch.app/docs/learn/check-registered-sex-offender-api
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## 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](/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](/sdk). 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](/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.

**curl**
```bash
# 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"}'
```

**Python**
```python
# pip install requests
import os, requests

KEY = os.environ["OFFENDERSEARCH_TEST_KEY"]      # os_test_… from the dashboard
HOST = "https://sandbox.offendersearch.app"

def sex_offender_check(first, last, dob):
    r = requests.post(
        f"{HOST}/v1/search",
        headers={"X-API-Key": KEY},
        json={"query": {"firstName": first, "lastName": last, "dob": dob}, "match": "balanced"},
        timeout=130,
    )
    r.raise_for_status()
    body = r.json()

    incomplete = [s["source"] for s in body["sourceStatus"] if s["incomplete"]]
    hits = [rec for rec in body["records"] if rec["matchState"] == "dob_match"]
    leads = [rec for rec in body["records"] if rec["matchState"] != "dob_match"]

    if hits:
        return "registered", hits, incomplete
    if body["status"] == "complete" and not leads:
        return "clear", [], []
    return "review", leads, incomplete        # leads to look at, or registries that did not answer

print(sex_offender_check("Exact", "Match", "1980-01-01"))   # ('registered', [...], [])
print(sex_offender_check("Clear", "Visitor", "1988-04-12")) # ('clear', [], [])
```

**Node**
```javascript
// Node 18+: fetch is built in
const KEY = process.env.OFFENDERSEARCH_TEST_KEY;   // os_test_… from the dashboard
const HOST = "https://sandbox.offendersearch.app";

async function sexOffenderCheck(firstName, lastName, dob) {
  const res = await fetch(`${HOST}/v1/search`, {
    method: "POST",
    headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ query: { firstName, lastName, dob }, match: "balanced" }),
  });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  const body = await res.json();

  const incomplete = body.sourceStatus.filter((s) => s.incomplete).map((s) => s.source);
  const hits = body.records.filter((r) => r.matchState === "dob_match");
  const leads = body.records.filter((r) => r.matchState !== "dob_match");

  if (hits.length) return { verdict: "registered", hits, incomplete };
  if (body.status === "complete" && !leads.length) return { verdict: "clear" };
  return { verdict: "review", leads, incomplete };   // leads to look at, or registries that did not answer
}

console.log(await sexOffenderCheck("Exact", "Match", "1980-01-01"));   // registered
console.log(await sexOffenderCheck("Clear", "Visitor", "1988-04-12")); // clear
```

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](/docs/search) 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:

*POST /v1/search — the envelope for Exact Match*
```json
{
  "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`.

> **An incomplete registry means "not clear".** A `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](/docs/guides/sex-offender/incomplete-results) and the [result completeness](/docs/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.

```bash
# 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": true
```

So `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](/docs/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](/docs/guides/sex-offender/matching-and-confidence) and the [date of birth](/docs/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.

*Clear Visitor*
```json
{
  "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:

*Exact Match, dob_match*
```json
{
  "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`. `offense` is the first of them.
- **`images[]`** carries photo URLs; **`addresses[]`** carries typed addresses (`residence`, `employment`, and so on) with `lat` and `lng` when the registry publishes them; **`flags`** carries `absconder` and `predator`; **`housingStatus`** is `homeless` for the sandbox name `Homeless Registrant`.
- **`sources[]`** is the citation: `jurisdiction`, `registryName`, `recordUrl`, `recordedAt`, `lastCheckedAt`, `sourceUpdatedAt`. A person on two registries has two entries (sandbox `Multi 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](/docs/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

1. **Mint a test key and point at the sandbox.** Create an `os_test_` key in the dashboard, export it as `OFFENDERSEARCH_TEST_KEY`, and use `https://sandbox.offendersearch.app`. Nothing on the sandbox is billed.
2. **Send name and date of birth to POST /v1/search.** Put `firstName`, `lastName` and `dob` (YYYY-MM-DD) inside `query`. Leave `match` at the default "balanced". Sandbox: "Exact Match" with 1980-01-01.
3. **Read status, counts and sourceStatus.** `status` "complete" with `counts.sourcesIncomplete` 0 means each registry in scope answered. Any `sourceStatus` row with `incomplete: true` means "not clear", never "no match".
4. **Read matchState on each record.** `dob_match` is the only automatic identification. `year_match`, `age_match` and `no_dob_age_year` are leads for a reviewer; a record whose DOB contradicts yours is not returned at all.
5. **Scope with state or jurisdictions, not locationScoped.** `query.state` filters the answer by residence or registration; `jurisdictions` lists registries to search. Send `locationScoped: true` only when you want a narrower search on purpose.
6. **Store the citation and keep checking.** Persist `searchId`, `recordId`, `personRef` and `sources[].recordUrl` with `lastCheckedAt`. For an ongoing check, create a person monitor with `POST /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](/docs/learn/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](/docs/learn/monitor-a-person-with-webhooks) tutorial builds the receiver, and [sex offender monitoring](/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](/fcra), 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](/sex-offender-map-api).
- Asynchronous searches with `POST /v1/searches` and a `webhookUrl`: see [async and webhooks](/docs/async-and-webhooks).
- Live re-verification with the `live` block and PDF evidence ($0.02 per completed source): see [live evidence](/docs/live-evidence).
- Criminal records, which are a separate endpoint with a different record shape: [search criminal records by name and date of birth](/docs/learn/search-criminal-records-by-name-and-dob).

## Related

- [Background check API tutorial](https://offendersearch.app/docs/learn/background-check-api-tutorial)
- [Search criminal records by name and date of birth](https://offendersearch.app/docs/learn/search-criminal-records-by-name-and-dob)
- [Monitor a person with webhooks](https://offendersearch.app/docs/learn/monitor-a-person-with-webhooks)
- [No-match and multiple-match results](https://offendersearch.app/docs/learn/no-match-and-multiple-match-results)
- [Background check API response explained](https://offendersearch.app/docs/learn/background-check-api-response-explained)
- [Sex offender API](https://offendersearch.app/sex-offender-api)
- [Sex offender monitoring](https://offendersearch.app/sex-offender-monitoring)
- [Search reference (POST /v1/search)](https://offendersearch.app/docs/search)
- [Matching and confidence](https://offendersearch.app/docs/matching)
- [Date of birth matching guide](https://offendersearch.app/docs/guides/sex-offender/date-of-birth-matching)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
