Search - POST /v1/criminal/search
The primary endpoint. Cached by default, live verification on request, and every parameter.
Base URL https://api.offendersearch.appThe surname is the anchor; everything else narrows within it
A criminal search is a surname query, refined. lastName is the one predicate every search must carry — a first name, a date of birth, an age, or a location alone is refused with 422 guard_unbounded_query, because an unbounded query returns a lottery, not an answer. Every other field you send only narrows that surname query: it lowers confidence or drops records that conflict, and a record missing the field it would test is kept and flagged rather than dropped. This page is the complete list of the ways to narrow.
firstName:absent, no_dob_age_year), because we cannot disprove your query. The only hard filter is the surname. Read matchState, matchConfidence and matchBasis per record and set your own threshold — see Matching & confidence.Two modes, decided by the request body
There is one search endpoint. Omit live for the cached answer — one indexed query across the continuously updated corpus, sub-second, no live source consulted. Send a live block for live verification — a real-time, moment-of-decision compliance check that re-verifies the actual jurisdiction(s) in-request and merges them with the cached answer through the same matcher, so a live record and a cached record agree about identity and rank together.
| Mode | How you ask | What it does · billing |
|---|---|---|
| Cached (default) | omit live | One indexed query across the whole corpus. No live source is ever consulted. One criminal_call. |
| Live verification | send a live block | Re-checks the actual jurisdiction(s) in-request. criminal_call plus criminal_live_source per completed source, to a $2.00 ceiling. |
POST /v1/criminal/search
The primary endpoint. X-API-Key is required; the account must be entitled to the Criminal product, and to the live entitlement for a live request.
| Field | Type | Description |
|---|---|---|
| query required | object | The search predicate. lastName is required inside it. The query fields may also be sent flat at the top level, but wrapping them in query is the documented form. |
| live optional | object | Presence switches on live verification: { "jurisdictions": [...] }, { "scope": "matched" }, and/or { "websites": [...] }, in any combination. |
| include optional | string[] | ["sourceData"] un-omits the per-source annex the light response leaves out; the response reports what it held back in omittedFields. |
| page optional | integer | Top level, not inside query. Defaults to 1; >= 1. |
| perPage optional | integer | Top level. Defaults to 50; clamped to 1–200. |
| Field | Type | Description |
|---|---|---|
| lastName | string | Required. Also accepted as last. Missing / empty → 422 guard_unbounded_query. Case-insensitive. |
| firstName | string | Also accepted as first. When absent, the first name is not considered (firstName:notQueried). Folded through the nickname table before comparison. |
| nameStrategy | string | prefix (default) or exact. Selects the surname strategy. prefix is a superset of exact. |
| prefixMatch | string | string[] | Requests explicit prefix matching on a named field ("lastName" / "firstName" / "both", a list, or a delimited string). Below 3 chars → 422 prefix_too_short. |
| dob | string YYYY-MM-DD | A full date of birth. Year and month are derived from it automatically. One dob reaches full-date, year, and age evidence in a single query. |
| birthYear | integer | A year of birth on its own. |
| birthMonth | integer | 1–12. Refines a year match when both sides publish a month; ignored without a year. |
| age | integer | Used against age-only records; ±1-year tolerance. Consulted only when dob cannot resolve. |
| state | string | USPS code — a location filter, not a corpus selector; it does not change which data is searched. |
| county | string | County filter, case-insensitive. Also accepted as jurisdiction. |
| city | string | Residence city, case-insensitive exact match on the record’s promoted address. Matches only records that carry a residential address (registry/wanted). |
| zipcode | string | Residence ZIP (also accepted as zip). Exact, or a ZIP prefix (e.g. "750" matches 750xx). |
| address | string | Fuzzy street match (also accepted as streetAddress) — a substring of the record’s address.line1. |
| website | string | A roster/records URL that scopes the cached search to the jurisdiction it resolves to. An explicit state/county wins over it; reported in resolution[]. |
Location filters are state and county (jurisdiction), plus city, zipcode and address on the person's residence where a source publishes one — no lat/lng radius yet. A record is dropped only when it has the relevant data and it does not match; a record missing a field is kept and flagged.
curl -X POST https://api.offendersearch.app/v1/criminal/search \
-H "X-API-Key: $OFFENDERSEARCH_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": { "lastName": "Hamilton", "firstName": "Alex", "state": "TX" },
"perPage": 50
}'Narrow by name
The surname is compared case-insensitively and is the only hard filter: a record whose surname neither equals nor starts with your lastName is dropped, and a record with no surname cannot satisfy a surname-mandatory query, so it is dropped too. Everything else about the name only adjusts confidence.
| Control | What it does |
|---|---|
nameStrategy: "prefix" (default) | The surname equals your value or starts with it. A superset of exact — turning it on never loses a record exact matching would have returned. |
nameStrategy: "exact" | The surname must equal your value. No prefix widening. |
firstName | Scored after the surname corroborates: firstName:exact (equal after nickname folding), firstName:prefix (either direction, Alex↔Alexander), firstName:absent (record has none — kept, small penalty), or firstName:mismatch (present, did not line up — kept, larger penalty). A mismatch never drops the record. |
| Nickname folding | Both sides are folded through a small, first-name-only nickname table before comparison, symmetrically: Bob ↔ Robert, Bill ↔ William, Jim ↔ James. A nickname hit reads firstName:exact — the folding happens before the equality test. There is no surname nickname. |
There is no fuzzy (typo) surname matching — Smyth never matches Smith. In this domain a false positive is the expensive error, so the matcher never introduces a surname it was not given. The full weighting is in Matching & confidence.
curl -X POST https://api.offendersearch.app/v1/criminal/search \
-H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
-d '{ "query": { "lastName": "Hamilton", "firstName": "Bob" } }'
# matches ROBERT HAMILTON too — the given name is folded through the nickname
# table before comparison, so matchBasis reads "firstName:exact".The 3-character prefix floor
Prefix matching has a floor, and it fires only on a prefix you explicitly asked for. The rule decides 422-versus-fallback:
| You sent | 2-character surname behaviour |
|---|---|
nothing (default prefix) | Falls back to exact — no error. A legitimate 2-character exact surname (Li, Ng, Ho, Vo) works. |
nameStrategy: "prefix" | 422 prefix_too_short — you asked for prefix. |
prefixMatch: "lastName" (or a synonym) | 422 prefix_too_short — you asked for prefix. |
nameStrategy: "exact" | Exact — no error, no floor (exact has no prefix). |
prefixMatch is the finer control: it selects prefix matching on a named field ("firstName", "lastName", "both", a list, or a delimited string; synonyms like "surname" / "given" are accepted). An unrecognised selector is ignored, not rejected — an unknown token must never widen a criminal search. The same 3-character floor applies to an explicit first-name prefix.
curl -X POST https://api.offendersearch.app/v1/criminal/search \
-H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
-d '{ "query": { "lastName": "Hamil", "firstName": "Al",
"prefixMatch": "lastName" } }'
# surname prefix-matches HAMIL* (HAMILTON, HAMILL, …); "Al" is compared
# exactly/by-nickname unless you also select it in prefixMatch.// { "query": { "lastName": "ha", "prefixMatch": "lastName" } } → HTTP 422
{ "error": { "code": "prefix_too_short",
"message": "prefixMatch prefix matching on lastName requires at least 3 characters (got 'ha'). Send a longer prefix, or drop it for an exact match." } }Narrow by date of birth or age
A date of birth is the strongest identity verifier available, and the one to send for anything consequential. Four fields carry birth evidence — but you rarely need more than one:
| Field | Type | What it checks |
|---|---|---|
dob | YYYY-MM-DD | A full date. Year and month are derived from it automatically — you do not also send birthYear/birthMonth. |
birthYear | integer | A year on its own, when that is all you know. |
birthMonth | 1–12 | Refines a year match to month+year when both sides publish a month. Ignored without a year. |
age | integer | Checked against age-only records within ±1 year. Consulted only when a date/year cannot resolve. |
dob reaches every kind of birth evidence. Jurisdictions do not all publish a date of birth — some publish only a year, many custody records publish only an age, a few publish neither. You do not compensate for that, and you must not: a dob you send is matched against a full date, else a year, else an age, so one query reaches all of them. Widening happens when we match; it never happens in what we store or send. See Searching by date of birth.curl -X POST https://api.offendersearch.app/v1/criminal/search \
-H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
-d '{ "query": { "lastName": "Chen", "dob": "1979-11-03" } }'That single query returns full-date records at dob_match, year-only records at year_match, age-only records at age_match, and records with no comparable birth evidence at no_dob_age_year — the last kept and flagged (unverified: true), present only because the surname matched. A response is therefore a mixture; branch on matchState for the full picture or unverified for the coarse one:
// ONE dob query, records honestly labelled by what each source published
[
{ "matchState": "dob_match", "unverified": false,
"dob": { "date": "1979-11-03", "birthYear": 1979, "dobPrecision": "full" } },
{ "matchState": "year_match", "unverified": false,
"dob": { "date": null, "birthYear": 1979, "dobPrecision": "year" } },
{ "matchState": "age_match", "unverified": false,
"dob": { "date": null, "birthYear": null, "age": 46, "dobPrecision": "age" } },
{ "matchState": "no_dob_age_year", "unverified": true,
"dob": { "date": null, "birthYear": null, "dobPrecision": "none" } }
]Only a genuine conflict (dob_mismatch) is excluded from records[]. A no_dob_age_year is an absence of comparable evidence, not a mismatch.
Narrow by place
Location filters keep records by where they are, not which corpus is searched. Two axes are the jurisdiction (state / county) the record lives in; three more — city, zipcode, address — filter on the person's residential address where the source publishes one (the registry and wanted records; incarcerated inmates have a facility, not a home address). These mirror the sex-offender product's location fields. There is no lat/lng radius yet — criminal records aren't geocoded.
| Field | What it does |
|---|---|
state | USPS code. Keeps records whose jurisdiction is that state. A location filter, not a corpus selector. |
county | County name, case-insensitive. Also accepted as jurisdiction. Combine with state to scope to one county. |
city | Residence city, case-insensitive exact match on the record's promoted address.city. Matches only records that carry a residential address. |
zipcode | Residence ZIP (also accepted as zip). Exact, or a ZIP prefix (e.g. "750" matches all of 750xx). |
address | Fuzzy street match (also accepted as streetAddress) — a substring of the record's address.line1. |
website | A roster/records URL you already know. It is resolved to a neutral jurisdiction code and that jurisdiction becomes the scope. An explicit state/county wins over it. |
A jurisdiction is always a neutral code — <ST>-<COUNTY> (a county, e.g. TX-DALLAS) or <ST>-<RECORD-KIND> (a statewide or federal system, e.g. TX-DOC, OK-COURT) — carrying a state and a place and nothing else. Enumerate the live values from Jurisdictions & codes rather than hard-coding them.
Driving a search from a website you already know
If you know the county roster or court portal for a place but not our code for it, hand us the URL and we resolve it. In a cached search, query.website scopes the answer to the resolved jurisdiction; the mapping comes back in a top-level resolution[] block so you can see which code it mapped to:
curl -X POST https://api.offendersearch.app/v1/criminal/search \
-H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
-d '{ "query": { "lastName": "Hamilton",
"website": "https://sheriff.examplecounty.gov/inmates" } }'"resolution": [
{ "input": "https://sheriff.examplecounty.gov/inmates",
"supported": true, "code": "TX-DALLAS",
"note": "equivalent data available for this jurisdiction" }
]In a live search, live.websites verifies the resolved jurisdictions exactly as if you had listed their codes in live.jurisdictions (the two are merged). An unsupported site does not fail the search — it is reported as its own sourceStatus[] row with incompleteReason: "unsupported_site". You can also resolve a URL up front, without searching, at Resolve a website → code.
curl -X POST https://api.offendersearch.app/v1/criminal/search \
-H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
-d '{ "query": { "lastName": "Hamilton", "state": "TX" },
"live": { "websites": ["https://sheriff.examplecounty.gov/inmates"] } }'Scoping a live check — the live block
Live verification is a real-time compliance re-check of a specific person at the actual jurisdiction(s). The live block is how you say where to re-check, in three combinable forms:
| Form | Re-verifies at… |
|---|---|
live.jurisdictions: [codes] | An explicit list of neutral jurisdiction codes. One public county code may expand to more than one internal system we cover for it — each reports its own sourceStatus[] row, and a single code can therefore run async. |
live.scope: "matched" | Every jurisdiction that produced a cached-mode hit for this query. The set is derived from the cached records' own sources. |
live.websites: [urls] | A list of roster/records URLs, each resolved to a code and fed into the live path. Merged with any live.jurisdictions you also sent. |
If none of these resolves to a source — an empty list, no cached matches under scope: "matched", or an unresolved code — the request is refused with 422 no_live_sources. The one exception is a live.websites list that was entirely unsupported: that is a resolution outcome, so you get the cached answer back with status: "partial" and the sites reported, not a 422.
Live verification: sync vs async
A live check of ≤2 sync-eligible sources runs synchronously (200, inline). More sources, or any source that needs heavier handling, fans out to an asynchronous job (202 + poll). Because one public county code can expand to several internal systems we cover for that county, a single live.jurisdictions: ["TX-DALLAS"] may resolve to more than two and run async — liveSourcesRequested in the 202 body reports the true count.
curl -X POST https://api.offendersearch.app/v1/criminal/search \
-H "X-API-Key: $OFFENDERSEARCH_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": { "lastName": "Hamilton", "state": "TX", "dob": "1989-04-23" },
"live": { "jurisdictions": ["TX-DALLAS"] }
}'counts.sourcesIncomplete first: when it is > 0, an empty result is a lower bound, not a confirmed absence, and warnings[] names the sources that fell short. See Result completeness and Async live jobs.Paging a result set
page and perPage are top-level request fields, not inside query — a deliberate difference from the Sex Offender API. Sending them inside query silently gives you the defaults (page: 1, perPage: 50). perPage is clamped to 1–200, and counts.records is the total before the slice. Full walk-through in Pagination.
The response envelope
Every top-level key is always present — test the value, never for key existence. On a cached search you get every key except usage and resolution (sourceStatus is []). On a live search you additionally get usage, a populated sourceStatus, and resolution if a website drove the request. A live-verified record carries liveChecked: true and a fresh lastCheckedAt.
{
"status": "complete",
"counts": { "records": 1, "recordsReturned": 1,
"sourcesQueried": 1, "sourcesComplete": 1, "sourcesIncomplete": 0 },
"warnings": [],
"page": 1, "perPage": 50, "totalPages": 1,
"sourceStatus": [
{ "code": "TX-DALLAS", "source": "TX-DALLAS", "ok": true, "incomplete": false,
"incompleteReason": null, "records": 1,
"lastCheckedAt": "2026-08-25T15:04:11+00:00", "live": true }
],
"omittedFields": ["sourceData"],
"records": [
{ "externalId": "TX-DALLAS:booking:889201",
"name": { "first": "ALEX", "middle": "Q", "last": "HAMILTON", "suffix": "" },
"matchConfidence": 0.95, "matchBasis": "lastName:exact firstName:notQueried dob:match",
"matchState": "dob_match", "unverified": false, "liveChecked": true }
],
"usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false,
"display": "1 live checks · $0.02" },
"searchId": "crs_7f2a1c9e0b4d6a8f1e23",
"legal": { "notice": "Not a consumer report. …" }
}The 202 async body
When a live search must run asynchronously, you get a 202 with a searchId and a relative poll URL. Poll it until it returns 200 — both paths run the same live checks and return the same envelope.
{
"searchId": "crs_9a3b71c0e28d4f6a5b12",
"status": "pending",
"mode": "async",
"liveSourcesRequested": 4,
"poll": "/v1/criminal/searches/crs_9a3b71c0e28d4f6a5b12",
"reason": "4 live sources (> 2 or not sync-eligible) — running asynchronously"
}The unbounded-query guard
A query with no surname is refused. A first name, a date of birth, and a state together are still refused — none of them is a surname, and the surname is the one predicate every search must carry:
// { "query": { "firstName": "Alex", "state": "TX", "dob": "1989-04-23" } } → 422
{ "error": { "code": "guard_unbounded_query",
"message": "a lastName predicate is required — an unbounded query returns a lottery, not an answer" } }