Offendersearch
Criminal API Reference

Search - POST /v1/criminal/search

The primary endpoint. Cached by default, live verification on request, and every parameter.

Base URL https://api.offendersearch.app

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

Bias to show. A record is dropped only when it has the relevant data and that data does not match — a surname that neither equals nor prefix-matches, or a birth date/year/age that conflicts. A record missing the field is kept and flagged (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.

ModeHow you askWhat it does · billing
Cached (default)omit liveOne indexed query across the whole corpus. No live source is ever consulted. One criminal_call.
Live verificationsend a live blockRe-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.

Request · top level
FieldTypeDescription
query
required
objectThe 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
objectPresence 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
integerTop level, not inside query. Defaults to 1; >= 1.
perPage
optional
integerTop level. Defaults to 50; clamped to 1–200.
Request · the query object
FieldTypeDescription
lastNamestringRequired. Also accepted as last. Missing / empty → 422 guard_unbounded_query. Case-insensitive.
firstNamestringAlso accepted as first. When absent, the first name is not considered (firstName:notQueried). Folded through the nickname table before comparison.
nameStrategystringprefix (default) or exact. Selects the surname strategy. prefix is a superset of exact.
prefixMatchstring | 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.
dobstring YYYY-MM-DDA 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.
birthYearintegerA year of birth on its own.
birthMonthinteger1–12. Refines a year match when both sides publish a month; ignored without a year.
ageintegerUsed against age-only records; ±1-year tolerance. Consulted only when dob cannot resolve.
statestringUSPS code — a location filter, not a corpus selector; it does not change which data is searched.
countystringCounty filter, case-insensitive. Also accepted as jurisdiction.
citystringResidence city, case-insensitive exact match on the record’s promoted address. Matches only records that carry a residential address (registry/wanted).
zipcodestringResidence ZIP (also accepted as zip). Exact, or a ZIP prefix (e.g. "750" matches 750xx).
addressstringFuzzy street match (also accepted as streetAddress) — a substring of the record’s address.line1.
websitestringA 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.

POST /v1/criminal/search — cached
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.

ControlWhat 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.
firstNameScored after the surname corroborates: firstName:exact (equal after nickname folding), firstName:prefix (either direction, AlexAlexander), 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 foldingBoth sides are folded through a small, first-name-only nickname table before comparison, symmetrically: BobRobert, Bill William, JimJames. A nickname hit reads firstName:exact — the folding happens before the equality test. There is no surname nickname.

There is no fuzzy (typo) surname matchingSmyth 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.

Nickname-aware first name
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 sent2-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.

Explicit prefix on the surname
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.
422 — an explicit prefix under 3 chars
// { "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:

FieldTypeWhat it checks
dobYYYY-MM-DDA full date. Year and month are derived from it automatically — you do not also send birthYear/birthMonth.
birthYearintegerA year on its own, when that is all you know.
birthMonth1–12Refines a year match to month+year when both sides publish a month. Ignored without a year.
ageintegerChecked against age-only records within ±1 year. Consulted only when a date/year cannot resolve.
One 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.
One dob, all birth-evidence tiers
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 response, four match states
// 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.

FieldWhat it does
stateUSPS code. Keeps records whose jurisdiction is that state. A location filter, not a corpus selector.
countyCounty name, case-insensitive. Also accepted as jurisdiction. Combine with state to scope to one county.
cityResidence city, case-insensitive exact match on the record's promoted address.city. Matches only records that carry a residential address.
zipcodeResidence ZIP (also accepted as zip). Exact, or a ZIP prefix (e.g. "750" matches all of 750xx).
addressFuzzy street match (also accepted as streetAddress) — a substring of the record's address.line1.
websiteA 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:

Cached scope by website
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[] — website → code
"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.

Live-verify by website
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:

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

POST /v1/criminal/search — live verification
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"] }
      }'
On a live search, read 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.

200 OK — live, synchronous
{
  "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.

202 Accepted
{
  "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:

422 — lastName missing
// { "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" } }