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.appOne 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.
- 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 }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:
// 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:
| Concern | Criminal Search API |
|---|---|
| Search predicate | query.lastName required; firstName, state, county, dob/birthYear/age narrow it. A nameless query is a 422, not an empty page. |
| Pagination | page / perPage at the top level (not inside query); perPage clamped to 200; counts.records is the total before the slice. |
| Name widening | nameStrategy: "prefix" (default) or "exact"; explicit prefix has a 3-char floor. No fuzzy surname matching, ever. Nickname-aware given names. |
| Live re-check | a live block — { "jurisdictions": [...] } or { "scope": "matched" } — a real-time compliance verification, billed per completed source to a $2.00 ceiling. |
| Location filter | query.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. |
| Freshness | a per-record lastCheckedAt timestamp and a lifecycle block, not a global "last updated" flag. The corpus is continuously updated. |
| Confidence | matchState + matchConfidence + matchBasis on every record — you set your own threshold rather than inherit one. |
| Errors | always { "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.
| Mode | How you ask | Use it when |
|---|---|---|
| Cached (default) | omit live | Almost always — screening, enrichment, batch. Fast, and the answer is a whole answer from the corpus. |
| Live verification | send a live block | A 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,matchConfidenceandmatchBasison every record, so you set your own auto-accept threshold rather than inherit one. - A completeness signal. On a live search,
counts.sourcesIncompleteseparates 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
dobreaches 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.
// 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
- Swap the host and key. Point at
POST /v1/criminal/searchand split any single full-name field intofirstName/lastName. Movepage/perPageto the top level of the body. - Adopt the labelled fields. Branch on
matchStateand thematchBasistokens to set your own threshold, and oncounts.sourcesIncompleteto separate no match from not determined. Key any de-dup on the per-jurisdictionsources[].code+externalIdpair, not on the assembledrecordId, which reflects the scope of a given search. - Add live verification where compliance needs it. Leave the default cached path for screening and enrichment; reach for a
liveblock only at a moment-of-decision check. Handle the202async 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.
legal block of every response, and there is no parameter that removes it.