Synchronous search (PRIMARY — call and wait)
Searches the full dataset by default (or the jurisdictions you name) in a single call and returns scored, de-duplicated results in the response. Served from the current snapshot for speed. Set deadlineMs to bound how long you wait; with onDeadline: partial the response returns whatever completed within that bound, with per-jurisdiction status in sourceStatus; with onDeadline: error a forced-partial result returns 504 instead. Accepts an API key OR a session token.
Request
- Base URL: https://api.offendersearch.app
- URL: https://api.offendersearch.app/v1/search
- Auth: one of:
- API key in header X-API-Key
- HTTP bearer
Request body
Jurisdiction codes — a registry id, or any two-letter code that registry COVERS, in any case. null/omit = the full dataset. ★ AN UNRECOGNISED VALUE IS A 422 (changed 2026-08-05), never an empty result. ["XX"] and ["TEXAS"] previously returned status: "complete", records: 0, sourcesQueried: 0, warnings: [] — indistinguishable from "this person is not a registered offender" — for one wrong token, while ["tx"] returned 1,219. GET /v1/sources lists every id.
OPT-IN ONLY. If true AND query.state is set, run only the registries that COVER that state instead of all 58. query.state alone does NOT turn this on — it is a residence FILTER, not a registry selector, and inferring the scope from it (the behaviour until 2026-08-05) hid every registrant whose registry is not the state they live in: 7.0% of records, and 36% of a matched out-of-state-residence sample returned ZERO. When this narrows the fan-out the response reports counts.sourcesSkippedByScope and a "NARROWED SEARCH" warning, because a registry that was never asked contributes a 0 meaning UNKNOWN, not NO MATCH.
Record types to search on THIS endpoint, which searches the sex-offender registries only. Criminal records are not a recordType here — they are a separate live product at POST /v1/criminal/search, because they carry a different record shape (no photograph or tier, and a recordType of their own naming jail / prison / court / warrant). ★ ANY OTHER VALUE IS A 422 (changed 2026-08-05) — note the underscore: ["sexoffender"] used to return a clean, complete zero.
RETIRED — accept-and-ignore for one release. The per-request daily/weekly freshness tier is gone: it was a two-value price knob that returned the SAME data either way, and it carried a +$0.01/call daily surcharge that no longer exists. SO now serves ONE cached, continuously-updated answer (advertised "close to live"), and re-verification is requested via the live block below instead. Any value you send here — a former tier or anything else — is ACCEPTED with no 422 and simply not read: no billing tier, no serving window, and it is no longer echoed back in the response. Remove it at your convenience.
DEPRECATED 2026-08-04 — leave it unset. The default (include) is the behaviour you want. Still accepted so that request bodies written against the old default keep working. Note before sending it: omit is a filter that DROPS WHOLE REGISTRIES from your own search. A registry it drops contributes zero records and is reported honestly as incomplete — sourceStatus[].incompleteReason: "excluded", counted in counts.sourcesIncomplete, and named in the INCOMPLETE SEARCH: warning — so a 0 from it means UNKNOWN, not NO MATCH. Remove the parameter and those records come back. Does NOT affect billing. (The omit window is now the widest window we serve — the per-request freshness tier it once compared against is retired.)
Fuzzy-match tolerance applied uniformly across ALL jurisdictions. ★ AN UNRECOGNISED VALUE IS A 422 (changed 2026-08-05). A typo used to fall through to balanced: "strick" returned 16 records where the strict the caller asked for returns 11 — a silent WIDENING, and the same fallthrough narrows in the other direction. strict=exact; balanced=nicknames/variants/initials/typos(dist 1) on first name, exact last; broad=also prefix/typo on last name. (query.prefixMatch/query.fuzzy override this — see Query.)
★ THE COMPLETE, ENUMERATED LIST OF WHAT EACH MODE DOES TO A NAME. Nothing outside this table is applied to a name in any mode. Every transformation is reported per record in matchDetail.strategies, so you can always see which one produced a given hit — and filter on it. GIVEN NAME (firstName) exact — equal, case- and accent-insensitive. strict, balanced, broad nickname — a curated given-name equivalence table (123 groups, 418 names): Bob/Bobby/Rob/Robert, Tony/Anthony, Peggy/Margaret, Bill/Will/William. It is a fixed list, not an algorithm; the equivalents we hold for any record are published on that record as nicknames, so you can read the table straight off the wire. balanced, broad initial — one letter against a full name, either direction: J matches John, and J on file is matched by John. balanced, broad prefix — the stored name STARTS WITH what you sent, minimum 3 characters: Thom -> Thomas, Marcel -> Marcellous. Anchored at the start, never a substring. balanced, broad fuzzy — Damerau-Levenshtein edit distance (a swap of two adjacent letters counts as ONE edit, so Jhon -> John). BUDGET: 1 edit in balanced, 2 in broad. Not phonetic — see the broad warning below for why the second edit is opt-in. balanced (1), broad (2) middle — the given name you sent is the person's MIDDLE name. 81.1% of the records we hold carry one, and many people are known by theirs, so firstName=Wayne returns the man registered as Dean Wayne Ellison. WHOLE TOKEN, EXACTLY, minimum 2 characters: Wayne matches the Wayne of Wayne Allen; it does not match Waynette, Wayn or the initial W. Never prefixed, never fuzzy: some names are overwhelmingly MIDDLE names (Lee is 20x commoner in the middle slot than the first), so for exactly the names this matches it draws on a much larger pool — which is where spelling latitude is least affordable. Reported as firstName: "middle" and capped at 0.60. balanced, broad absent — the record publishes no given name at all, so your query could not be DISPROVED. Kept and flagged; never a confirmation. every mode
SURNAME (lastName) exact — equal, or equal to one whole token of a compound surname (Smith matches Hamilton-Smith, and never Goldsmith). strict, balanced, broad prefix — starts-with, minimum 3 characters. broad only fuzzy — edit distance, budget SCALED BY LENGTH: 0 edits at <=4 characters, 1 at 5-7, 2 at >=8. Hernandez -> Fernandez matches; Ross -> Rose does not. broad only There is no nickname table for surnames, and asking for one (nameMatch.lastName: ["nickname"]) is a 422.
ALIASES — searched in balanced and broad, not in strict, and with exact/prefix/fuzzy only. Aliases arrive unstructured and in mixed word order, so every token is tested and we cannot tell you which token was the given name — which is why nickname and initial are not offered on them. An alias-only hit is reported as alias:<strategy> and caps matchConfidence at 0.55. ★ WHAT WE DO NOT DO, STATED PLAINLY, BECAUSE IT IS THE QUESTION EVERY EVALUATION ASKS. No phonetic or Soundex matching (Smyth -> Smith works because it is one edit; Schmidt -> Smith does not and will not). No transliteration. No middle-name field. No cross-field matching — a firstName query is never tested against a surname. No inference of one name from another beyond the fixed nickname table above. If you need a variant we do not cover, send it as a second query; we would rather you saw two honest answers than one widened one.
★ CORRECTION, 2026-08-05 — balanced USED TO FUZZ THE GIVEN NAME AT 2 EDITS, WHICH IS NOT WHAT THIS DOCUMENT HAS EVER SAID. Every client-facing surface promised edit distance 1 for balanced; the engine ran 2. Measured on 4,000 production records over 12 surnames and 25 given names, the second edit returned 2,504 records where 1 returns 1,274 — and of the 1,230 it added, ZERO were nickname-table equivalents and 98.0% carried an entirely unrelated given name (juan->john, carl->paul, gary->mark, rick->eric). Two edits on a four-letter name replace half of it. balanced now behaves as documented. If you were relying on the wider behaviour, ask for broad — it is the same budget, named, and its cost is documented below. Recall was measured before the change on a control cohort: 21 of 21 people from a customer's own audit corpus, and 40 of 40 blind registrants queried by their registered given name, are all still returned.
★ PREFER balanced (the default), ESPECIALLY FOR A NATIONAL SEARCH. balanced already handles the variants people reach for broad to solve — nicknames (Tony/Anthony), initials, first-name prefixes and first-name typos. Measured against 34,894 competitor records, balanced matched 98.28% and broad recovered only 38 more (0.11%).
★ broad FUZZES THE SURNAME, AND THAT COSTS PRECISION. Its surname edit-distance budget scales with length (0 edits at <=4 characters, 1 at 5-7, 2 at >=8), which is why Hernandez/Fernandez matches but Ross/Rose does not. Even so, broad returns roughly 13.5% surname false positives by design: at 6 letters a single edit cannot separate Siegel from Riegel any more than Smith from Smyth. Every such hit is labelled lastName:fuzzy in matchBasis — filter on it before acting. In this API a false positive (naming an innocent person as a registered offender) is a materially worse error than a miss.
★ broad MAY NOT FINISH AT NATIONAL SCOPE. It cannot use the indexed surname range, so it reads far more candidate rows per registry; a 57-registry fan-out can exceed the per-registry deadline. When that happens the response says so (status: partial, counts.sourcesIncomplete > 0, a warnings[] sentence naming each registry) — it is never silently short. Production 2026-08-04, {"firstName":"Tony","lastName":"Wilson"}: balanced returned 182 records complete; broad returned 32 with sourcesIncomplete: 29. Scope broad with query.state to use it safely.
Parity: request extensive detail. ["stateData"] (or ["extensive"]) adds the stateData block. It is the ONLY field behind this option — offenses[] and images[] come back on every response. A response reports what it left out in top-level omittedFields.
Maximum time to wait, in ms. Default 2 minutes (120000); lower it for a faster, bounded response. ★ BOUNDED TO 1000-600000 SINCE 2026-08-05 — outside that is a 422. 0 and -1 were previously accepted and silently ignored, and on the cache-served sync path the per-registry probe deadline governs anyway, so a sub-second value could never have meant what it said.
On deadline: partial=return what completed (status=partial); error=return 504. ★ Any other value is a 422 (changed 2026-08-05); it used to fall through to partial.
Example request
{
"query": {
"firstName": "John",
"lastName": "Smith",
"dob": "1985-06-14",
"age": 39,
"city": "Chicago",
"state": "IL",
"zipcode": "60614",
"lat": 41.9,
"lng": -87.65
},
"jurisdictions": [
"IL",
"IN"
],
"live": {
"jurisdictions": [
"TX"
]
}
}Response
Search results (complete, or partial when a deadlineMs bound is hit)
complete=every registry was searched to the end; partial=at least one was NOT (deadline or candidate-cap truncation) or a source errored; pending/running=async job in flight; error=async job failed. ★ partial is NEVER about how old the data is — a snapshot's age does not affect status. When status is partial, read counts.sourcesIncomplete before treating an empty records as an answer (API-CONTRACT.md §5.0).
Server processing time for the search, in ms.
Plain-language notices about anything that SHORTENED this answer — one sentence per condition, naming the registries. ALWAYS present; [] means nothing did, so warnings == [] is a valid completeness check and the cheapest one available. The loudest entry, always first when present, begins "INCOMPLETE SEARCH:" and means one or more registries could not be searched to the end. Freshness never produces a warning.
The page you asked for, echoed back. 1 when the caller did not paginate. ★ CHANGED 2026-08-05: a query.page past totalPages now returns an EMPTY records array instead of being CLAMPED to the last page. The clamp re-served the last page under every page number you sent (pages 5, 6 and 50 of a 4-page result all carried page 4's records), so the canonical while records: page += 1 client NEVER TERMINATED — and /v1/search meters one billable call per request, so every spin was charged. counts.records and totalPages are unchanged, and walking 1..totalPages returns exactly the same records it always did.
Page size actually applied. NOTE: when the caller did NOT paginate this equals the TOTAL record count (the whole set was returned in one page), not the 20 default.
Total pages at this perPage. 1 when the caller did not paginate.
Set only when an async job failed (status=error).
TRUE when the search matched more records than one response may carry and the list was cut to cappedLimit. Always present, false on virtually every search. Branch on this field, not on prose and not by comparing counts. When it is true, counts.records is still the true number of people who matched — you are holding the first cappedLimit of them. A capped response is not an error and not a partial: every record in it is a real match, and the registries were all searched to the end. To see the rest, narrow the search (see cappedReason).
Why the response was capped; null when capped is false. responseLimit is the fixed ceiling on how many records one response may carry. It exists so that a whole-registry sweep cannot be issued as a single call; it is not a limit on how much of the corpus you may reach, only on how much of it arrives at once.
The ceiling that was applied (currently 4000), or null when capped is false. Read it from the response rather than hard-coding it.
The registries whose records are ENTIRELY absent from this response because of the cap — sorted, and [] on every uncapped search (always present). capped / cappedLimit tell you HOW MANY records did not fit; this tells you WHOSE. Records are ordered by match confidence rather than by registry, so a registry's whole contribution can fall below the line together. ★ NO PAGE OF THIS RESPONSE REACHES A REGISTRY NAMED HERE: totalPages counts only the cappedLimit records carried, so walking every page does not recover them. Their sourceStatus[].matched counts are still exact. The recovery is to NARROW the search until the answer fits — add state, city, zipCode, firstName or dob/age, or name registries directly with jurisdictions. Example: {"lastName":"Smith"} matches 11,338 people nationally and this list names the registries holding the rest; the same query with "state": "GA" returns 894 records with capped: false.
The record FIELDS this response does not carry because you did not request them — ["stateData"] on a default search, [] when include (or compat mode=extensive) was sent. Always present. ★ READ IT BEFORE TREATING A NULL NESTED BLOCK AS A FACT ABOUT THE REGISTRANT. stateData: null is byte-identical for "you did not ask" and "this registry publishes none"; this key is the only thing that separates them. images and offenses are never omitted, so [] there is always a fact about the registrant.
Example response
{
"searchId": "srch_9f2c1a7b",
"usage": {
"liveChecks": 1,
"liveChargeUsd": 0.02,
"display": "1 live checks · $0.02"
},
"meterEvents": [
{
"kind": "live_source",
"quantity": 1,
"chargeCeilingUsd": 2
}
],
"sourceStatus": [
{
"source": "IL"
}
],
"records": [
{
"recordId": "rec_ab12cd34ef56gh78ij90",
"uuid": "IL:X24A0926",
"matchDetail": {
"strategies": {
"lastName": "exact",
"firstName": "nickname"
},
"fieldsPresent": [
"lastName",
"firstName",
"dob"
]
},
"nicknames": [
"john",
"johnny",
"jack"
],
"birthYear": 1976,
"marks": "Tattooed Arm, left upper; Scar on Chest; Pierced ear, left",
"addresses": [
{
"type": "residence"
}
],
"registrationState": "FL",
"addressStates": [
"FL",
"GA"
],
"stateData": {
"shoeSize": "10½",
"build": "Large",
"shoeWidth": "D"
},
"source": {
"jurisdiction": "IL"
},
"sources": [
{
"jurisdiction": "IL"
}
]
}
]
}Changes
No recorded changes to this operation across all 1 revision of this API.