# Migrating from another provider

> A drop-in compatibility endpoint mirrors the legacy envelope, so you switch by changing the base URL and key. Full parameter map and what you gain by moving.

- **HTML:** https://offendersearch.app/docs/migration
- **Base URL:** https://api.offendersearch.app
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **All documentation as markdown:** https://offendersearch.app/docs.md

## Switch 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.

```plain
- 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

| 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

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

**One difference worth planning for.** The legacy envelope has room for a single completeness signal — the integer `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.

## A staged migration

1. **Swap the host and key.** Point at `/v1/compat/sexoffender` and run your existing test suite unchanged. The response envelope is identical.
2. **Shadow-read `/v1/search`.** Issue the same query to the native endpoint alongside compat and diff the record sets. Key the diff on `sources[].recordUrl` with its `jurisdiction` — not on `recordId`, which is derived from the merge for a given query scope.
3. **Adopt the labelled fields.** Branch on `matchState` and `matchDetail.strategies` to set your own auto-accept threshold, and on `counts.sourcesIncomplete` to separate *no match* from *not determined*.
4. **Cut over.** Move production traffic to `/v1/search`, and keep compat available for any integration you have not migrated yet — both endpoints stay supported.

## POST /v1/compat/sexoffender — Compatibility endpoint

A drop-in endpoint that mirrors legacy sex-offender search APIs’ exact parameters and returns the exact { offenders, page, totalPages } envelope.

**Authentication:** `X-API-Key` header.

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

### Parameters — Body (legacy parameters, verbatim)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `firstName / lastName` | `string` | optional | Name fields. Cannot be combined with q (400). |
| `dob / city / state / zipcode` | `string` | optional | Standard filters. |
| `address` | `string` | optional | Fuzzy street match. Cannot be combined with q or lat/lng (400). |
| `lat / lng / radius` | `number` | optional | GIS radius search (radius in miles, max 100). GIS pages 50/page and defaults to the last 90 days of updates. |
| `q` | `string` | optional | Free-text query. Cannot be combined with firstName/lastName or lat/lng (400). |
| `fuzzy` | `boolean` | optional | Enable fuzzy name matching (maps to match: "balanced"). |
| `mode` | `"extensive"` | optional | Request extended per-state detail (maps to include: ["stateData"]). |
| `prefixMatch` | `"firstName" \| "lastName" \| "both"` | optional | 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` | `date-time` | optional | Range filter on when we first recorded the record (source.scrapedAt). |
| `updatedAtStart / updatedAtEnd` | `date-time` | optional | Range filter on when the source last changed the record; GIS defaults updatedAtStart to now − 90 days. |
| `uuid / personUuid` | `string` | optional | Direct record lookup (maps to GET /v1/records/{id}). |
| `page` | `integer` | optional | Page number for the paginated envelope. |

### Request

**cURL**

```bash
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" }'
```

**Node**

```javascript
const res = await fetch(
  "https://api.offendersearch.app/v1/compat/sexoffender",
  {
    method: "POST",
    headers: {
      "X-API-Key": process.env.OFFENDERSEARCH_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      firstName: "John", lastName: "Doe", state: "NJ",
      fuzzy: true, mode: "extensive",
    }),
  },
);
const { offenders, page, totalPages } = await res.json();
console.log(offenders.length, "of page", page, "/", totalPages);
```

**Python**

```python
import os, requests

resp = requests.post(
    "https://api.offendersearch.app/v1/compat/sexoffender",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    json={"firstName": "John", "lastName": "Doe", "state": "NJ",
          "fuzzy": True, "mode": "extensive"},
)
data = resp.json()
print(len(data["offenders"]), "of page", data["page"], "/", data["totalPages"])
```

### Response

```json
{
  "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.

---

## Related

- Previous: [Verification reports](https://offendersearch.app/docs/reports.md)
- Index: [Offendersearch API documentation](https://offendersearch.app/docs.md)
