Migrating from another provider
A drop-in compatibility endpoint, the full legacy parameter map, and what /v1/search adds.
Base URL https://api.offendersearch.appThis page as Markdown/docs/migration.mdSwitch by changing the base URL
Point existing integrations at POST /v1/compat/sexoffender. It mirrors legacy sex-offender search APIs’ exact parameters and returns the exact { offenders, page, totalPages } envelope — so you switch by changing only the base URL and key, with no changes to your request or response handling. When you are ready, move to /v1/search for scored matches, per-source status, freshness tiers, and verification reports.
- const BASE = "https://api.previous-provider.example";
+ const BASE = "https://api.offendersearch.app/v1/compat";
- headers: { "Authorization": "Bearer " + LEGACY_KEY }
+ headers: { "X-API-Key": process.env.OFFENDERSEARCH_KEY }Parameter map
Every legacy input has a home in our contract:
| Legacy API | Offendersearch | Notes |
|---|---|---|
| firstName / lastName | query.firstName / query.lastName | Same fields. |
| dob | query.dob | YYYY-MM-DD. Used as a verifier and to boost matchConfidence. |
| city / state / zipcode | query.city / query.state / query.zipcode | Location filters. query.state is a UNION — it keeps a record with an address in that state OR held by that state's registry. Read registrationState / addressStates on each record to tell which. |
| address | query.address | Fuzzy street match. |
| lat / lng / radius | query.lat / query.lng / query.radiusMiles | GIS radius (miles, max 100). |
| q | query.q | Free-text across name, aliases, city, ZIP, address. |
| fuzzy: true | match: "balanced" | Fuzzy is a mode with us, applied uniformly across registries. |
| prefixMatch | query.prefixMatch | Same field, and it also accepts "both" to prefix-match first and last name together. Ours matches aliases as well as the registered name, and reports which one matched. |
| mode: "extensive" | include: ["stateData"] | Full offenses[], photos, vehicles, state-specific fields. |
| uuid / personUuid | GET /v1/records/{recordId} | Direct record lookup by id. |
| page | page / perPage | Native /v1/search returns the full de-duplicated set in one response, up to a defined 4,000-record response cap. An unpaginated answer above that cap is returned with capped: true; send perPage to paginate and every matched record is reachable, with capped false. Branch on counts.records vs counts.recordsReturned: if they differ, there is more to fetch. |
| createdAt* / updatedAt* filters | query.createdAtStart/End · query.updatedAtStart/End | Inclusive range bounds on source.scrapedAt and source.sourceUpdatedAt, both of which are returned on every record. |
| faceId (Facial Search) | — | Not part of the current contract. |
What you gain
Moving from compat to /v1/search adds capabilities legacy providers do not offer:
- Scored matches. Every record carries matchConfidence (0–1) and matchBasis. Legacy APIs return raw rows with no score.
- Identity verification. dobVerification and unverified tell you exactly how each record was checked against your DOB/age.
- De-duplicated people. One record per person with a sources[] array of every corroborating registry — not repeated rows.
- Per-source status. sourceStatus reports every jurisdiction the request touched (ok / error / restricted / …) on every response, so an incomplete search is always labelled.
- Freshness tiers. daily is the most current tier; weekly is one tier behind at no surcharge. Every record reports its own lastCheckedAt on either tier.
- Verification reports. A branded, timestamped PDF of the full search results with a source citation on every record — one consolidated document per search, on demand.
- Sync-first speed. One blocking call returns scored results in a single round trip, with elapsedMs on every response, and an async endpoint for unbounded work.
error: 503 — with no reason and no per-source detail. On /v1/search the same condition arrives as status: "partial" with a full sourceStatus[] naming each jurisdiction and why it did not complete. That is the main reason to finish the migration rather than stop at compat.A staged migration
- Swap the host and key. Point at
/v1/compat/sexoffenderand run your existing test suite unchanged. The response envelope is identical. - Shadow-read
/v1/search. Issue the same query to the native endpoint alongside compat and diff the record sets. Key the diff onsources[].recordUrlwith itsjurisdiction— not onrecordId, which is derived from the merge for a given query scope. - Adopt the labelled fields. Branch on
matchStateandmatchDetail.strategiesto set your own auto-accept threshold, and oncounts.sourcesIncompleteto separate no match from not determined. - Cut over. Move production traffic to
/v1/search, and keep compat available for any integration you have not migrated yet — both endpoints stay supported.
Generating a client rather than hand-writing one? The full OpenAPI document is published at /openapi.json and /openapi.yaml, and it is the same specification these pages are written against.
Compatibility endpoint
A drop-in endpoint that mirrors legacy sex-offender search APIs’ exact parameters and returns the exact { offenders, page, totalPages } envelope.
Point an existing legacy integration here and it keeps working by changing only the base URL and API key — no code changes to your request or response handling.
Under the hood it maps onto the same engine, so you can migrate incrementally: run on compat today, then move to /v1/search when you want scored matches, provenance, and freshness tiers. See Migrating from another provider for the full parameter map.
Combination rules (return 400 with {code, message}): q cannot be combined with firstName/lastName or with lat/lng; address cannot be combined with q or with lat/lng.
GIS search (lat + lng): results page 50 per page (regular searches page 20), and the query defaults to the last 90 days of source updates unless you pass an explicit updatedAtStart. A missing radius defaults to 1 mile (max 100).
| Field | Type | Description |
|---|---|---|
| firstName / lastName optional | string | Name fields. Cannot be combined with q (400). |
| dob / city / state / zipcode optional | string | Standard filters. |
| address optional | string | Fuzzy street match. Cannot be combined with q or lat/lng (400). |
| lat / lng / radius optional | number | GIS radius search (radius in miles, max 100). GIS pages 50/page and defaults to the last 90 days of updates. |
| q optional | string | Free-text query. Cannot be combined with firstName/lastName or lat/lng (400). |
| fuzzy optional | boolean | Enable fuzzy name matching (maps to match: "balanced"). |
| mode optional | "extensive" | Request extended per-state detail (maps to include: ["stateData"]). |
| prefixMatch optional | "firstName" | "lastName" | "both" | Prefix-match a name field — the name you send is treated as the start of a name (minimum 3 characters), matched against aliases too. See Partial name search. |
| createdAtStart / createdAtEnd optional | date-time | Range filter on when we first recorded the record (source.scrapedAt). |
| updatedAtStart / updatedAtEnd optional | date-time | Range filter on when the source last changed the record; GIS defaults updatedAtStart to now − 90 days. |
| uuid / personUuid optional | string | Direct record lookup (maps to GET /v1/records/{id}). |
| page optional | integer | Page number for the paginated envelope. |
curl https://api.offendersearch.app/v1/compat/sexoffender \
-H "X-API-Key: $OFFENDERSEARCH_KEY" \
-H "Content-Type: application/json" \
-d '{ "firstName": "John", "lastName": "Doe", "state": "NJ", "fuzzy": true, "mode": "extensive" }'{
"offenders": [ /* legacy-shaped records */ ],
"page": 1,
"totalPages": 1
}The response is the legacy envelope, returned verbatim. Move to /v1/search for scored matches, provenance, freshness, and verification reports.