Search — POST /v1/search
The primary endpoint. Every parameter, the four stages of a search, and nine worked query shapes.
Base URL https://api.offendersearch.appThis page as Markdown/docs/search.mdHow a search works
Every search moves through the same four stages, so the response is predictable and the same query returns the same answer:
- Dispatch. Your
queryis matched across the jurisdictions you targeted — all 58 by default, or the codes injurisdictions. - Match & verify. Each jurisdiction’s rows are filtered by your
matchmode, then checked against anydob/ageyou supplied to confirm identity. - De-duplicate & score. The same person present in multiple jurisdictions is merged into one record whose
sources[]lists every corroborating jurisdiction, and each record gets amatchConfidenceandmatchBasis. - Return. You get
records, a per-jurisdictionsourceStatus, andcounts— withstatus: "complete"or"partial".
sourceStatus[] with its own status and freshness, so coverage is reported per response rather than assumed. See Result completeness & per-source status.Three companion pages carry the detail this endpoint reference assumes: Matching & confidence for the match modes and the closed strategy vocabulary, Searching by date of birth for matchState, and Jurisdictions for the difference between scoping a search and filtering a result.
Synchronous search
The primary endpoint. One authenticated call searches the full dataset (or the jurisdictions you name), scores and de-duplicates the matches, and returns them in a single response.
Send a query describing the person you are checking. By default the search covers the full dataset at once; you can narrow it with jurisdictions, tune the fuzzy-match tolerance with match, pick a freshness tier with freshness, request extended detail with include, and cap how long you are willing to wait with deadlineMs.
Only know part of a name? Set prefixMatch to "firstName", "lastName" or "both" and the name you send is treated as the start of a name — thom returns Thomas, Thompson and Thomason — matched against aliases as well as the registered name. Minimum 3 characters. See Partial name search.
The synchronous endpoint answers from the maintained corpus in a single round trip, and every response reports its own elapsedMs so you can measure it against your own traffic. If you set a deadlineMs bound, the response comes back with status: "partial" once that bound is reached, with per-jurisdiction status reported in sourceStatus, so you always get an answer within the time you allow.
Every record you get back carries a matchConfidence score, the matchBasis (why it matched, per field — e.g. lastName:prefix, alias:prefix), a matchedName saying whether the registered name or an alias matched, a dobVerification result, and full per-source provenance with a source citation and a lastCheckedAt timestamp.
- Search all or some jurisdictions. Omit jurisdictions for a nationwide search, or pass codes like ["TX","NY"] to scope it.
- Name, DOB, age, or location. Match on any combination — last name or q is the only hard requirement; DOB or age dramatically improves confidence.
- Nickname-aware name search. A firstName is expanded to its nicknames/variants (John ↔ Johnny ↔ Jack) for recall, and the expansion is echoed back in each record’s nicknames[].
- Partial (prefix) name search. Set prefixMatch to search on the start of a name — "thom" returns Thomas, Thompson and Thomason — on the first name, the last name, or both, and across aliases as well as the registered name.
- Geographic radius. Provide lat/lng + radiusMiles (defaults to 1, capped at 100) to find registrants near a point; each address carries offender/predator flags and lat/lng. Radius matching selects on published coordinates, so use a name search when you need selection that is independent of address coordinates.
- Free-text and fuzzy street match. Use q for a single free-text field, or address for a fuzzy street-address match.
- Tunable fuzzy matching. Choose strict, balanced, or broad to trade recall for precision uniformly across every jurisdiction.
- Extended per-state detail. Add include: ["stateData"] to get offenses[], photos, vehicles, and state-specific fields.
- De-duplicated people. The same person appearing in several sources is merged into one record with a sources[] array.
- Deadline control. Set deadlineMs and onDeadline to bound latency, or let it run to completeness.
| Field | Type | Description |
|---|---|---|
| query required | object | The identity fields to search on. See the query table below. |
| jurisdictions optional · default null | string[] | null | Jurisdiction codes to search. null or omitted searches the full dataset, e.g. ["IL","IN"]. |
| locationScoped optional · default false | boolean | Scope control: when true AND query.state is set, only the jurisdictions covering that state are queried, instead of all 58. It is never inferred from query.state alone — state is a residence filter, not a jurisdiction selector, and a record can be held by one jurisdiction while the registrant has an address in another. When this narrows the fan-out, the response reports counts.sourcesSkippedByScope and a NARROWED SEARCH warning. |
| freshness optional · default "daily" | "daily" | "weekly" | Which tier answers the search. "daily" is the default and the most current tier, billed at +$0.01/call. "weekly" carries no surcharge and is one tier behind; identity fields — name, date of birth, offence history — are equivalent between the two. Every record carries its own lastCheckedAt on either tier, and sourceStatus reports per-source freshness on every response. |
| match optional · default "balanced" | "strict" | "balanced" | "broad" | Fuzzy-match tolerance applied uniformly across all registries. See Matching & confidence. |
| include optional | string[] | Request extra detail: "stateData" (per-state extended fields, offenses[], photos, vehicles) and/or "raw". |
| recordTypes optional · default ["sex_offender"] | string[] | Which record types to return. Additive as new types ship. |
| deadlineMs optional · default 120000 | integer | How long to wait, in ms (max 300000). Completeness-first default of 2 minutes; lower it for a fast bounded response. |
| onDeadline optional · default "partial" | "partial" | "error" | On timeout, return partial results (default) or a 504 error. |
lastName (or q, or a lat/lng radius) is the primary key; every other field is an optional filter or verifier.
| Field | Type | Description |
|---|---|---|
| firstName optional | string | Given name. Optional but improves ranking and enables nickname matching. |
| lastName optional | string | Surname — the main search key. |
| prefixMatch optional | "firstName" | "lastName" | "both" | Partial-name search: treat the name(s) you sent as the START of a name, so "thom" returns Thomas, Thompson and Thomason. Minimum 3 characters; also matches aliases. See Partial name search. |
| nameMatch optional | object | Per-field control over how names are matched: { firstName: ["prefix","nickname"], lastName: ["prefix"], aliases: true }. Overrides prefixMatch and match. See Partial name search. |
| dob optional | date (YYYY-MM-DD) | The strongest verifier; a match on DOB pushes confidence toward 1.0. |
| age optional | integer | Used to verify identity when a DOB is not available. |
| ageTolerance optional · default 1 | integer (0–10) | How many years of slack the age comparison allows when you send a dob and the record publishes only an age. Eleven registries publish an age and no date at all, so a name + DOB search compares your date against a published age — and how much slack that allows is a risk decision that belongs to you. The default of 1 is not arbitrary: a published age with an unpublished birthday is consistent with two birth years, and the age is anchored to the date we read that registry page rather than to today. Raise it for a high-recall screening pass — more same-name strangers returned, fewer true matches missed. |
| onAgeMismatch optional · default "drop" | "drop" | "flag" | What to do with a record that matches on NAME but whose published age contradicts the dob you sent. "drop" omits it. "flag" returns it labelled matchState: "age_mismatch" so you can judge it yourself — "silently omitted" and "checked, and the age contradicts your date" are different facts. A flagged record is always unverified and can never be read as a confirmed identification. |
| city optional | string | Residence city filter. |
| state optional | string | 2-letter USPS code or the full state name — identical results; an unresolvable value returns 422 rather than an empty result. It is a FILTER, not a jurisdiction selector: it does NOT change which jurisdictions run — all 58 are queried and state narrows the answer. Pass locationScoped: true when you explicitly want the narrower, cheaper fan-out. It FILTERS as a UNION, not as a plain residence test: a record is kept when either it has an address in that state OR that state's jurisdiction is the one holding it. Those halves are different populations — 105,028 records are held by a jurisdiction they have no address in, and 70,973 carry no address state at all and are reachable only by the second half. Every record returns registrationState and addressStates so you can tell which half matched without a second call. For the registration half alone, send jurisdictions instead. |
| zipcode optional | string | Residence ZIP filter. |
| address optional | string | Fuzzy street-address match. |
| lat / lng optional | number | Coordinates for a radius search. |
| radiusMiles optional · default — | number | Radius around lat/lng (max 100). Returns registrants near the point. |
| q optional | string | Free-text search across name, aliases, city, ZIP, and address in one field. |
| createdAtStart / createdAtEnd optional | date-time | Filter on when we first recorded the record (source.scrapedAt) — inclusive range bounds. |
| updatedAtStart / updatedAtEnd optional | date-time | Filter on when the source last changed the record (source.sourceUpdatedAt) — inclusive range bounds. |
curl https://api.offendersearch.app/v1/search \
-H "X-API-Key: $OFFENDERSEARCH_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": {
"firstName": "John",
"lastName": "Doe",
"dob": "1980-04-12"
},
"jurisdictions": null,
"freshness": "daily",
"match": "balanced",
"include": ["stateData"]
}'{
"searchId": "srch_9f2a7c",
"status": "complete",
"freshness": "daily",
"elapsedMs": 142,
"counts": { "records": 1, "sourcesQueried": 2, "sourcesComplete": 2,
"sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
"page": 1,
"perPage": 1,
"totalPages": 1,
"warnings": [],
"sourceStatus": [
{ "source": "NJ", "status": "ok", "matched": 1, "fromCache": true,
"note": "served from nightly cache",
"incomplete": false, "incompleteReason": null },
{ "source": "NSOPW", "status": "ok", "matched": 1, "fromCache": true,
"note": "served from nightly cache",
"incomplete": false, "incompleteReason": null }
],
"records": [
{
"recordId": "rec_4b1e",
"uuid": "b1e4-…",
"recordType": "sex_offender",
"matchConfidence": 1.0,
"matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "name_match"],
"matchDetail": {
"strategies": { "lastName": "exact" },
"fieldsPresent": ["lastName", "firstName", "dob"],
"signals": ["matchedLegalName"],
"matchedNameType": "legal",
"verification": "dob_match"
},
"name": { "first": "John", "middle": "A", "last": "Doe", "full": "John A. Doe" },
"aliases": ["Johnny Doe"],
"nicknames": ["john", "johnny", "jack"],
"dob": "1980-04-12",
"birthYear": 1980,
"dobPrecision": "exact",
"age": "46",
"sex": "male", "race": "white", "height": "5'10\"", "weight": "180",
"addresses": [
{ "type": "residence", "line1": "12 Main St", "city": "Trenton",
"county": "Mercer", "state": "NJ", "zipcode": "08608",
"lat": 40.2171, "lng": -74.7429 }
],
"registrationState": "NJ",
"addressStates": ["NJ"],
"offense": { "tier": "II", "riskLevel": "", "statute": "2C:14-2",
"crime": "Sexual assault", "registrationDate": "2015-06-01" },
"offenses": [
{ "crime": "Sexual assault", "statute": "2C:14-2", "tier": "II",
"convictionDate": "2014-11-03", "registrationDate": "2015-06-01" }
],
"stateData": {
"stateOffenderId": "NJ-00123", "status": "active", "designation": "Tier II",
"registrationEnds": "2035-06-01", "lawAgency": "Example County"
},
"flags": { "absconder": false, "predator": false },
"images": [{ "url": "https://sor.example-state.gov/offender/NJ-00123/image" }],
"matchState": "dob_match",
"dobVerification": "dob_match",
"unverified": false,
"source": {
"jurisdiction": "NJ",
"registryName": "State Sex Offender Registry",
"recordUrl": "https://sor.example-state.gov/offender/NJ-00123",
"scrapedAt": "2026-07-25T09:14:00Z",
"lastCheckedAt": "2026-07-25T09:14:00Z",
"sourceUpdatedAt": "2026-07-20T00:00:00Z"
},
"sources": [
{ "jurisdiction": "NJ", "registryName": "State Sex Offender Registry",
"recordUrl": "https://sor.example-state.gov/offender/NJ-00123",
"scrapedAt": "2026-07-25T09:14:00Z",
"lastCheckedAt": "2026-07-25T09:14:00Z",
"sourceUpdatedAt": "2026-07-20T00:00:00Z" },
{ "jurisdiction": "NSOPW", "registryName": "NSOPW (National Sex Offender Public Website)",
"recordUrl": "https://sor.example-state.gov/offender/NJ-00123",
"scrapedAt": "2026-07-25T09:14:00Z",
"lastCheckedAt": "2026-07-25T09:14:00Z",
"sourceUpdatedAt": null }
]
}
]
}counts summarizes the search; sourceStatus reports every jurisdiction touched (ok / error / restricted / no_coverage / pending) with a lastCheckedAt timestamp; records holds the scored, de-duplicated matches.
Only know part of a name? Set prefixMatch and the name you send is treated as the START of a name — "thom" returns Thomas, Thompson and Thomason. Use "firstName", "lastName", or "both". Minimum 3 characters (shorter returns 422). Prefixes are matched against every recorded alias as well as the registered name, and each record’s matchedName tells you which one matched. An exact match always ranks above a prefix match.
{
"query": {
"lastName": "thom",
"prefixMatch": "lastName",
"state": "TX"
}
}
// Returns Thomas, Thompson, Thomason, Thom, Hamilton-Thompson ...
// but NOT Bothomley - we anchor at the start of a name, never mid-word.
//
// Each record reports how it matched:
// "matchBasis": ["lastName:prefix", "name_match"]
// "matchedName": { "value": "Ana Thompson", "type": "legal" }
// "matchConfidence": 0.60 <- capped: a prefix hit is a CANDIDATEprefixMatch: "both" prefix-matches the first and last name together; a record must satisfy both. Add a dob or age to keep precision while widening recall — a confirmed DOB lifts the confidence cap that a partial-name match otherwise carries.
{
"query": {
"firstName": "thom",
"lastName": "and",
"prefixMatch": "both",
"dob": "1980-04-12"
}
}
// -> Thomas Anderson, Thompson Andrews, ...prefixMatch is the shorthand. Send nameMatch when you want to drive matching yourself: list exactly the strategies you want per field. Anything you leave out is OFF for that field; exact matching is always on and cannot be disabled. nameMatch overrides both prefixMatch and match. firstName accepts prefix, nickname, fuzzy and middle; lastName accepts prefix and fuzzy; aliases toggles alias matching (default true).
{
"query": {
"firstName": "Robert",
"lastName": "thom",
"nameMatch": {
"firstName": [],
"lastName": ["prefix"],
"aliases": true
}
}
}
// firstName: [] exact only - no nickname widening, so no "Bob"
// lastName: ["prefix"] Thompson, Thomas, Thomason ...
// aliases: true also match aliases (the default)
//
// -> Robert Thompson (not Bob Thompson)Pass jurisdiction codes in jurisdictions to limit the search. Naming ["TX"] searches Texas only.
{
"query": { "firstName": "Maria", "lastName": "Lopez" },
"jurisdictions": ["TX"]
}Combine lat/lng with radiusMiles for a geographic search — useful for “who lives near this school / address”.
{
"query": {
"lat": 40.7357,
"lng": -74.1724,
"radiusMiles": 5
}
}Pass q to search across name, aliases, city, ZIP, and address in a single field — the legacy-style catch-all.
{
"query": { "q": "john doe newark nj" },
"match": "broad"
}Use match: "strict" to require exact name + DOB, and freshness: "daily" — the most current tier — for a point-in-time answer you can defend.
{
"query": { "firstName": "John", "lastName": "Doe", "dob": "1980-04-12" },
"match": "strict",
"freshness": "daily"
}Add include: ["stateData"] to pull the full offenses[] list, photos, vehicles, and state-specific fields (parity with a legacy extensive mode).
{
"query": { "firstName": "John", "lastName": "Doe" },
"include": ["stateData"]
}Lower deadlineMs and keep onDeadline: "partial" to guarantee a quick answer within the bound you set.
{
"query": { "lastName": "Doe" },
"deadlineMs": 4000,
"onDeadline": "partial"
}What the envelope guarantees
- Every top-level key is always present. Test the value, never for key existence. See The Record object.
- Coverage is reported, not implied.
counts.sourcesQueried,sourcesComplete,sourcesIncompleteandsourcesSkippedByScopeappear on every response, alongside asourceStatus[]entry per jurisdiction. - Match strength is labelled.
matchState,matchBasis,matchDetail,matchedNameandmatchConfidencelet you set your own auto-accept threshold rather than inherit one. - The contract is additive. New data lands as a new
recordType; existing keys and their meanings do not change under you. - Ordering is total and stable. The same query returns the same records in the same order, so paging never reshuffles. See Pagination.