Offendersearch
Criminal API Reference

Migrating from another provider

Moving from a legacy provider: the parameter map, the additive recordType note, and what you gain.

Base URL https://api.offendersearch.app

One normalized envelope

The Criminal Search API returns one normalized record schema across every jurisdiction, so you stop stitching differing per-place formats together yourself. Moving a legacy criminal-records or background-check integration means changing the base URL and key, then adopting the labelled fields the envelope adds. The concepts you already have all have a home here — this page maps them.

Point at the new base URL and key
- const BASE = "https://api.previous-provider.example";
+ const BASE = "https://api.offendersearch.app";
- headers: { "Authorization": "Bearer " + LEGACY_KEY }
+ headers: { "X-API-Key": process.env.OFFENDERSEARCH_KEY }
Check your links: our domain is offendersearch.app. Discard anything pointing at a .com variant — it is not ours.

Map the legacy concepts to ours

Most legacy providers expose a "run a check" call that takes a full name, a date of birth, and a region, and hands back a provider-scored hit. Ours is one search with an explicit surname anchor. The shape of the call barely changes:

One call — the surname is the anchor
// A typical legacy "run a background check" call…
POST /background-check
{ "name": "Alex Hamilton", "dob": "1989-04-23", "region": "TX" }

// …becomes one search. lastName is the anchor; everything else narrows it.
POST /v1/criminal/search
{ "query": { "lastName": "Hamilton", "firstName": "Alex",
             "dob": "1989-04-23", "state": "TX" } }

The one adjustment worth planning for: lastName is required. A legacy call that passed only a first name, a date of birth, or a region will get a 422 guard_unbounded_query here rather than an arbitrary slice — split a single "full name" field into firstName and lastName before you send it.

Parameter map

Every legacy input has a home in our contract:

ConcernCriminal Search API
Search predicatequery.lastName required; firstName, state, county, dob/birthYear/age narrow it. A nameless query is a 422, not an empty page.
Paginationpage / perPage at the top level (not inside query); perPage clamped to 200; counts.records is the total before the slice.
Name wideningnameStrategy: "prefix" (default) or "exact"; explicit prefix has a 3-char floor. No fuzzy surname matching, ever. Nickname-aware given names.
Live re-checka live block — { "jurisdictions": [...] } or { "scope": "matched" } — a real-time compliance verification, billed per completed source to a $2.00 ceiling.
Location filterquery.state/query.county (jurisdiction) + query.city/query.zipcode/query.address (residence, where published). No lat/lng radius. A jurisdiction is a neutral <ST>-<COUNTY> / <ST>-<RECORD-KIND> code.
Freshnessa per-record lastCheckedAt timestamp and a lifecycle block, not a global "last updated" flag. The corpus is continuously updated.
ConfidencematchState + matchConfidence + matchBasis on every record — you set your own threshold rather than inherit one.
Errorsalways { "error": { "code", "message" } } — branch on code, never on a message string.

Cached vs live — a real-time compliance re-check on demand

A legacy provider typically gives you one answer and leaves "how current is this?" implicit. Here the choice is explicit and per-request. Omit live and you get the cached answer — one indexed query across the continuously updated corpus, sub-second. Send a live block and you get a real-time verification of a specific person at the actual jurisdiction(s), for the moment a compliance decision is being made — billed per completed source to a $2.00 ceiling.

ModeHow you askUse it when
Cached (default)omit liveAlmost always — screening, enrichment, batch. Fast, and the answer is a whole answer from the corpus.
Live verificationsend a live blockA moment-of-decision compliance check that must reflect the jurisdiction as of right now.

On a live search, read counts.sourcesIncomplete before treating an empty result as a confirmed absence — > 0 means a source could not be checked to the end, so the answer is a lower bound. A cached search never has this failure mode. See Search and Result completeness.

What you gain

  • Labelled match strength. matchState, matchConfidence and matchBasis on every record, so you set your own auto-accept threshold rather than inherit one.
  • A completeness signal. On a live search, counts.sourcesIncomplete separates no match from not determined — a distinction a single provider-scored hit cannot express.
  • Neutral jurisdiction codes. Every place is a <ST>-<COUNTY> or <ST>-<RECORD-KIND> code — a state and a place, and nothing else. See Jurisdictions & codes.
  • One dob reaches every kind of birth evidence. A single date is matched against a full date, else a year, else an age — you never run a second, weaker query to catch people whose source is less generous. See Searching by date of birth.
  • Stored history and CSV batch. Prior searches are recorded, and up to 1000 cached lookups run in one call.
Replace a provider-scored hit with your own gate
// Instead of a single provider-chosen "hit / no-hit", read the evidence:
res.records
  .filter(r => r.matchState === "dob_match")       // birthday confirmed
  .filter(r => r.matchBasis.includes("lastName:exact"));

// year_match / age_match / no_dob_age_year are candidates to review,
// not confirmations — set your OWN threshold. See Matching & confidence.

A staged migration

  1. Swap the host and key. Point at POST /v1/criminal/search and split any single full-name field into firstName / lastName. Move page/perPage to the top level of the body.
  2. Adopt the labelled fields. Branch on matchState and the matchBasis tokens to set your own threshold, and on counts.sourcesIncomplete to separate no match from not determined. Key any de-dup on the per-jurisdiction sources[].code + externalId pair, not on the assembled recordId, which reflects the scope of a given search.
  3. Add live verification where compliance needs it. Leave the default cached path for screening and enrichment; reach for a live block only at a moment-of-decision check. Handle the 202 async path for multi-source live requests. See Async live jobs.

The additive-recordType note

Criminal records are a distinct product on the same account and key as the Sex Offender API, returned as their own recordType (criminal_record). The contract is additive: new kinds of record land as a new recordType, and existing keys and their meanings do not change under you. Anything you build that switches on recordType should have a default branch for a value it does not yet recognise — a new record kind must never break your parsing of the response shape. A caller who knows one API knows the other.

This data is not a consumer report and may not be used for any FCRA-covered purpose (employment, tenant screening, credit, insurance) — the notice is present verbatim in the legal block of every response, and there is no parameter that removes it.