# Offendersearch API documentation

> One REST API over 58 sex-offender registries — every US state, DC and the territories. One normalized record schema, per-source status on every response, labelled match strength, and freshness chosen per request.

- **Base URL:** https://api.offendersearch.app
- **API version:** 1.0.0
- **Authentication:** `X-API-Key` request header on every request
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **API catalog (RFC 9727):** https://offendersearch.app/.well-known/api-catalog
- **HTML documentation:** https://offendersearch.app/docs
- **Per-section markdown:** https://offendersearch.app/docs/{section}.md

Offendersearch is not a consumer reporting agency and results are not a consumer report. Do not use them for FCRA-covered decisions without appropriate process.

## What the API guarantees

- **One call, nationwide.** Omit `jurisdictions` to search the full dataset, or name a subset.
- **One normalized schema across 58 jurisdictions.** Every match is a 76-field superset, with a 34-field extensive `stateData` block available via `include: ["stateData"]`.
- **Per-source status on every response.** `sourceStatus[]` reports every jurisdiction the request touched, individually, so an incomplete search is always labelled as one and never returned as a silently short list.
- **Labelled match strength.** `matchState` is `dob_match`, `year_match`, `age_match` or `no_dob_age_year` on every record, so you set your own confidence threshold rather than inherit one.
- **Provenance built in.** Every record carries `sources[]` with a `lastCheckedAt`, and freshness is chosen per request.
- **Additive contract.** New data lands as a new `recordType`; the response shape does not break.

## Endpoints

| Method | Path | What it does | Documented in |
| --- | --- | --- | --- |
| POST | `/v1/search` | Synchronous search across every jurisdiction, scored and de-duplicated. | [search](https://offendersearch.app/docs/search.md) |
| POST | `/v1/searches` | Asynchronous search — submit now, collect by polling or webhook. | [async-and-webhooks](https://offendersearch.app/docs/async-and-webhooks.md) |
| GET | `/v1/searches/{searchId}` | Status and results of an asynchronous search. | [async-and-webhooks](https://offendersearch.app/docs/async-and-webhooks.md) |
| POST | `/v1/batch` | Up to 1000 queries in one call, JSON or CSV, results in input order. | [batch](https://offendersearch.app/docs/batch.md) |
| POST | `/v1/report` | A consolidated, timestamped PDF of a search you already ran. | [reports](https://offendersearch.app/docs/reports.md) |
| GET | `/v1/records/{recordId}` | Fetch a single normalized record by id. | [record-object](https://offendersearch.app/docs/record-object.md) |
| GET | `/v1/sources` | The live coverage catalog with per-jurisdiction health. | [freshness](https://offendersearch.app/docs/freshness.md) |
| POST | `/v1/compat/sexoffender` | Drop-in compatibility endpoint returning the legacy envelope. | [migration](https://offendersearch.app/docs/migration.md) |

## Contents

- [Quickstart](https://offendersearch.app/docs/quickstart.md) — Your first authenticated search in three languages, and the four fields to read off every response.
- [Authentication, keys & security](https://offendersearch.app/docs/authentication.md) — The X-API-Key header, key lifecycle and rotation, and the security posture behind the API.
- [Errors, status codes & rate limits](https://offendersearch.app/docs/errors.md) — Every status code, the stable error envelope, the rate-limit headers, and how to back off.
- [Search — POST /v1/search](https://offendersearch.app/docs/search.md) — The primary endpoint. Every parameter, the four stages of a search, and nine worked query shapes.
- [Matching, confidence & partial names](https://offendersearch.app/docs/matching.md) — Match modes, the closed strategy vocabulary, confidence ceilings, and partial-name search.
- [Searching by date of birth](https://offendersearch.app/docs/date-of-birth.md) — Labelled match strength on every record, and how dob, birthYear and dobPrecision fit together.
- [Result completeness & per-source status](https://offendersearch.app/docs/result-completeness.md) — An incomplete search is labelled, never silently empty. counts, sourceStatus and incompleteReason.
- [Pagination & response size](https://offendersearch.app/docs/pagination.md) — The full result set in one response by default — plus page, perPage and totalPages when you want slices.
- [Freshness tiers & source coverage](https://offendersearch.app/docs/freshness.md) — Per-request freshness tiers, per-record lastCheckedAt, and the live coverage catalog.
- [Jurisdictions & codes](https://offendersearch.app/docs/jurisdictions.md) — Every jurisdiction code, and the difference between scoping a search and filtering a result.
- [Async search, webhooks & idempotency](https://offendersearch.app/docs/async-and-webhooks.md) — Submit and collect, with no request-timeout ceiling. Signed webhooks and idempotent retries.
- [Batch & CSV search](https://offendersearch.app/docs/batch.md) — Up to 1000 lookups per call, JSON or CSV, row in and row out, with per-row fault isolation.
- [The Record object](https://offendersearch.app/docs/record-object.md) — One normalized 76-field schema across every jurisdiction, and how to read its empty values.
- [Verification reports](https://offendersearch.app/docs/reports.md) — A timestamped PDF of a search you already ran, with a source citation on every record.
- [Migrating from another provider](https://offendersearch.app/docs/migration.md) — A drop-in compatibility endpoint, the full legacy parameter map, and what /v1/search adds.

---

# Quickstart

> Make your first nationwide sex-offender search in cURL, Node or Python, and learn the four response fields to read on every call: status, counts, matchState.

HTML: https://offendersearch.app/docs/quickstart · Markdown: https://offendersearch.app/docs/quickstart.md

## Authenticate

Every request carries your secret key in the `X-API-Key` header. Keys are created, rotated and revoked from the dashboard, and a key's secret is shown in full only once, at creation.

```bash
export OFFENDERSEARCH_KEY="os_live_9f2a…"
```

## Your first search

One authenticated call covers every jurisdiction. Passing `jurisdictions: null` — or omitting it — searches the full dataset, and the response is one scored, de-duplicated, source-tagged result set.

**cURL**

```bash
curl https://api.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "firstName": "John",
      "lastName": "Doe",
      "dob": "1980-04-12"
    },
    "jurisdictions": null,
    "freshness": "daily",
    "match": "balanced",
    "include": ["stateData"]
  }'
```

**Node**

```javascript
const res = await fetch("https://api.offendersearch.app/v1/search", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query: { firstName: "John", lastName: "Doe", dob: "1980-04-12" },
    jurisdictions: null,     // null = search the full dataset
    freshness: "daily",
    match: "balanced",
    include: ["stateData"],  // extended per-state detail
  }),
});

const { records, sourceStatus, counts, status } = await res.json();
// status is "complete", or "partial" when a deadlineMs bound is set
for (const r of records) {
  console.log(r.matchConfidence, r.name.full, r.source.jurisdiction);
}
```

**Python**

```python
import os, requests

resp = requests.post(
    "https://api.offendersearch.app/v1/search",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    json={
        "query": {"firstName": "John", "lastName": "Doe", "dob": "1980-04-12"},
        "jurisdictions": None,   # None = search the full dataset
        "freshness": "daily",
        "match": "balanced",
        "include": ["stateData"],
    },
    timeout=130,
)
data = resp.json()
print(data["status"], data["counts"])
for r in data["records"]:
    print(r["matchConfidence"], r["name"]["full"], r["source"]["jurisdiction"])
```

## Read four things off every response

1. **`status`** — `"complete"` or `"partial"`. `"partial"` means a `deadlineMs` bound you set was reached before every source completed.
2. **`counts.sourcesComplete` vs `counts.sourcesQueried`** — how much of the search finished. An equal pair is a closed answer.
3. **`counts.records`** — the total number of matches *before* any page slice, so it does not change as you page.
4. **`matchState` and `matchedName`, per record** — the labelled strength of the identity match, and whether the registered name or an alias produced the hit.

Every response also carries `sourceStatus[]`, one entry per jurisdiction the request touched, with that jurisdiction’s own status and freshness — so an incomplete search is always labelled as one and never returned as a silently short list.

```json
{
  "status": "complete",
  "counts": {
    "records": 3,
    "sourcesQueried": 58,
    "sourcesComplete": 58,
    "sourcesIncomplete": 0,
    "sourcesSkippedByScope": 0
  },
  "records": [
    {
      "matchState": "dob_match",
      "matchedName": { "value": "John A. Doe", "type": "legal" },
      "matchConfidence": 1.0
    }
  ]
}
```

## Where to go next

- [POST /v1/search](https://offendersearch.app/docs/search.md) — every parameter, and nine worked query shapes.
- [The Record object](https://offendersearch.app/docs/record-object.md) — one normalized schema across all 58 jurisdictions.
- [Result completeness](https://offendersearch.app/docs/result-completeness.md) — how counts, sourceStatus and incompleteReason fit together.
- [Async & webhooks](https://offendersearch.app/docs/async-and-webhooks.md) and [Batch & CSV](https://offendersearch.app/docs/batch.md) — for volume work.
- [OpenAPI JSON](https://offendersearch.app/openapi.json) / [YAML](https://offendersearch.app/openapi.yaml) — generate a typed client rather than hand-writing one.

---

# Authentication, keys & security

> Authenticate every request with an X-API-Key header. Key lifecycle, what a key controls, tenant isolation, encryption, audit logging and BAA availability.

HTML: https://offendersearch.app/docs/authentication · Markdown: https://offendersearch.app/docs/authentication.md

## The X-API-Key header

Authenticate every request with your secret key in the `X-API-Key` header. Keys are created, named, rotated, and revoked from the API keys page. A key's secret is shown in full only once, at creation, and is stored only as a one-way hash — keep it in a secret manager, and never in client-side code.

```bash
curl https://api.offendersearch.app/v1/sources \
  -H "X-API-Key: os_live_9f2a…"
```

A missing or unrecognised key returns `401`. An account without billing enabled returns `402`. Both use the standard error envelope with a stable `error.code`.

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Missing or invalid X-API-Key header."
  }
}
```

## What a key controls

A key is a pure authentication credential — nothing billable is provisioned on it. Data freshness is chosen **per request** with the optional `freshness` parameter (`daily` by default, or `weekly`), and verification reports are a **separate endpoint** (`POST /v1/report`) that any valid key can call, billed per document. There are no per-key feature scopes to configure, so a key created today can call every endpoint documented here.

**Rotation.** Issue a second key, deploy it, then revoke the first — both are valid at once, so rotation needs no downtime window. Usage is attributed per key, which is the practical reason to issue one key per environment or per service rather than sharing a single credential across a fleet.

Administrative operations use a separate `X-Admin-Key` internal credential and are not part of the public API.

## Security

- **Encryption.** TLS in transit, AES-256 at rest for stored records and reports.
- **Per-key hashing.** Secrets are hashed at rest — a database read never exposes a usable key.
- **Access controls & tenant isolation.** Data is scoped per account; one customer can never read another’s keys, usage, or reports.
- **Audit logging.** Requests are logged with account, timestamp, and endpoint; usage is visible to account owners.
- **Attestations.** A Business Associate Agreement (BAA) is available to eligible enterprise accounts that process PHI through the API, and a formal SOC 2 examination is underway.

Results are public-record data and are not a consumer report; do not use them for FCRA-covered decisions without appropriate process.

---

# Errors, status codes & rate limits

> Every status code the API returns, the stable error.code envelope you switch on, rate-limit headers, and the backoff strategy to implement against 429.

HTML: https://offendersearch.app/docs/errors · Markdown: https://offendersearch.app/docs/errors.md

## Status codes

The API uses conventional HTTP status codes. A `200` can still carry a `partial` result: when you set a `deadlineMs` bound, the sources that completed within it are returned and every source is labelled in `sourceStatus`, rather than failing the whole call.

| Status | Meaning | When you see it |
| --- | --- | --- |
| `200` | OK | Search completed — or returned partial results under the deadline (check status). |
| `202` | Accepted | Asynchronous search accepted; poll the results URL or await the webhook. |
| `400` | Bad Request | Invalid or missing parameters (e.g. no lastName and no q/location). |
| `401` | Unauthorized | Missing or invalid X-API-Key header, or a request outside the key’s scope. |
| `402` | Payment Required | Billing is not enabled on the account (required for all accounts, including verification reports). |
| `404` | Not Found | The search id or record id does not exist. |
| `422` | Unprocessable Entity | The request was understood and DECLINED, and the body says why in a plain sentence you can show a user. Two causes. (a) THE QUERY CANNOT BE ANSWERED COMPLETELY, so it is declined rather than answered with a subset that would read as the whole answer: a query carrying nothing to narrow on cannot be guaranteed to return the same full result twice. Send at least one of lastName, dob, age, city, zipcode, a lat/lng radius, or q — a firstName on its own is not enough to narrow a search, so pair it with a surname or a date of birth. A name + DOB query is NEVER refused, whatever the name. (b) A parameter is malformed or unrecognised — an unknown jurisdiction code, or a filter placed at the top level of the body instead of inside query. |
| `429` | Too Many Requests | Rate limit or plan quota exceeded. Back off and retry after the Retry-After header. |
| `504` | Deadline Exceeded | Returned only when the deadline is hit and onDeadline = "error". |

## The error envelope

Every non-2xx response returns a JSON body with a stable `error.code` you can switch on and a human-readable `error.message` you can surface to a user. The shape is identical on every endpoint, so one error handler covers the whole API.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "query requires at least one of lastName, q, or a lat/lng radius."
  }
}
```

`422` is a deliberate contract, not a failure. A query with nothing to narrow on cannot be guaranteed to return the same full result twice, so it is declined with a sentence you can show a user rather than answered with a subset that would read as the whole answer. A name + date-of-birth query is never declined, whatever the name.

```json
{
  "error": {
    "code": "unprocessable_query",
    "message": "A firstName on its own cannot be answered completely. Pair it with a lastName, a dob, or an age."
  }
}
```

```javascript
const res = await fetch(url, { method: "POST", headers, body });

if (!res.ok) {
  const { error } = await res.json();
  switch (res.status) {
    case 401: throw new Error("Check X-API-Key: " + error.message);
    case 402: throw new Error("Billing is not enabled on this account.");
    case 422: return { declined: error.message };          // show the user
    case 429: return retryAfter(res.headers.get("Retry-After"));
    default:  throw new Error(error.code + ": " + error.message);
  }
}

const body = await res.json();
// A 200 can still be partial - read status and counts, never records alone.
if (body.status === "partial") {
  // counts.sourcesComplete of counts.sourcesQueried finished
}
```

## Rate limits & quotas

Requests are rate limited per API key. When you exceed your plan’s sustained rate or monthly quota the API responds with `429 Too Many Requests` and a `Retry-After` header (seconds). Every response also carries the current limit state, so you can throttle proactively.

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Requests allowed in the current window. |
| `X-RateLimit-Remaining` | Requests left in the current window. |
| `X-RateLimit-Reset` | Unix time when the window resets. |
| `Retry-After` | On a 429, seconds to wait before retrying. |

Concurrency and monthly volume are set by your plan. For large jobs, prefer the asynchronous endpoint or batch and spread submissions, rather than issuing thousands of synchronous calls at once. Handle `429` with exponential backoff that respects `Retry-After`.

```python
import time, requests

def search(payload, key, attempts=5):
    for attempt in range(attempts):
        r = requests.post(
            "https://api.offendersearch.app/v1/search",
            headers={"X-API-Key": key},
            json=payload,
            timeout=130,
        )
        if r.status_code != 429:
            r.raise_for_status()
            return r.json()
        wait = int(r.headers.get("Retry-After", 2 ** attempt))
        time.sleep(wait)
    raise RuntimeError("rate limited after %d attempts" % attempts)
```

## Retrying safely

A retry should never start — or bill — a second search. On `POST /v1/searches` send an `Idempotency-Key` header and a repeated key returns the original job rather than creating a new one.

When a response comes back `partial`, the field to read is `sourceStatus[].incompleteReason`: it is a closed enum, and each value says whether retrying the identical request will change the answer.

---

# Search — POST /v1/search

> The primary endpoint: one authenticated call searches every jurisdiction, scores and de-duplicates the matches. Full parameter reference and worked examples.

HTML: https://offendersearch.app/docs/search · Markdown: https://offendersearch.app/docs/search.md

## How a search works

Every search moves through the same four stages, so the response is predictable and the same query returns the same answer:

1. **Dispatch.** Your `query` is matched across the jurisdictions you targeted — all 58 by default, or the codes in `jurisdictions`.
2. **Match & verify.** Each jurisdiction’s rows are filtered by your `match` mode, then checked against any `dob`/`age` you supplied to confirm identity.
3. **De-duplicate & score.** The same person present in multiple jurisdictions is merged into one record whose `sources[]` lists every corroborating jurisdiction, and each record gets a `matchConfidence` and `matchBasis`.
4. **Return.** You get `records`, a per-jurisdiction `sourceStatus`, and `counts` — with `status: "complete"` or `"partial"`.

## POST /v1/search — Synchronous search

The primary endpoint. One authenticated call searches the full dataset (or the jurisdictions you name), scores and de-duplicates the matches, and returns them in a single response.

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

Send a `query` describing the person you are checking. By default the search covers the full dataset at once; you can narrow it with `jurisdictions`, tune the fuzzy-match tolerance with `match`, pick a freshness tier with `freshness`, request extended detail with `include`, and cap how long you are willing to wait with `deadlineMs`.

Only know part of a name? Set `prefixMatch` to `"firstName"`, `"lastName"` or `"both"` and the name you send is treated as the start of a name — `thom` returns Thomas, Thompson and Thomason — matched against aliases as well as the registered name. Minimum 3 characters. See Partial name search.

The synchronous endpoint answers from the maintained corpus in a single round trip, and every response reports its own `elapsedMs` so you can measure it against your own traffic. If you set a `deadlineMs` bound, the response comes back with `status: "partial"` once that bound is reached, with per-jurisdiction status reported in `sourceStatus`, so you always get an answer within the time you allow.

Every record you get back carries a `matchConfidence` score, the `matchBasis` (why it matched, per field — e.g. `lastName:prefix`, `alias:prefix`), a `matchedName` saying whether the registered name or an alias matched, a `dobVerification` result, and full per-source provenance with a source citation and a `lastCheckedAt` timestamp.

### What you can do

- **Search all or some jurisdictions.** Omit jurisdictions for a nationwide search, or pass codes like ["TX","NY"] to scope it.
- **Name, DOB, age, or location.** Match on any combination — last name or q is the only hard requirement; DOB or age dramatically improves confidence.
- **Nickname-aware name search.** A firstName is expanded to its nicknames/variants (John ↔ Johnny ↔ Jack) for recall, and the expansion is echoed back in each record’s nicknames[].
- **Partial (prefix) name search.** Set prefixMatch to search on the start of a name — "thom" returns Thomas, Thompson and Thomason — on the first name, the last name, or both, and across aliases as well as the registered name.
- **Geographic radius.** Provide lat/lng + radiusMiles (defaults to 1, capped at 100) to find registrants near a point; each address carries offender/predator flags and lat/lng. Radius matching selects on published coordinates, so use a name search when you need selection that is independent of address coordinates.
- **Free-text and fuzzy street match.** Use q for a single free-text field, or address for a fuzzy street-address match.
- **Tunable fuzzy matching.** Choose strict, balanced, or broad to trade recall for precision uniformly across every jurisdiction.
- **Extended per-state detail.** Add include: ["stateData"] to get offenses[], photos, vehicles, and state-specific fields.
- **De-duplicated people.** The same person appearing in several sources is merged into one record with a sources[] array.
- **Deadline control.** Set deadlineMs and onDeadline to bound latency, or let it run to completeness.

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | `object` | required | The identity fields to search on. See the query table below. |
| `jurisdictions` | `string[] \| null` | optional · default `null` | Jurisdiction codes to search. null or omitted searches the full dataset, e.g. ["IL","IN"]. |
| `locationScoped` | `boolean` | optional · default `false` | Scope control: when true AND query.state is set, only the jurisdictions covering that state are queried, instead of all 58. It is never inferred from query.state alone — state is a residence filter, not a jurisdiction selector, and a record can be held by one jurisdiction while the registrant has an address in another. When this narrows the fan-out, the response reports counts.sourcesSkippedByScope and a NARROWED SEARCH warning. |
| `freshness` | `"daily" \| "weekly"` | optional · default `"daily"` | Which tier answers the search. "daily" is the default and the most current tier, billed at +$0.01/call. "weekly" carries no surcharge and is one tier behind; identity fields — name, date of birth, offence history — are equivalent between the two. Every record carries its own lastCheckedAt on either tier, and sourceStatus reports per-source freshness on every response. |
| `match` | `"strict" \| "balanced" \| "broad"` | optional · default `"balanced"` | Fuzzy-match tolerance applied uniformly across all registries. See Matching & confidence. |
| `include` | `string[]` | optional | Request extra detail: "stateData" (per-state extended fields, offenses[], photos, vehicles) and/or "raw". |
| `recordTypes` | `string[]` | optional · default `["sex_offender"]` | Which record types to return. Additive as new types ship. |
| `deadlineMs` | `integer` | optional · default `120000` | How long to wait, in ms (max 300000). Completeness-first default of 2 minutes; lower it for a fast bounded response. |
| `onDeadline` | `"partial" \| "error"` | optional · default `"partial"` | On timeout, return partial results (default) or a 504 error. |

### Parameters — query

lastName (or q, or a lat/lng radius) is the primary key; every other field is an optional filter or verifier.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `firstName` | `string` | optional | Given name. Optional but improves ranking and enables nickname matching. |
| `lastName` | `string` | optional | Surname — the main search key. |
| `prefixMatch` | `"firstName" \| "lastName" \| "both"` | optional | Partial-name search: treat the name(s) you sent as the START of a name, so "thom" returns Thomas, Thompson and Thomason. Minimum 3 characters; also matches aliases. See Partial name search. |
| `nameMatch` | `object` | optional | Per-field control over how names are matched: { firstName: ["prefix","nickname"], lastName: ["prefix"], aliases: true }. Overrides prefixMatch and match. See Partial name search. |
| `dob` | `date (YYYY-MM-DD)` | optional | The strongest verifier; a match on DOB pushes confidence toward 1.0. |
| `age` | `integer` | optional | Used to verify identity when a DOB is not available. |
| `ageTolerance` | `integer (0–10)` | optional · default `1` | How many years of slack the age comparison allows when you send a dob and the record publishes only an age. Eleven registries publish an age and no date at all, so a name + DOB search compares your date against a published age — and how much slack that allows is a risk decision that belongs to you. The default of 1 is not arbitrary: a published age with an unpublished birthday is consistent with two birth years, and the age is anchored to the date we read that registry page rather than to today. Raise it for a high-recall screening pass — more same-name strangers returned, fewer true matches missed. |
| `onAgeMismatch` | `"drop" \| "flag"` | optional · default `"drop"` | What to do with a record that matches on NAME but whose published age contradicts the dob you sent. "drop" omits it. "flag" returns it labelled matchState: "age_mismatch" so you can judge it yourself — "silently omitted" and "checked, and the age contradicts your date" are different facts. A flagged record is always unverified and can never be read as a confirmed identification. |
| `city` | `string` | optional | Residence city filter. |
| `state` | `string` | optional | 2-letter USPS code or the full state name — identical results; an unresolvable value returns 422 rather than an empty result. It is a FILTER, not a jurisdiction selector: it does NOT change which jurisdictions run — all 58 are queried and state narrows the answer. Pass locationScoped: true when you explicitly want the narrower, cheaper fan-out. It FILTERS as a UNION, not as a plain residence test: a record is kept when either it has an address in that state OR that state's jurisdiction is the one holding it. Those halves are different populations — 105,028 records are held by a jurisdiction they have no address in, and 70,973 carry no address state at all and are reachable only by the second half. Every record returns registrationState and addressStates so you can tell which half matched without a second call. For the registration half alone, send jurisdictions instead. |
| `zipcode` | `string` | optional | Residence ZIP filter. |
| `address` | `string` | optional | Fuzzy street-address match. |
| `lat / lng` | `number` | optional | Coordinates for a radius search. |
| `radiusMiles` | `number` | optional · default `—` | Radius around lat/lng (max 100). Returns registrants near the point. |
| `q` | `string` | optional | Free-text search across name, aliases, city, ZIP, and address in one field. |
| `createdAtStart / createdAtEnd` | `date-time` | optional | Filter on when we first recorded the record (source.scrapedAt) — inclusive range bounds. |
| `updatedAtStart / updatedAtEnd` | `date-time` | optional | Filter on when the source last changed the record (source.sourceUpdatedAt) — inclusive range bounds. |

### Request

**cURL**

```bash
curl https://api.offendersearch.app/v1/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": {
      "firstName": "John",
      "lastName": "Doe",
      "dob": "1980-04-12"
    },
    "jurisdictions": null,
    "freshness": "daily",
    "match": "balanced",
    "include": ["stateData"]
  }'
```

**Node**

```javascript
const res = await fetch("https://api.offendersearch.app/v1/search", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query: { firstName: "John", lastName: "Doe", dob: "1980-04-12" },
    jurisdictions: null,     // null = search the full dataset
    freshness: "daily",
    match: "balanced",
    include: ["stateData"],  // extended per-state detail
  }),
});

const { records, sourceStatus, counts, status } = await res.json();
// status is "complete", or "partial" when a deadlineMs bound is set
for (const r of records) {
  console.log(r.matchConfidence, r.name.full, r.source.jurisdiction);
}
```

**Python**

```python
import os, requests

resp = requests.post(
    "https://api.offendersearch.app/v1/search",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    json={
        "query": {"firstName": "John", "lastName": "Doe", "dob": "1980-04-12"},
        "jurisdictions": None,   # None = search the full dataset
        "freshness": "daily",
        "match": "balanced",
        "include": ["stateData"],
    },
    timeout=130,
)
data = resp.json()
print(data["status"], data["counts"])
for r in data["records"]:
    print(r["matchConfidence"], r["name"]["full"], r["source"]["jurisdiction"])
```

### Response

```json
{
  "searchId": "srch_9f2a7c",
  "status": "complete",
  "freshness": "daily",
  "elapsedMs": 142,
  "counts": { "records": 1, "sourcesQueried": 2, "sourcesComplete": 2,
              "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "page": 1,
  "perPage": 1,
  "totalPages": 1,
  "warnings": [],
  "sourceStatus": [
    { "source": "NJ",    "status": "ok", "matched": 1, "fromCache": true,
      "note": "served from nightly cache",
      "incomplete": false, "incompleteReason": null },
    { "source": "NSOPW", "status": "ok", "matched": 1, "fromCache": true,
      "note": "served from nightly cache",
      "incomplete": false, "incompleteReason": null }
  ],
  "records": [
    {
      "recordId": "rec_4b1e",
      "uuid": "b1e4-…",
      "recordType": "sex_offender",
      "matchConfidence": 1.0,
      "matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "name_match"],
      "matchDetail": {
        "strategies": { "lastName": "exact" },
        "fieldsPresent": ["lastName", "firstName", "dob"],
        "signals": ["matchedLegalName"],
        "matchedNameType": "legal",
        "verification": "dob_match"
      },
      "name": { "first": "John", "middle": "A", "last": "Doe", "full": "John A. Doe" },
      "aliases": ["Johnny Doe"],
      "nicknames": ["john", "johnny", "jack"],
      "dob": "1980-04-12",
      "birthYear": 1980,
      "dobPrecision": "exact",
      "age": "46",
      "sex": "male", "race": "white", "height": "5'10\"", "weight": "180",
      "addresses": [
        { "type": "residence", "line1": "12 Main St", "city": "Trenton",
          "county": "Mercer", "state": "NJ", "zipcode": "08608",
          "lat": 40.2171, "lng": -74.7429 }
      ],
      "registrationState": "NJ",
      "addressStates": ["NJ"],
      "offense": { "tier": "II", "riskLevel": "", "statute": "2C:14-2",
                   "crime": "Sexual assault", "registrationDate": "2015-06-01" },
      "offenses": [
        { "crime": "Sexual assault", "statute": "2C:14-2", "tier": "II",
          "convictionDate": "2014-11-03", "registrationDate": "2015-06-01" }
      ],
      "stateData": {
        "stateOffenderId": "NJ-00123", "status": "active", "designation": "Tier II",
        "registrationEnds": "2035-06-01", "lawAgency": "Example County"
      },
      "flags": { "absconder": false, "predator": false },
      "images": [{ "url": "https://sor.example-state.gov/offender/NJ-00123/image" }],
      "matchState": "dob_match",
      "dobVerification": "dob_match",
      "unverified": false,
      "source": {
        "jurisdiction": "NJ",
        "registryName": "State Sex Offender Registry",
        "recordUrl": "https://sor.example-state.gov/offender/NJ-00123",
        "scrapedAt": "2026-07-25T09:14:00Z",
        "lastCheckedAt": "2026-07-25T09:14:00Z",
        "sourceUpdatedAt": "2026-07-20T00:00:00Z"
      },
      "sources": [
        { "jurisdiction": "NJ", "registryName": "State Sex Offender Registry",
          "recordUrl": "https://sor.example-state.gov/offender/NJ-00123",
          "scrapedAt": "2026-07-25T09:14:00Z",
          "lastCheckedAt": "2026-07-25T09:14:00Z",
          "sourceUpdatedAt": "2026-07-20T00:00:00Z" },
        { "jurisdiction": "NSOPW", "registryName": "NSOPW (National Sex Offender Public Website)",
          "recordUrl": "https://sor.example-state.gov/offender/NJ-00123",
          "scrapedAt": "2026-07-25T09:14:00Z",
          "lastCheckedAt": "2026-07-25T09:14:00Z",
          "sourceUpdatedAt": null }
      ]
    }
  ]
}
```

counts summarizes the search; sourceStatus reports every jurisdiction touched (ok / error / restricted / no_coverage / pending) with a lastCheckedAt timestamp; records holds the scored, de-duplicated matches.

### Worked examples

**Search on a partial name (prefixMatch)** — Only know part of a name? Set prefixMatch and the name you send is treated as the START of a name — "thom" returns Thomas, Thompson and Thomason. Use "firstName", "lastName", or "both". Minimum 3 characters (shorter returns 422). Prefixes are matched against every recorded alias as well as the registered name, and each record’s matchedName tells you which one matched. An exact match always ranks above a prefix match.

```json
{
  "query": {
    "lastName": "thom",
    "prefixMatch": "lastName",
    "state": "TX"
  }
}

// Returns Thomas, Thompson, Thomason, Thom, Hamilton-Thompson ...
// but NOT Bothomley - we anchor at the start of a name, never mid-word.
//
// Each record reports how it matched:
//   "matchBasis":  ["lastName:prefix", "name_match"]
//   "matchedName": { "value": "Ana Thompson", "type": "legal" }
//   "matchConfidence": 0.60      <- capped: a prefix hit is a CANDIDATE
```

**Partial first AND last name at once** — prefixMatch: "both" prefix-matches the first and last name together; a record must satisfy both. Add a dob or age to keep precision while widening recall — a confirmed DOB lifts the confidence cap that a partial-name match otherwise carries.

```json
{
  "query": {
    "firstName": "thom",
    "lastName": "and",
    "prefixMatch": "both",
    "dob": "1980-04-12"
  }
}

// -> Thomas Anderson, Thompson Andrews, ...
```

**Control matching per field (nameMatch)** — prefixMatch is the shorthand. Send nameMatch when you want to drive matching yourself: list exactly the strategies you want per field. Anything you leave out is OFF for that field; exact matching is always on and cannot be disabled. nameMatch overrides both prefixMatch and match. firstName accepts prefix, nickname, fuzzy and middle; lastName accepts prefix and fuzzy; aliases toggles alias matching (default true).

```json
{
  "query": {
    "firstName": "Robert",
    "lastName": "thom",
    "nameMatch": {
      "firstName": [],
      "lastName": ["prefix"],
      "aliases": true
    }
  }
}

// firstName: []          exact only - no nickname widening, so no "Bob"
// lastName:  ["prefix"]  Thompson, Thomas, Thomason ...
// aliases:   true        also match aliases (the default)
//
// -> Robert Thompson     (not Bob Thompson)
```

**Search a single state** — Pass jurisdiction codes in jurisdictions to limit the search. Naming ["TX"] searches Texas only.

```json
{
  "query": { "firstName": "Maria", "lastName": "Lopez" },
  "jurisdictions": ["TX"]
}
```

**Find registrants near a location** — Combine lat/lng with radiusMiles for a geographic search — useful for “who lives near this school / address”.

```json
{
  "query": {
    "lat": 40.7357,
    "lng": -74.1724,
    "radiusMiles": 5
  }
}
```

**Free-text query** — Pass q to search across name, aliases, city, ZIP, and address in a single field — the legacy-style catch-all.

```json
{
  "query": { "q": "john doe newark nj" },
  "match": "broad"
}
```

**High-precision check on the freshest data** — Use match: "strict" to require exact name + DOB, and freshness: "daily" — the most current tier — for a point-in-time answer you can defend.

```json
{
  "query": { "firstName": "John", "lastName": "Doe", "dob": "1980-04-12" },
  "match": "strict",
  "freshness": "daily"
}
```

**Extended per-state detail** — Add include: ["stateData"] to pull the full offenses[] list, photos, vehicles, and state-specific fields (parity with a legacy extensive mode).

```json
{
  "query": { "firstName": "John", "lastName": "Doe" },
  "include": ["stateData"]
}
```

**Fast bounded response** — Lower deadlineMs and keep onDeadline: "partial" to guarantee a quick answer within the bound you set.

```json
{
  "query": { "lastName": "Doe" },
  "deadlineMs": 4000,
  "onDeadline": "partial"
}
```

## What the envelope guarantees

- **Every top-level key is always present.** Test the value, never for key existence.
- **Coverage is reported, not implied.** `counts.sourcesQueried`, `sourcesComplete`, `sourcesIncomplete` and `sourcesSkippedByScope` appear on every response, alongside a `sourceStatus[]` entry per jurisdiction.
- **Match strength is labelled.** `matchState`, `matchBasis`, `matchDetail`, `matchedName` and `matchConfidence` let you set your own auto-accept threshold rather than inherit one.
- **The contract is additive.** New data lands as a new `recordType`; existing keys and their meanings do not change under you.
- **Ordering is total and stable.** The same query returns the same records in the same order, so paging never reshuffles.

---

# Matching, confidence & partial names

> strict, balanced and broad match modes, the closed match-strategy vocabulary, confidence ceilings, nickname expansion, and prefix search on partial names.

HTML: https://offendersearch.app/docs/matching · Markdown: https://offendersearch.app/docs/matching.md

## Match modes

The `match` field sets the fuzzy-match tolerance, applied uniformly across every jurisdiction, so behaviour is consistent no matter which states you hit. Whatever mode you choose, supplying a `dob` or `age` is the strongest lever on accuracy.

| Mode | First name | Last name | Use it for |
| --- | --- | --- | --- |
| `strict` | Exact only | Exact only | Lowest false positives; pair with a DOB. |
| `balanced` | Nicknames, variants, initials, typos (edit-distance 1) | Exact | The default — good recall with controlled noise. |
| `broad` | All balanced rules | Also prefix + typo tolerance | Maximum recall; expect more low-confidence rows to triage. |

Read `matchDetail` to see exactly why a record matched — it splits the match into `strategies` (for example `{"lastName":"exact"}`), the `fieldsPresent` on the record, and any `signals`. The flat `matchBasis` array carries the same information mixed together.

**Nickname-aware name search.** Under `balanced` and `broad`, a `firstName` is expanded to its common nicknames and variants before matching, and the expansion used is echoed back on every record in the output-only `nicknames[]` array. It is computed at serialize time and is never presented as data published by a jurisdiction.

## The match-strategy vocabulary — the complete list

Every record tells you *how* it matched, per field, in `matchDetail.strategies` (and, in the older flat form, as the `field:strategy` tokens of `matchBasis`). **The list is closed.** Any token the engine emits that this mapping does not define arrives under `matchDetail.other` rather than appearing here.

| Strategy | Can appear on | Meaning | Rank | Ceiling | Turned on by |
| --- | --- | --- | --- | --- | --- |
| `exact` | lastName, firstName, alias | The field equals what you sent — or equals one whole token of a compound surname, so a Smith query matches Hamilton-Smith. | 1 | none | Every mode. Cannot be switched off. |
| `nickname` | firstName only | A known given-name equivalence. Never appears on a surname — there is no surname nickname table, and asking for one is a 422. | 2 | 0.75 | balanced, broad |
| `initial` | firstName only | A single letter against a full given name, in either direction. | 2 | 0.60 | balanced, broad. Not requestable via nameMatch. |
| `prefix` | lastName, firstName, alias | The field starts with what you sent. Minimum 3 characters, anchored at a token start — never a mid-word substring. | 3 | 0.60 | firstName: balanced, broad · lastName: broad only |
| `fuzzy` | lastName, firstName, alias | A spelling variant or typo within an edit-distance budget that scales with name length. | 4 | 0.60 | firstName: balanced, broad · lastName: broad only |
| `middle` | firstName only | The given name you sent is the person’s MIDDLE name, matched as a whole token of the registered legal name — 81% of records carry one, and many people go by theirs. Whole token and exact only: minimum 2 characters, never a prefix, never fuzzy, and an initial is not a name in either direction. Tried only after the registered first name and every alias have failed, so a stronger basis is always the one reported. matchedNameType stays "legal"; signals also carries matchedMiddleName. | 5 | 0.60 | balanced, broad |
| `absent` | firstName only | The record publishes no first name at all, so your query could not be DISPROVED. The record is kept and flagged — it is never a confirmation. | 6 | 0.60 | Every mode. Not requestable via nameMatch. |

**Rank** is the ordering contract, not a score: `1` is the strongest evidence, and results sort by surname rank then given-name rank, with legal-name hits ahead of alias-only hits. **Ceiling** is the cap the strategy puts on `matchConfidence`, applied automatically so a widened hit is never reported as certainty. The cap is lifted in exactly one case: you supplied a `dob` and the record’s full date of birth matched it.

**Choosing an auto-accept threshold.** Gate on `matchDetail.strategies`, not on `matchConfidence` alone — several strategies share one ceiling. Auto-accept only `exact` on every field you queried with `matchedNameType: "legal"`, and for anything consequential require a confirmed date of birth as well. Send `nickname`, `initial`, `prefix` and `fuzzy` hits to human review. Never auto-accept an alias-only hit or `firstName: "absent"` — the latter means *this person could not be ruled out*, which is not the same fact as *this is your person*.

## Partial name search

Set `prefixMatch` and the name you send is treated as the **start** of a name. Searching `thom` returns Thomas, Thompson and Thomason — on the first name, the last name, or both — matched against every recorded alias as well as the registered name.

```json
{
  "query": {
    "firstName": "thom",
    "lastName": "and",
    "prefixMatch": "both"
  }
}
```

`prefixMatch` accepts `"firstName"`, `"lastName"`, `"both"`, or a list. When both fields are prefix-matched, a record must satisfy *both*.

| Rule | What it means |
| --- | --- |
| Prefix, not substring | thom matches Thompson but not Bothomley. We anchor at the start of a name, never mid-word. |
| Minimum 3 characters | A shorter prefix is rejected with 422. One or two letters is a bulk download, not a search. |
| Every name part | A compound or hyphenated surname matches on any of its parts: thom finds Hamilton-Thompson. |
| Aliases included | Prefixes are matched against every recorded alias as well as the registered name, so a variation of a first name still finds the person. matchedName tells you which one matched. |
| A superset of exact | Turning prefix matching on never loses a record that an exact search would have returned. |
| Exact ranks first | An exact surname match always appears above a prefix match, and the order is deterministic — paging never reshuffles results. |
| Scope it when you can | A prefix search scoped with an explicit jurisdictions list returns in roughly 30 ms; an unscoped nationwide prefix search on a very common stem returns in around 0.6 s. Note that query.state does NOT scope the search — it is a residence filter, and every jurisdiction is still queried. |

### Per-field control with nameMatch

`prefixMatch` is the shorthand. Send `nameMatch` to drive matching yourself and list exactly the strategies you want per field. Anything you leave out is **off** for that field, and `nameMatch` overrides both `prefixMatch` and `match`.

```json
{
  "query": {
    "firstName": "Robert",
    "lastName": "thom",
    "nameMatch": {
      "firstName": [],
      "lastName": ["prefix"],
      "aliases": true
    }
  }
}
```

| Strategy | Applies to | Meaning | Example |
| --- | --- | --- | --- |
| `exact` | firstName, lastName | The name equals what you sent. Always on — it cannot be switched off. | `Thomas → Thomas` |
| `prefix` | firstName, lastName | The name starts with what you sent. Minimum 3 characters. | `thom → Thomas, Thompson, Thomason` |
| `nickname` | firstName | A known given-name equivalence. | `bob → Robert` |
| `fuzzy` | firstName, lastName | A spelling variant or typo within edit distance. | `smyth → Smith` |

### Which name matched — legal or alias

A prefix is matched against every recorded alias as well as the registered name. Every record tells you which name did it.

```json
{
  "name":        { "first": "Ryan", "last": "Lloyd" },
  "aliases":     ["BRANDON THOMPSON"],
  "matchedName": { "value": "BRANDON THOMPSON", "type": "alias" },
  "matchBasis":  ["lastName:prefix", "alias:prefix", "alias_match"],
  "matchConfidence": 0.55
}
```

Send `"nameMatch": { "aliases": false }` to search registered names only.

### How results are ordered

The ordering is deterministic, so paging never reshuffles. In order:

1. Surname match strength — exact, then nickname, then prefix, then fuzzy
2. First-name match strength, on the same scale
3. Legal-name matches before alias-only matches
4. matchConfidence, highest first
5. Last name, first name, then date of birth
6. Record id — the final tie-break, so the ordering is total and repeatable

### Confidence ceilings

Widening a search widens the risk of returning the wrong person, so a partial-name hit is never presented as certainty.

| How the name matched | matchConfidence ceiling |
| --- | --- |
| Exact match on the registered name | `No cap` |
| Nickname | `0.75` |
| Prefix, fuzzy, or no first name published | `0.60` |
| Alias only | `0.55` |

The one exception: if you supplied a `dob` and the record’s full date of birth matches it, the cap is lifted. Supplying a `dob` or `age` alongside a prefix search is the single best way to keep precision while widening recall.

---

# Searching by date of birth

> Add a dob and every record returns a labelled matchState: dob_match, year_match, age_match or no_dob_age_year — so you set your own confidence threshold.

HTML: https://offendersearch.app/docs/date-of-birth · Markdown: https://offendersearch.app/docs/date-of-birth.md

## Searching by date of birth

A date of birth is the strongest identity verifier. Add `dob` (`YYYY-MM-DD`) to your `query` and every returned record carries a `matchState` telling you exactly how it matched. A record with no date of birth published is never silently dropped: if there is nothing to confirm the date but the name matched, you still get the record, labelled as unverified. Only a true conflict is excluded.

Jurisdictions publish identity at different resolutions — a full calendar date, a birth year, an age, or none of the three — and `matchState` names which one verified this record. That lets you set your own threshold for auto-accept versus review, rather than inherit one.

### matchState — how each record matched

| matchState | Meaning |
| --- | --- |
| `dob_match` | The record has a full date of birth on file and it equals the DOB you queried — the strongest confirmation. |
| `year_match` | The record has NO full DOB (a year-only source, e.g. Massachusetts), but its birth year equals your DOB’s year. |
| `age_match` | No DOB or birth year is published, but the record’s age matches the age implied by your DOB (±1 year for birthday drift). |
| `no_dob_age_year` | The record has no DOB, year, or age to verify against — yet it is still returned because the name matched. Check matchBasis for name_match or alias_match. This is the “no DOB on file, but the name matches” case. |
| `dob_mismatch` | The record HAD a DOB, year, or age and it did NOT match your query, so it is filtered OUT of the results. You will not normally see this state — it is documented so you know a true conflict is excluded, never silently shown. |
| `null` | You did not send a dob or an age, so there was nothing to verify the record against and this field carries no information. It is null, not a string — do not treat it as a failed verification. |

`matchState` is `null` when you did not supply a `dob` or `age`. A record whose date, year or age actively conflicts with your query is filtered out of `records[]`.

### dobVerification — the same signal, backward-compatible

`dobVerification` carries the raw token for integrations written before `matchState` existed. New code should read `matchState`.

| dobVerification | Meaning |
| --- | --- |
| `dob_match` | The record’s full date of birth equals the DOB you supplied — the strongest identity confirmation. |
| `year_match` | The record is year-only (dobPrecision: year) and its year equals your DOB’s year. |
| `age_match` | No full DOB was available, but the published age matched yours within ±1 year (birthday drift). |
| `no_dob_age_year` | The record had no DOB, age, or year to check against — kept and flagged (unverified: true). |
| `dob_mismatch` | The record had a DOB/age that did NOT match; such records are filtered out of records[], so you normally never see this. |

## dob, birthYear and dobPrecision

Jurisdictions differ in how much of a birth date they publish, so three fields carry the answer and which of them is populated is itself information. Read `dobPrecision` to know which case you are in.

| What the jurisdiction publishes | dob | birthYear | dobPrecision | Matches your dob query as |
| --- | --- | --- | --- | --- |
| A full calendar date | `"1961-09-25"` | `1961` | `"exact"` | `dob_match` |
| A birth year only | `null` | `1961` | `"year"` | `year_match` — any date you query inside that year |
| An age only | `null` | `null` | `"unknown"` | `age_match` — a date consistent with the published age |
| Neither date, year nor age | `null` | `null` | `"unknown"` | `no_dob_age_year` — kept on the name, `unverified: true` |

`dob` is a full `YYYY-MM-DD` date or `null` — never a bare year, never a partial date, and never a day and month chosen to fill the field, so you can hand it straight to a date parser. **Read `birthYear` for any year logic**: it is populated both from a published year and from the year of a published date, so your code never has to branch on which kind of record it is looking at. It is never inferred from an age.

**Age tolerance.** Where a jurisdiction publishes an age and no date, a name + `dob` search compares your date against that published age, and `ageTolerance` (0–10, default 1) sets how much slack that comparison allows. `onAgeMismatch` decides what happens to a record that matches on name but whose published age contradicts your date: `"drop"` omits it, `"flag"` returns it labelled `matchState: "age_mismatch"` and always `unverified`.

```json
{
  "query": {
    "firstName": "John",
    "lastName": "Doe",
    "dob": "1980-04-12",
    "ageTolerance": 2,
    "onAgeMismatch": "flag"
  },
  "match": "balanced"
}
```

## matchBasis — why each record is in the results

| matchBasis value | Meaning |
| --- | --- |
| `lastName:exact / firstName:exact` | The field equals the name you sent. |
| `lastName:prefix / firstName:prefix` | The field starts with the name you sent — a partial-name hit. See Partial name search. |
| `firstName:nickname` | Matched through a nickname equivalence (bob → Robert). |
| `lastName:fuzzy / firstName:fuzzy` | Matched through a spelling variant or typo. |
| `firstName:absent` | The record publishes no first name, so your first name could not be disproved — kept and flagged rather than dropped. |
| `alias:exact / alias:prefix` | An alias matched, not the registered name. Read matchedName to see which alias. |
| `name_match` | The record matched on its primary name — the name you searched. |
| `alias_match` | The record matched on one of its aliases rather than its primary name. |
| `unverified_no_dob_or_age` | The record was kept without DOB/age verification because there was nothing on file to check against — paired with matchState = no_dob_age_year. |

`matchBasis` also carries `lastName`, `firstName`, and `dob` tokens indicating which identity fields the record itself provides. So a name hit with no date of birth reads as `matchState: "no_dob_age_year"` with `matchBasis: ["...", "name_match"]` (or `alias_match` when the match was on an alias).

```json
{
  "matchState": "no_dob_age_year",
  "matchBasis": ["lastName", "firstName", "unverified_no_dob_or_age", "name_match"],
  "unverified": true,
  "name": { "first": "John", "last": "Doe", "full": "John Doe" },
  "dob": null,
  "dobPrecision": "unknown"
}
```

---

# Result completeness & per-source status

> Every response labels which sources completed. counts, sourceStatus and the closed incompleteReason enum tell you whether an empty result is an answer.

HTML: https://offendersearch.app/docs/result-completeness · Markdown: https://offendersearch.app/docs/result-completeness.md

## Per-source status on every response

Every jurisdiction a search touches is reported individually in `sourceStatus[]`, on every response, whether or not it contributed a record. That is what makes a result auditable: a search is never a single number you have to trust, and an incomplete search is always labelled as one.

| status | Meaning |
| --- | --- |
| `ok` | Queried successfully; matched holds the count from this source. |
| `pending` | Still running (async searches) — results not yet in. |
| `error` | This source did not complete for this request; see note. |
| `restricted` | This source is not commercially available for this request; see note. |
| `no_coverage` | The source does not cover the queried location. |

**A complete search**

```json
{
  "searchId": "srch_9f2a7c",
  "status": "complete",
  "counts": {
    "records": 1,
    "sourcesQueried": 2,
    "sourcesComplete": 2,
    "sourcesIncomplete": 0,
    "sourcesSkippedByScope": 0
  },
  "warnings": [],
  "sourceStatus": [
    { "source": "NJ", "status": "ok", "matched": 1,
      "lastCheckedAt": "2026-08-13T04:12:00Z",
      "incomplete": false, "incompleteReason": null },
    { "source": "NSOPW", "status": "ok", "matched": 1,
      "lastCheckedAt": "2026-08-13T04:20:00Z",
      "incomplete": false, "incompleteReason": null }
  ],
  "records": []
}
```

**A labelled partial search**

```json
{
  "searchId": "srch_31c9de",
  "status": "partial",
  "counts": {
    "records": 4,
    "sourcesQueried": 58,
    "sourcesComplete": 56,
    "sourcesIncomplete": 2,
    "sourcesSkippedByScope": 0
  },
  "warnings": [
    "2 of 58 sources did not complete: TX, WA."
  ],
  "sourceStatus": [
    { "source": "TX", "status": "ok", "matched": 0,
      "incomplete": true, "incompleteReason": "truncated" },
    { "source": "WA", "status": "error", "matched": 0,
      "incomplete": true, "incompleteReason": "unavailable",
      "note": "source did not complete for this request" },
    { "source": "NJ", "status": "ok", "matched": 4,
      "incomplete": false, "incompleteReason": null }
  ],
  "records": []
}
```

**Read `sourceStatus[].incomplete`, not `status`.** `sourceStatus[].status` describes whether the source responded; `incomplete` describes whether the search of that source finished. A source can be `"ok"` and `incomplete: true` at the same time.

## Reading an empty result

An empty `records` array is not, on its own, evidence that a person is not registered. Two very different events produce the same empty list, and the response tells you which one you are holding.

| What happened | What the response says |
| --- | --- |
| Every jurisdiction completed and nobody matched. **This is an answer.** | `status: "complete"`, `counts.sourcesIncomplete: 0`, `counts.sourcesComplete === counts.sourcesQueried` |
| One or more jurisdictions did not complete. **This is a lower bound.** | `status: "partial"`, `counts.sourcesIncomplete > 0`, and a `warnings[]` sentence naming the jurisdictions |

Never infer absence without checking `counts.sourcesIncomplete === 0` first. Record an incomplete search as *not determined* and re-query more narrowly.

```javascript
const { status, counts, records } = await search(query);

if (counts.sourcesIncomplete > 0) {
  // Not an answer about anyone: a lower bound.
  return { outcome: "not_determined", counts };
}
if (records.length === 0) {
  // Every queried source completed and nobody matched.
  return { outcome: "no_match", counts };
}
return { outcome: "matches", records, counts };
```

## counts — how much of the search finished

`status` is one word, and one word cannot tell you how partial a partial result is. A search that reached 57 of 58 jurisdictions and one that reached 7 of 58 both say `"partial"`, and they are very different answers.

| Key | Type | Meaning |
| --- | --- | --- |
| `records` | `integer` | How many records matched in TOTAL. This is the whole result set, not the length of one page — records.length equals it unless you asked for a page slice. |
| `sourcesQueried` | `integer` | How many jurisdictions this request fanned out to. 58 on an unscoped search; fewer when you set jurisdictions or locationScoped. |
| `sourcesComplete` | `integer` | How many of those jurisdictions completed. THIS IS THE NUMBER TO ACT ON. status is a single word — "partial" reads identically whether 1 of 58 sources fell short or 51 did, and those are very different answers. sourcesComplete makes them distinguishable: 57 of 58 is a result you can act on; 7 of 58 is one to retry. status: "complete" means exactly sourcesComplete === sourcesQueried. |
| `sourcesIncomplete` | `integer` | The complement: how many jurisdictions did not complete. Anything above 0 means the records you received are a LOWER BOUND rather than a closed answer. Each one is named in sourceStatus[] with an incompleteReason. |
| `sourcesSkippedByScope` | `integer` | How many jurisdictions were not queried because YOUR request narrowed the fan-out — locationScoped: true, or an explicit jurisdictions list. It is separate from sourcesIncomplete on purpose: a jurisdiction you chose not to query is a scope decision, not an incomplete one. 0 on a nationwide search. |

`status: "complete"` means exactly `counts.sourcesComplete === counts.sourcesQueried`. If you log one number per search, log that ratio rather than the word.

## incompleteReason — the closed enum

A closed enum of six values, total over the condition it describes: `incomplete: true` always carries exactly one of these, and `incomplete: false` always carries `null`. An exhaustive switch on this field is safe to write; adding a seventh value would be a breaking change and is treated as one.

| Value | What to do | What it means |
| --- | --- | --- |
| `deadline` | Retry the same request | This source did not complete within the time bound set for this request, so it contributed no records. Transient — the identical request will usually complete. |
| `truncated` | Narrow the query — a retry returns the same answer | The query matches more candidate rows inside this one jurisdiction than a single search examines, and the examined set is not ranked, so a matching person can fall outside it. Deterministic: the identical request returns the identical answer. Add a first name, a date of birth or an age, or a city, and search again. |
| `excluded` | Remove the parameter from your request | A parameter on your request (onStale: "omit") excluded this source from the search. Remove it and this source is queried again. |
| `not_searched` | Treat as not determined; retry later | This source did not contribute to this request, so a zero from it is not an answer about anyone. |
| `unavailable` | Retry with backoff | This source did not respond to this request and contributed no records. |
| `error` | Retry with backoff | This source returned no usable answer for this request and contributed no records. |

## The defined per-source limits

Two per-jurisdiction limits are defined in the contract so that one over-broad query cannot degrade the service for everyone. Both are always reported, never silent.

| Limit | Value | What reaches it |
| --- | --- | --- |
| `truncated` | 15,000 candidate rows per jurisdiction | Your query matches more rows inside that one jurisdiction than a single search examines. The examined set is not ranked, so a matching person can fall outside it. |
| `truncated` | 2,000 typo candidates per jurisdiction | `match: "broad"` only. Broad evaluates a second, speculative set of candidates for spelling variants of your surname, with its own smaller budget. |
| `deadline` | 15 seconds per jurisdiction | That jurisdiction did not complete within the bound and contributed zero records. |

A `dob` or `age` is never what gets displaced: when your query constrains the birth year, the records whose birth year matches are examined first, to the full 15,000.

**A cap is not a page.** `perPage` slices an answer you can walk in full — `counts.records` is the true total and `page = 1 … totalPages` returns every record exactly once. A candidate limit is the opposite: there is no next 15,000 to ask for. That is why it is reported and paging is not.

### Which query shapes reach a limit

| Query shape | Behaviour |
| --- | --- |
| `lastName` + `state` | **Safest.** Use this shape for any automated reconciliation job. |
| `lastName` alone | Fine for most surnames. Reaches the limit on short or very common surname tokens — Lee, Ford, Hill, Wood, Ward, Ray, West, James, Allen, Thomas — because alias and address matching widen the reach of a surname. |
| `firstName` with no `lastName` | **Declined with `422`.** A first name on its own has nothing to narrow on. Pair it with a surname, or with a date of birth or age. |
| `city`, `zipCode`, `dob` or `age` with no name | Reaches the limit in large jurisdictions — a single birth year still matches tens of thousands of people. With a name, the date is never what is displaced. |

**On the compatibility endpoint.** `/v1/compat/sexoffender` mirrors the legacy envelope, which has room for exactly one completeness signal: the integer `error: 503`. `{"offenders": [], "error": 503}` means nothing was established — not that nobody matched. Call `POST /v1/search` when you need to tell those two apart.

---

# Pagination & response size

> Search returns the full de-duplicated result set in one response unless you paginate. page, perPage and totalPages, and how a cap differs from a page.

HTML: https://offendersearch.app/docs/pagination · Markdown: https://offendersearch.app/docs/pagination.md

## Pagination

`POST /v1/search` returns the **full, de-duplicated result set in a single response** unless you ask otherwise. Set `query.page` and/or `query.perPage` to paginate; set neither and you get everything at once. Results are totally and stably ordered, so paging never reshuffles.

You do not have to page to get a complete answer. There is no page-until-empty loop to write and no cursor to carry. On a response you did not paginate, `perPage` comes back equal to `counts.records` and `totalPages` is `1` — that is the shape confirming you have everything.

| Parameter | Default | Constraint |
| --- | --- | --- |
| `query.page` | 1 | `>= 1` — `0` returns 422. A page past the end is clamped to the last page. |
| `query.perPage` | 20 *when paginating* | `>= 1` |

The search envelope returns `page`, `perPage` and `totalPages` directly. `counts.records` is the total matched *before* the page slice, which is not `records.length`.

```javascript
async function page(n) {
  const res = await fetch("https://api.offendersearch.app/v1/search", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.OFFENDERSEARCH_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      query: { lastName: "Doe", state: "NJ", page: n, perPage: 20 },
    }),
  });
  return res.json();
}

const first = await page(1);
for (let n = 2; n <= first.totalPages; n++) {
  const next = await page(n);
}
```

## Response size

An unpaginated search for a common surname can reach roughly 20 MB of JSON (gzip is applied above 1 KB). Paginate for anything interactive. An unpaginated response is returned up to a defined **4,000-record** cap and sets `capped: true` when that cap applies; send `perPage` and every matched record is reachable, with `capped` false. Branch on `counts.records` against `counts.recordsReturned`: if they differ, there is more to fetch.

- **Broad geographic searches.** A wide `lat/lng` + `radiusMiles` query can match many registrants. Narrow the radius or add name filters, or run it as an async search. Radius matching selects on published coordinates, so use a name search when you need selection that is independent of address coordinates.
- **Compatibility endpoint.** The compat endpoint preserves the legacy paged envelope. Regular searches page 20 per page there; a GIS (lat + lng) search pages 50.
- **A cap is not a page.** The per-jurisdiction candidate limit is reported through `incompleteReason: "truncated"` and is not something paging can walk through — narrow the query instead.

---

# Freshness tiers & source coverage

> Choose a freshness tier per request. Every record reports its own lastCheckedAt, and GET /v1/sources returns the live coverage catalog with health signals.

HTML: https://offendersearch.app/docs/freshness · Markdown: https://offendersearch.app/docs/freshness.md

## Freshness tiers

Freshness is an optional per-request `freshness` parameter with two values. It defaults to `daily` — the most current tier — billed at the base rate plus $0.01 per call. Pass `weekly` for the same dataset one tier behind, with no surcharge. Every record returns a `lastCheckedAt` either way, so currency is a value on the response rather than an assumption about the service.

| Tier | What you get | Price | Best for |
| --- | --- | --- | --- |
| `daily (default)` | The most current coverage tier | Base + $0.01 / call | Anything where currency decides the outcome: pre-employment screening, tenant checks, live compliance |
| `weekly` | One tier behind daily — identity fields are equivalent | Included at base rate (no surcharge) | Bulk and batch work, and periodic re-screens |

Base is $0.15 per call, dropping to $0.11 after 2,000 calls a month; the default daily freshness adds $0.01 per call, and weekly freshness has no surcharge.

**Send `daily` when currency decides the outcome** — pre-employment screening, tenant checks, live compliance. **Send `weekly` for bulk and periodic work**: it is the same nationwide dataset one tier behind, and identity fields — name, date of birth, offence history, aliases — are equivalent.

```json
{
  "query": { "firstName": "John", "lastName": "Doe", "dob": "1980-04-12" },
  "freshness": "daily"
}
```

## Currency is answered per request

Two fields carry it, and both are on every response. `sources[].lastCheckedAt` is when that jurisdiction’s copy of the record was last confirmed, and `sourceStatus[].lastCheckedAt` is the same question at the jurisdiction level for every jurisdiction the search touched — including the ones that returned no match. Read them rather than a general statement about the dataset: they are contract fields, and they answer the question for the exact record and the exact request in front of you.

```json
{
  "source": {
    "jurisdiction": "NJ",
    "registryName": "State Sex Offender Registry",
    "recordUrl": "https://…",
    "scrapedAt": "2026-08-13T04:12:00Z",
    "lastCheckedAt": "2026-08-13T04:12:00Z",
    "sourceUpdatedAt": "2026-08-11T00:00:00Z"
  },
  "sources": []
}
```

`sourceUpdatedAt` is the date the jurisdiction itself states it last changed the record; it is `null` where a jurisdiction publishes no such date.

## GET /v1/sources — List sources

The coverage catalog: every jurisdiction we cover, by code, name, and live health.

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

The live coverage catalog: every jurisdiction the API covers, with its code, display name, and health signals such as typical latency and last successful refresh. Use it to render your own coverage UI or to decide which `jurisdictions` to name.

### What you can do

- **Coverage.** Every jurisdiction with its code and name.
- **Health.** Typical latency and last successful refresh timestamps, per jurisdiction.

### Request

**cURL**

```bash
curl https://api.offendersearch.app/v1/sources \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

**Node**

```javascript
const res = await fetch("https://api.offendersearch.app/v1/sources", {
  headers: { "X-API-Key": process.env.OFFENDERSEARCH_KEY },
});
const sources = await res.json();
for (const s of sources) console.log(s.id, s.scope, s.covers.join(","));
```

**Python**

```python
import os, requests

resp = requests.get(
    "https://api.offendersearch.app/v1/sources",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
)
for s in resp.json():
    print(s["id"], s["scope"], ",".join(s["covers"]))
```

### Response

```json
[
  {
    "id": "NJ",
    "name": "State Sex Offender Registry",
    "covers": ["NJ"],
    "health": { "lastSuccessAt": "2026-07-25T09:14:00Z", "typicalLatencyMs": 380 }
  },
  {
    "id": "CA",
    "name": "State Sex Offender Registry",
    "covers": ["CA"],
    "health": { "lastSuccessAt": "2026-07-25T09:14:00Z", "typicalLatencyMs": 410 }
  }
]
```

---

# Jurisdictions & codes

> Every jurisdiction code the API accepts, how the jurisdictions array scopes a search, and why query.state filters a result rather than selecting registries.

HTML: https://offendersearch.app/docs/jurisdictions · Markdown: https://offendersearch.app/docs/jurisdictions.md

## Scoping a search

The `jurisdictions` array controls *which jurisdictions run*. Omit it or send `null` to search the full dataset — the default and the common case. Naming codes narrows the search to exactly those jurisdictions, which reduces both latency and the number of sources reported in `sourceStatus`.

- `jurisdictions: null` → the full dataset (default).
- `["IL"]` → Illinois only.
- `["IL", "IN"]` → Illinois *and* Indiana only.

```json
{
  "query": { "firstName": "Maria", "lastName": "Lopez" },
  "jurisdictions": ["TX", "NM"]
}
```

## query.state filters a result; jurisdictions selects the search

These are two different controls. `jurisdictions` decides which jurisdictions are queried at all. `query.state` does not change that — every jurisdiction is queried and `state` narrows the answer. Set `locationScoped: true` alongside `query.state` when you explicitly want the narrower, cheaper fan-out; the response then reports `counts.sourcesSkippedByScope` and a NARROWED SEARCH warning, so the scope decision is visible in the payload.

**`query.state` filters as a union, not as a plain residence test.** A record is kept when either it has an address in that state *or* that state’s jurisdiction is the one holding it. Those are different populations — 105,028 records are held by a jurisdiction they have no address in, and 70,973 carry no address state at all and are reachable only by the second half. Every record returns `registrationState` and `addressStates` so you can tell which half matched without a second call.

```json
// Filter: query every jurisdiction, keep Texas-connected records.
{ "query": { "lastName": "Doe", "state": "TX" } }

// Scope: query only the jurisdictions covering Texas.
{ "query": { "lastName": "Doe", "state": "TX" }, "locationScoped": true }

// Scope, explicitly: query exactly these jurisdictions.
{ "query": { "lastName": "Doe" }, "jurisdictions": ["TX"] }
```

`state` accepts a 2-letter USPS code or the full state name — identical results. A value that cannot be resolved returns `422` rather than an empty result, so a typo is never mistaken for no matches.

## Jurisdiction codes

Jurisdiction codes are the standard USPS state & territory abbreviations, and they are the same vocabulary that `query.state`, `registrationState`, `addressStates` and `sources[].jurisdiction` use — so a value you read off a record can be sent straight back as a filter.

| Code | Jurisdiction |
| --- | --- |
| `AL` | Alabama |
| `AK` | Alaska |
| `AZ` | Arizona |
| `AR` | Arkansas |
| `CA` | California |
| `CO` | Colorado |
| `CT` | Connecticut |
| `DE` | Delaware |
| `DC` | District of Columbia (territory) |
| `FL` | Florida |
| `GA` | Georgia |
| `HI` | Hawaii |
| `ID` | Idaho |
| `IL` | Illinois |
| `IN` | Indiana |
| `IA` | Iowa |
| `KS` | Kansas |
| `KY` | Kentucky |
| `LA` | Louisiana |
| `ME` | Maine |
| `MD` | Maryland |
| `MA` | Massachusetts |
| `MI` | Michigan |
| `MN` | Minnesota |
| `MS` | Mississippi |
| `MO` | Missouri |
| `MT` | Montana |
| `NE` | Nebraska |
| `NV` | Nevada |
| `NH` | New Hampshire |
| `NJ` | New Jersey |
| `NM` | New Mexico |
| `NY` | New York |
| `NC` | North Carolina |
| `ND` | North Dakota |
| `OH` | Ohio |
| `OK` | Oklahoma |
| `OR` | Oregon |
| `PA` | Pennsylvania |
| `RI` | Rhode Island |
| `SC` | South Carolina |
| `SD` | South Dakota |
| `TN` | Tennessee |
| `TX` | Texas |
| `UT` | Utah |
| `VT` | Vermont |
| `VA` | Virginia |
| `WA` | Washington |
| `WV` | West Virginia |
| `WI` | Wisconsin |
| `WY` | Wyoming |
| `PR` | Puerto Rico (territory) |
| `GU` | Guam (territory) |
| `USVI` | US Virgin Islands (territory) |
| `AS` | American Samoa (territory) |
| `CNMI` | Northern Mariana Islands (territory) |

---

# Async search, webhooks & idempotency

> Submit a search, collect it by polling or webhook: POST /v1/searches, GET /v1/searches/{searchId}, signed webhook delivery, and retries with Idempotency-Key.

HTML: https://offendersearch.app/docs/async-and-webhooks · Markdown: https://offendersearch.app/docs/async-and-webhooks.md

## Choosing sync or async

Use `POST /v1/search` when a person is waiting on the answer — it is the interactive endpoint and returns in one round trip within `deadlineMs`. Use `POST /v1/searches` for batch and high-volume work: submit the search, then collect the result by polling `GET /v1/searches/{searchId}` or by supplying a `webhookUrl`. Both read the same corpus at the same `freshness` tier and return the same envelope.

|  | Synchronous | Asynchronous |
| --- | --- | --- |
| Endpoint | `POST /v1/search` | `POST /v1/searches` |
| Shape | One round trip; `elapsedMs` on every response | Submit now, collect when ready |
| Scope | Bounded by `deadlineMs` | No request-timeout ceiling — every named jurisdiction runs to completion |
| Delivery | In the response | Poll or webhook |
| Best for | Interactive checks, anything user-facing | Bulk screening, periodic re-screens, backfills, scheduled jobs |

## POST /v1/searches — Asynchronous search — built for batch and high volume

The batch endpoint. Submit the search, get a job id back immediately, and collect the result when it is ready. Built for bulk screening, periodic re-screens, backfills and unattended scheduled jobs.

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

The async endpoint accepts every field the synchronous search accepts, plus an optional `webhookUrl`. Instead of waiting, it returns `202 Accepted` with a `searchId` and a `resultsUrl` right away and keeps working in the background until every named jurisdiction has responded.

Because you are not holding a connection open, there is no request-timeout ceiling on the work — which is what makes this the right endpoint for bulk screening a roster, re-screening a population on a schedule, backfills, and broad national sweeps where you want every jurisdiction rather than a fast answer.

Retrieve results by polling `GET /v1/searches/{searchId}` until `status` is `complete`, or supply a `webhookUrl` and we will POST the finished `SearchResponse` to it — no polling required.

Reach for `POST /v1/search` instead when a person is waiting on the answer: that is the interactive endpoint and it returns in one round trip. Both endpoints read the same corpus at the same `freshness` tier and return the same envelope — the choice is about how you collect the result, not how current it is.

Send an `Idempotency-Key` header to make retries safe: a repeated key returns the original job instead of starting (and billing) a second search. See Idempotency.

### What you can do

- **Built for volume.** No request-timeout ceiling, so every named jurisdiction runs to completion. Submit many, collect the results as they finish.
- **Webhook or poll.** Get notified via webhookUrl, or poll the results URL on your own schedule.
- **Same query surface.** All query fields, jurisdictions, match, freshness, and include carry over.
- **Safe retries.** An Idempotency-Key header collapses duplicate submissions to one job and one bill.

### Headers

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | Your secret API key. |
| `Idempotency-Key` | `string` | optional | Optional. A unique client token; retries with the same value return the original job. |

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `…SearchRequest` | `object` | required | Every field from POST /v1/search is accepted (query, jurisdictions, match, freshness, include, …). |
| `webhookUrl` | `uri` | optional | We POST the completed SearchResponse here when the search finishes. |

### Request

**cURL**

```bash
curl https://api.offendersearch.app/v1/searches \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f0c2e14-order-4821" \
  -d '{
    "query": { "lastName": "Doe" },
    "jurisdictions": null,
    "freshness": "daily",
    "webhookUrl": "https://acme.example/hooks/os"
  }'
```

**Node**

```javascript
const res = await fetch("https://api.offendersearch.app/v1/searches", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": "7f0c2e14-order-4821",
  },
  body: JSON.stringify({
    query: { lastName: "Doe" },
    jurisdictions: null,
    freshness: "daily",
    webhookUrl: "https://acme.example/hooks/os",
  }),
});

const { searchId, resultsUrl, status } = await res.json();
console.log(status, resultsUrl); // "running", ".../v1/searches/srch_9f2a7c"
```

**Python**

```python
import os, requests

resp = requests.post(
    "https://api.offendersearch.app/v1/searches",
    headers={
        "X-API-Key": os.environ["OFFENDERSEARCH_KEY"],
        "Idempotency-Key": "7f0c2e14-order-4821",
    },
    json={
        "query": {"lastName": "Doe"},
        "jurisdictions": None,
        "freshness": "daily",
        "webhookUrl": "https://acme.example/hooks/os",
    },
)
job = resp.json()
print(job["status"], job["resultsUrl"])
```

### Response

```json
{
  "searchId": "srch_9f2a7c",
  "status": "running",
  "estimatedCompletionSec": 45,
  "resultsUrl": "https://api.offendersearch.app/v1/searches/srch_9f2a7c"
}
```

Returns 202 Accepted. status is "pending" or "running"; poll resultsUrl or wait for the webhook.

## GET /v1/searches/{searchId} — Get a search

Fetch the current status and results of an asynchronous search.

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

Returns the same `SearchResponse` shape as the synchronous endpoint. While the search is still running, `status` is `running` and `records` fills in as jurisdictions complete; once every jurisdiction has reported, `status` becomes `complete`.

Poll on your own cadence (a few seconds is typical), or skip polling entirely by supplying a `webhookUrl` on the original request.

### Path parameters

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `searchId` | `string` | required | The id returned by POST /v1/searches. |

### Request

**cURL**

```bash
curl https://api.offendersearch.app/v1/searches/srch_9f2a7c \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

**Node**

```javascript
// Poll until the search is complete.
async function poll(searchId) {
  for (;;) {
    const res = await fetch(
      `https://api.offendersearch.app/v1/searches/${searchId}`,
      { headers: { "X-API-Key": process.env.OFFENDERSEARCH_KEY } },
    );
    const data = await res.json();
    if (data.status === "complete" || data.status === "error") return data;
    await new Promise((r) => setTimeout(r, 2000));
  }
}

const result = await poll("srch_9f2a7c");
console.log(result.counts.records, "records");
```

**Python**

```python
import os, time, requests

def poll(search_id):
    url = f"https://api.offendersearch.app/v1/searches/{search_id}"
    headers = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}
    while True:
        data = requests.get(url, headers=headers).json()
        if data["status"] in ("complete", "error"):
            return data
        time.sleep(2)

result = poll("srch_9f2a7c")
print(result["counts"]["records"], "records")
```

### Response

```json
{
  "searchId": "srch_9f2a7c",
  "status": "complete",
  "elapsedMs": 41230,
  "counts": { "records": 3 },
  "sourceStatus": [ /* … per-jurisdiction status … */ ],
  "records": [ /* … scored, de-duplicated records … */ ]
}
```

Returns 404 if the searchId does not exist. status is running until every jurisdiction reports.

## Webhooks

Supply a `webhookUrl` on an async search and the finished `SearchResponse` is POSTed to it when the search completes. Respond `2xx` promptly to acknowledge; a non-2xx response is retried with exponential backoff for several attempts. Deliveries are signed so you can verify authenticity.

```json
{
  "event": "search.completed",
  "searchId": "srch_9f2a7c",
  "status": "complete",
  "counts": { "records": 3, "sourcesQueried": 58,
              "sourcesComplete": 58, "sourcesIncomplete": 0 },
  "sourceStatus": [],
  "records": []
}
```

Verify the `X-Offendersearch-Signature` header (an HMAC of the raw request body using your signing secret) before trusting a webhook, and treat delivery as at-least-once — de-duplicate on `searchId`.

```javascript
import crypto from "node:crypto";

app.post("/hooks/os", express.raw({ type: "*/*" }), (req, res) => {
  const sent = req.get("X-Offendersearch-Signature") ?? "";
  const expected = crypto
    .createHmac("sha256", process.env.OS_WEBHOOK_SECRET)
    .update(req.body)              // the RAW body, before JSON parsing
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected))) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString("utf8"));
  enqueue(event.searchId, event);  // at-least-once: de-duplicate on searchId
  res.sendStatus(200);
});
```

## Idempotency

Network retries should never start — or bill — a second search. Send an `Idempotency-Key` header (any unique client-generated string) on `POST /v1/searches`. If that key has been seen before, the original job is returned instead of a new one being created.

```bash
curl https://api.offendersearch.app/v1/searches \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Idempotency-Key: 7f0c2e14-order-4821" \
  -H "Content-Type: application/json" \
  -d '{ "query": { "lastName": "Doe" }, "freshness": "daily" }'
```

Reuse the same key when retrying the same logical request; use a fresh key for a genuinely new search. Keys are scoped to your account and retained long enough to cover normal retry windows.

---

# Batch & CSV search

> Run up to 1000 lookups in one call. Post a JSON array, a JSON envelope with batch-wide options, or a text/csv body; results return in order, fault-isolated.

HTML: https://offendersearch.app/docs/batch · Markdown: https://offendersearch.app/docs/batch.md

## One row in, one result out

The batch endpoint is the shape to reach for when you already hold a list: a roster to screen, a CSV export to reconcile, a nightly re-check of a population. Each row runs through the same engine as `POST /v1/search`, so every result carries the same envelope — `counts`, `sourceStatus`, labelled `matchState` — and the same Record schema.

- **Three accepted body shapes.** A bare JSON array of `Query` objects, a JSON envelope `{ "queries": [...], ...options }` whose non-`queries` keys apply to every row, or a `text/csv` body whose header row names Query fields.
- **Input order is preserved.** Each result carries its 0-based `index`.
- **Rows are fault-isolated.** A row that fails is reported with `status: "error"` in place and never aborts the rest of the batch.
- **1000 rows per call.** A larger batch returns `413`.

**Billing is per search, not per request.** A batch is one HTTP call, but each search in it is metered as one search — a batch of 100 rows is 100 metered searches. Set `freshness: "weekly"` on a row, or batch-wide, to meter that search with no surcharge.

## POST /v1/batch — Batch / CSV search

Run many lookups in one call — row in, row out. Send a JSON array of queries, a JSON envelope with batch-wide options, or a text/csv body. Each row runs through the same engine as /v1/search and results come back in input order.

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

The batch endpoint provides legacy CSV-batch parity: **one row = one call in, one result out**. Post a JSON array of `Query` objects, a JSON envelope `{ "queries": [...], ...options }` where the non-`queries` keys (`jurisdictions`, `freshness`, `match`, `recordTypes`, `locationScoped`, `include`) apply to every row, or a `text/csv` body whose header row names Query fields (`firstName,lastName,state,dob,city,zipcode,address,age,q`).

Rows run with bounded server-side concurrency and results return in the same order you sent them. A row that fails is reported with `status: "error"` **in place** — it never aborts the rest of the batch. The maximum is **1000 rows** per call (a larger batch returns `413`).

**Billing is per search, not per request.** A batch is one HTTP call but each search in it is billed as one search — a batch of 100 rows = 100 metered searches, each at the graduated per-call rate plus its own +$0.01 daily-freshness surcharge when that row is `daily` (the default). Set `freshness: "weekly"` on a row (or batch-wide) to bill that search with no surcharge.

Every row is served at the freshness tier it asks for. `weekly` is the natural fit here — bulk and periodic re-screening work is exactly what it is for, and it carries no surcharge; set `daily` on the rows where currency decides the outcome. For very large or long-running workloads, the asynchronous endpoint is the other batch-shaped option: submit and collect, with no request-timeout ceiling.

### What you can do

- **JSON or CSV.** Post a JSON array, a JSON envelope with batch-wide options, or a text/csv body — whatever your pipeline already produces.
- **Row-in / row-out.** Results return in input order, each tagged with its 0-based index.
- **Fault-isolated.** A row that errors is reported with status:"error" in place and never aborts the batch.
- **Up to 1000 rows.** One call handles up to 1000 queries; larger batches return 413.

### Body

Send one of: a JSON array of Query objects, a JSON envelope, or a text/csv body.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `queries` | `Query[]` | required | The per-row queries (envelope form). Or post a bare JSON array, or a CSV body. |
| `jurisdictions / freshness / match / recordTypes / locationScoped / include` | `various` | optional | Batch-wide options in the envelope form — applied to every row. |

### Request

**cURL**

```bash
# JSON envelope: batch-wide options apply to every row
curl https://api.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "queries": [
      { "firstName": "John", "lastName": "Smith", "state": "FL" },
      { "firstName": "Jane", "lastName": "Doe",   "state": "TX" }
    ],
    "freshness": "daily",
    "match": "balanced"
  }'

# …or upload a CSV whose header row names Query fields
curl https://api.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary $'firstName,lastName,state\nJohn,Smith,FL\nJane,Doe,TX'
```

**Node**

```javascript
const res = await fetch("https://api.offendersearch.app/v1/batch", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    queries: [
      { firstName: "John", lastName: "Smith", state: "FL" },
      { firstName: "Jane", lastName: "Doe", state: "TX" },
    ],
    freshness: "daily",
    match: "balanced",
  }),
});

const { count, results } = await res.json();
// results come back in input order; a failed row is status:"error", never aborts the batch
for (const r of results) console.log(r.index, r.status, r.counts?.records ?? 0);
```

**Python**

```python
import os, requests

resp = requests.post(
    "https://api.offendersearch.app/v1/batch",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    json={
        "queries": [
            {"firstName": "John", "lastName": "Smith", "state": "FL"},
            {"firstName": "Jane", "lastName": "Doe", "state": "TX"},
        ],
        "freshness": "daily",
        "match": "balanced",
    },
)
data = resp.json()
print(data["count"], "rows")
for r in data["results"]:
    print(r["index"], r["status"], r.get("counts", {}).get("records", 0))
```

### Response

```json
{
  "count": 2,
  "results": [
    { "index": 0, "status": "complete",
      "counts": { "records": 1, "sourcesQueried": 3 },
      "records": [ /* … scored, de-duplicated records … */ ] },
    { "index": 1, "status": "complete",
      "counts": { "records": 0, "sourcesQueried": 3 }, "records": [] }
  ]
}
```

Results are in input order. A failed row is { "index": n, "status": "error", "error": "…" } in place. Returns 413 if the batch exceeds 1000 rows.

## The CSV form

Post a `text/csv` body whose header row names `Query` fields — `firstName`, `lastName`, `state`, `dob`, `city`, `zipcode`, `address`, `age`, `q`. Every row is a query, and the response is the same JSON envelope as the JSON form.

```bash
curl https://api.offendersearch.app/v1/batch \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @roster.csv

# roster.csv
# firstName,lastName,state,dob
# John,Smith,FL,1980-04-12
# Jane,Doe,TX,
```

```json
{
  "count": 2,
  "results": [
    { "index": 0, "status": "complete",
      "counts": { "records": 1, "sourcesQueried": 58,
                  "sourcesComplete": 58, "sourcesIncomplete": 0 },
      "records": [] },
    { "index": 1, "status": "partial",
      "counts": { "records": 0, "sourcesQueried": 58,
                  "sourcesComplete": 57, "sourcesIncomplete": 1 },
      "records": [] }
  ]
}
```

**Read each row’s own counts.** Row-level completeness is reported per row, exactly as it is on a single search — row 1 above returned no records, but one jurisdiction did not complete, so it is *not determined* rather than *no match*.

## Batch or async?

|  | Batch | Async |
| --- | --- | --- |
| Unit | Many queries, one call | One query, collected later |
| Delivery | In the response, in input order | Poll or signed webhook |
| Ceiling | 1000 rows per call | No request-timeout ceiling |
| Use it for | Rosters, CSV reconciliation, scheduled re-screens | Very broad single searches, unattended jobs |

---

# The Record object

> One normalized 76-field schema across 58 jurisdictions: identity, addresses, offense, stateData, flags, images and per-source provenance. Every field explained.

HTML: https://offendersearch.app/docs/record-object · Markdown: https://offendersearch.app/docs/record-object.md

## One schema across 58 jurisdictions

Every match in a search response is a normalized Record — a 76-field superset spanning the top-level record, `name`, `addresses[]`, `offenses[]`, the 34-field extensive `stateData` block, and per-source provenance. It is a strict superset of the legacy record shape plus scoring, verification, and freshness fields. The same schema is returned by `/v1/search` and by `/v1/records/{id}`, so one type definition covers the whole API.

## sources[] — which jurisdictions is this person on?

A person can appear on more than one registry: registered in one state, working in another, and listed federally as well. They come back as one record, not three — and `sources[]` names every jurisdiction that record was built from, one entry per jurisdiction, de-duplicated.

| Key | What it holds |
| --- | --- |
| `jurisdiction` | The jurisdiction’s two-letter code — the same vocabulary `query.state` and `jurisdictions` accept. |
| `registryName` | The registry’s own name, in full, as that jurisdiction publishes it. This is the name to show a user or print in a report — the specimen values in the payloads below are placeholders, not the strings any particular registry returns. |
| `recordUrl` | That jurisdiction’s own link to this registrant. `""` where the jurisdiction publishes no addressable page per registrant. |
| `scrapedAt` / `lastCheckedAt` | When this jurisdiction’s copy of the record was first recorded, and when it was last confirmed. |
| `sourceUpdatedAt` | The date the jurisdiction itself states it last changed the record; `null` where none is published. |

**`sources[].recordUrl`, paired with its `jurisdiction`, is the identifier to store.** It is the jurisdiction’s own permanent address for that person, it does not change when your query scope changes, and it is the link you would cite in an audit file. `recordId` is not that identifier — it is derived from the merge, so the same person can carry a different one on a state-scoped search and a nationwide search.

## Reading empty values

**No key is ever omitted.** Every top-level key is present on every record, on every endpoint, in every mode — so you test the value, never for key existence. There are three empty values and they mean different things:

| Value | Meaning |
| --- | --- |
| `""` | No value is held for this field on this record. That is a property of the jurisdiction — either it publishes no such field, or it publishes one and this registrant has none recorded. |
| `null` | Not known or not applicable; the precise meaning is per-field. On `flags.absconder` / `flags.predator` it means the jurisdiction does not publish it — which is not `false`. |
| `[]` | No items of this kind are held (no aliases, no addresses) — same reading as `""`, plus a third case on the array fields. |

Read `""` as “no value on file for this field”, never as a statement about the person. Jurisdictions differ in which fields they publish at all, so `marks: ""` means either that the jurisdiction publishes no such field, or that it publishes one and this registrant has none recorded.

**The array fields carry a third case: the jurisdiction does not publish that category for anybody.** `stateData.vehicles`, `stateData.professionalLicenses` and the non-residence entries of `addresses[]` return `[]` when the registrant genuinely has none *and* when that jurisdiction never publishes the category. Read `[]` as “none known”, never as “none exists”. Vehicles are published by 22 of 56 jurisdictions.

**The one exception — not requested rather than not published.** `stateData` (`null`) and `offenses` (`[]`) are omitted from the response unless you send `include: ["stateData"]`. Their empty values then say nothing about what the jurisdiction holds. `images` is **not** one of them — photos are returned on every response, so an empty `images` array does mean no photo is held.

## Types and formats to key your parser on

`age` is a **string**, not a number. `addresses[].type` is an **open vocabulary** — beyond `residence`, `employment` and `school` you will also see `last_known` (Minnesota) and `other`, so always have a default branch. Every date field is **ISO-8601**.

**Date format, as of the 2026-08-04 contract revision.** The four `offense` date fields and the five `stateData` ones are ISO-8601. A partial date stays partial — a jurisdiction that publishes only a month or a year gives you `"1998-11"` or `"1998"`, and the companion `datePrecision` object names the precision for every date key. Nothing is discarded: where a jurisdiction publishes something that is not a date, the field is `""` and its literal text is kept in `datesAsPublished`.

**`dob` shape, as of the 2026-08-06 contract revision.** `dob` is guaranteed to be a full `YYYY-MM-DD` date or `null`. Where a jurisdiction publishes only a birth year, `dob` is `null` and the year is in the integer field `birthYear`. If you have persisted `-01-01` birthdays sourced from this API, re-fetch those records: the ones whose `dobPrecision` is `"year"` now return `dob: null` with the year in `birthYear`, and the ones whose `dobPrecision` is `"exact"` are real 1 January birthdays and are unchanged.

There is no date filter on the `offense` or `stateData` dates — `dob` is the one date you can narrow a query on, alongside the `createdAt*` / `updatedAt*` provenance ranges.

## Field reference

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recordId` | `string` | optional | THE HANDLE FOR THIS RESULT — NOT A STABLE PRIMARY KEY. DO NOT STORE IT AS ONE. It addresses GET /v1/records/{recordId} and it is deterministic for a given merge, but it is DERIVED FROM THE MERGE: it is computed from the set of registry rows that were combined into this person on THIS query, so it changes when the query scope changes and it can change between releases. The same man searched with state: "KY" and searched nationally is one person and two recordIds, because the national search merges in a second registry's row for him and the merged identity is a different identity. Keying a diff on recordId therefore reports people as MISSING who were returned both times, under a different id. THE STABLE KEY IS source.recordUrl / sources[].recordUrl — the registry's own permanent URL for that person on its own site. Store that. Match on it. It does not move with query scope, it does not move with a release, and it is the identifier the registry itself will still honour next year. TWO LIMITS ON THAT, BOTH PROPERTIES OF THE REGISTRY, AND YOU SHOULD HANDLE BOTH: recordUrl is "" where the jurisdiction publishes no addressable page per registrant; and on Florida, California, Georgia, Missouri and Oregon (partly on Oklahoma, Maine, Virginia and Arizona) it is the registry's own search or landing page rather than that registrant's page — a valid citation, but shared by many people, so it is not a key on its own. For both, key on jurisdiction + name + dob + registrationState instead. Read sources[] rather than source alone, because the person may be corroborated by a registry whose URL IS per-registrant even when the primary one is not. |
| `uuid` | `string` | optional | The REGISTRY's own id for this person (parity with a legacy uuid / personUuid) — not ours. "" when the registry exposes no id. Do not confuse with recordId. |
| `recordType` | `enum` | optional | sex_offender today; additive as new types ship. |
| `matchConfidence` | `number` | optional | Our score from 0 to 1. It measures HOW MUCH IDENTITY EVIDENCE WE HOLD, not the probability that this is your person — 0.4 base, +0.3 for a DOB, +0.2 for a full name, +0.1 for corroboration by more than one registry. A widened match is then capped: nickname 0.75, initial/prefix/fuzzy/middle 0.60, alias-only 0.55 (the cap is skipped when you supplied a DOB and the record's full DOB matched). Filter on matchState, not on this. |
| `matchBasis` | `string[]` | optional | MIXES TWO VOCABULARIES. Bare tokens (lastName, firstName, dob) mean only "the record HAS this field populated" — they are NOT match reasons. Qualified tokens (field:strategy) are the real reasons: lastName:exact, lastName:prefix, lastName:fuzzy, firstName:exact/nickname/initial/prefix/fuzzy, firstName:middle (the given name you sent is the person's MIDDLE name), firstName:absent (the record has no first name, so your query could not be disproved), alias:exact/prefix/fuzzy, plus name_match, alias_match, middle_name_match and unverified_no_dob_or_age. Switch only on the qualified tokens and on matchedName.type. |
| `matchedName` | `object \| null` | optional | Which name actually matched your query: { value, type } where type is "legal" (the registered name) or "alias". null when you searched without a name. This is how you tell an alias hit from a registered-name hit at a glance. |
| `name` | `Name` | optional | first, middle, last, suffix, and a pre-formatted full. |
| `aliases` | `string[]` | optional | Known alternate names and spellings, as the registry publishes them — mixed shapes ("SURNAME, GIVEN" and "Given Surname" both occur in one response), so never parse positionally. [] carries two facts and the response does not label which: either the registry publishes no alias field, or it publishes one and this person has none on file. Both are properties of the source, and neither is evidence that the person uses no other name. How often a registry publishes aliases is its own decision and the spread is wide — KY 99%, AK/NE 89%, ID 46%, SD 44%, and WV 2% because the West Virginia registry publishes no alias field and the only alternate names it publishes appear within the offence narrative. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §5. |
| `nicknames` | `string[]` | optional | Output-only, DERIVED BY US — never sourced from the registry, so never present it as data on file. Nickname/variant expansion of name.first, for name-search recall: "Robert" → ["Bob","Bobby","Rob","Robbie","Robby"], "John" → ["Jack","Johnnie","Johnny","Jon"], "Zebediah" → []. Parity with a legacy firstName_nicknames field. |
| `dob` | `date \| null` | optional | A FULL ISO-8601 CALENDAR DATE (YYYY-MM-DD) OR null — never anything else. Never a bare year, never a partial date, and never a month and day we chose for you. It is safe to hand straight to a date parser, and null is the only other value it can take. null means we cannot give you a complete date for this person, which happens two ways, and dobPrecision tells you which: the registry publishes only a birth YEAR (dobPrecision "year" — the year is in birthYear), or it publishes nothing finer than an age (dobPrecision "unknown" — read age). ★ A null dob DOES NOT COST YOU THE RECORD ON A DOB SEARCH. A year-only record still matches any date you query inside that year and comes back as matchState "year_match"; an age-only record still matches a date consistent with its published age and comes back as "age_match". AZ, MD and WI publish an age and no date on any surface — that null is final. California publishes an exact date of birth for effectively every registrant, so a rare California null is an individual gap and not a statement about what the state publishes. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §6.1. |
| `birthYear` | `integer \| null` | optional | THE YEAR THE REGISTRANT WAS BORN, AS AN INTEGER (1961), WHENEVER WE KNOW IT. Read this — not dob — for anything that reasons about a year, so your code never has to branch on which kind of record it is holding. It is populated in both directions: when the registry published a full date, birthYear is that date's year and dob carries the date; when the registry published only a year, birthYear carries the year and dob is null. null means the birth year genuinely is not known to us. IT IS NEVER INFERRED FROM AN AGE, and that is deliberate: an age is not a date, deriving a year from one would bake in the day we read the page and would be wrong the moment the person has a birthday, and this field is served as fact rather than as an estimate. So an age-only record carries age and birthYear null — and it is still reachable by a name + dob search, because that widening happens when we match your query rather than when we store the record, and it is labelled matchState "age_match" so you can see it happened. To distinguish a year the registry PUBLISHED from a year we read off a published date, read dobPrecision, not the presence of dob. |
| `dobPrecision` | `enum` | optional | HOW MUCH OF THE BIRTH DATE THE REGISTRY ACTUALLY PUBLISHED: exact · year · unknown. "exact" means the registry published a full calendar date and dob carries it. "year" means the registry publishes ONLY a birth year (MA, GA, ND, NV, PA, and sometimes KS): dob is null — we do not manufacture a month and a day to fill it — and the year is in birthYear. "unknown" means dob and birthYear are both null and the registry published nothing finer than an age, if that; read age. READ THIS FIELD RATHER THAN INSPECTING dob: it is the authoritative statement of what the source published, and it is what separates a year the registry printed from a year we read off a full date it printed. ("year_month" is reserved in the contract; no registry emits it.) The four date fields inside offense, and the five inside stateData, carry their own separate datePrecision object — dobPrecision governs the date of birth only. |
| `age` | `string` | optional | Age in years as a STRING ("58"), not a number. "" when not published. Many registries publish age instead of a DOB. |
| `sex / race / ethnicity` | `string` | optional | Demographics as the registry publishes them — free text, NOT normalized ("Male", "M", "W"). "" when we hold no value. READ race AND ethnicity TOGETHER: several registries, California among them, publish ONE column covering both concepts, and we route Hispanic values to ethnicity and everything else to race — so for those states the two keys are mutually exclusive and reading race alone silently drops every Hispanic registrant. California: race 51%, ethnicity 48%, either 99.4%. Compute coverage on (race \|\| ethnicity). California's race vocabulary is the state's own and unusually fine-grained (17 values, including "Filipino", "Samoan", "Guamanian"), so do not map it onto a five-bucket scheme without deciding what each becomes. MAINE AND THE DISTRICT OF COLUMBIA NEED ONE SENTENCE OF THEIR OWN. Neither jurisdiction publishes a sex or a race. Their sex comes from the jurisdiction's OWN federal (NSOPW) feed, which is why it reads "M"/"F"/"U" rather than "Male"/"Female", and it is on nearly every ME/DC record. Their race is "" on every record and will stay that way: neither jurisdiction publishes it, and there is no ethnicity to fall back to — so a ME/DC record with a sex and an empty race is final. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §6.3. |
| `height / weight / eyeColor / hairColor` | `string` | optional | Physical description as published. No unit is attached and formats vary wildly ("5'09\"", "509", "165lbs"). "" when not published. |
| `marks` | `string` | optional | Scars, marks and tattoos as recorded by the registry — free text, no vocabulary, no fixed separator, often several hundred characters ("Tattooed Arm, left upper; Scar on Chest; Pierced ear, left"). "" carries two facts and the response does not label which: either the registry publishes no such field at all, or it publishes one and this person has none on file. Both are properties of the source, and neither is evidence that a person has no tattoos. NOT PUBLISHED by KY, NE, WV, ID, SD, AK, TX, IL, VA, GA, MA and ME. Maine is the statutory case: its registry publishes NO physical description at all — no marks, height, weight, eye or hair colour — because state law releases those only against a written request naming the requester. Around 28 other states do publish the field, each at its own rate — MO 85% down to MT 22%. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §6.5. |
| `addresses` | `Address[]` | optional | Each with type, line1, city, county, state, zipcode, and nullable lat/lng (null = not geocoded, which is not the same as no address). type is an OPEN vocabulary: residence · employment · school · last_known (MN) · other. [] when no address is published. line1 IS NOT ALWAYS A STREET: the District of Columbia publishes block-level addresses by design ("2100 BLOCK OF NEW HAMPSHIRE AVENUE NW") and has no house numbers on any surface, and employment/school rows in several states (Maine among them) carry the employer name folded in ahead of the street ("KENT PACKERS, 51 KENT RD") because the registry publishes the name too and there is no separate key for it — split on the first comma if you need the street alone. Maine is a special case of its own: it publishes a residence street only for registrants inside its tier scheme, and the town and nothing finer for everyone else, so the majority of Maine records carry city + state with an empty line1 and no request will produce more. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §7. |
| `registrationState` | `string` | optional | WHICH REGISTRY HOLDS THIS PERSON — the registering state or territory. It is NOT where they live, and there is deliberately no single field called "state", because the two answers disagree constantly: of the 846,485 records held by a two-letter state registry, 105,028 (12.4%) have NO address in the state whose registry holds them, and 8,564 more carry both a home-state and an out-of-state address. 36,525 of Florida's 92,690 registrants have no Florida address on file. The value is in exactly the vocabulary query.state accepts, so you can send it straight back as a filter and find this record again. "" ON 35,153 RECORDS (2.34%) AND THAT IS NOT AN ERROR: 29,097 come from NSOPW where the federal feed named no member registry, and 6,056 come from 103 TRIBAL registries — a tribe is its own registering authority and we do not map one onto the state it sits inside, because that would assert a containment we have not verified. source.jurisdiction is populated on 100% of records and carries the registry's own code in those cases. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §7.5. |
| `addressStates` | `string[]` | optional | WHERE THEIR ADDRESSES ARE — every distinct state appearing in addresses[], first-seen order, de-duplicated. THE PLURAL IS LOAD-BEARING: 23,058 records carry addresses in two or more states (22,626 in two, 432 in three or more), because residence, employment and school addresses are published independently by the registry and need not agree. The array does NOT say which kind of address contributed each state — addressStates[0] is not "where they live", exactly as addresses[0] is not. Read addresses[] and filter on type when you need the residence specifically. [] MEANS NO ADDRESS ON FILE CARRIES A STATE — 70,973 records (4.72%). It does not mean the person has no address and it does not mean we did not look: the registry published none we could parse. Those records are still returned by a state search, via registrationState, which is the only thing that reaches them. Values are canonicalised like registrationState, with one exception: a token we do not recognise is passed through upper-cased rather than dropped, because registries publish real non-state codes here — Florida writes "YY" for out-of-country on 5,005 addresses. Per-jurisdiction detail in docs/FIELD-DICTIONARY.md §7.5. |
| `offense` | `Offense` | optional | Primary offense — ALWAYS an object, never null; all 16 fields are "" (or null for federal) when nothing is published. Survives a light call, unlike offenses[]. Its four date fields (convictionDate, offenseDate, registrationDate, releaseDate) are ISO-8601 — BREAKING CHANGE 2026-08-04: they used to be the registry's own string passed through, so one response could carry "2003-03-31", "10/11/1988" and "Aug. 10, 1987" in the same key (offenseDate was ISO on only 45.4% of populated values). A PARTIAL DATE STAYS PARTIAL: a registry publishing only a month or a year yields "1998-11" or "1998" and we never invent a day — read the companion datePrecision object (exact \| year_month \| year \| none \| unparseable), one entry per date key, always present. NOTHING IS DISCARDED: a value that is not a date at all comes back "" with precision "unparseable" and the registry's literal text preserved in datesAsPublished, which also carries the original for any date we reformatted. offenseDate is WHEN THE CRIME HAPPENED and is a different fact from convictionDate — the two can be years apart, so never substitute one for the other (TN publishes only the offence date and no conviction date at all). convictionCounty/convictionCity are the COURT's county and city, not where the person lives — jurisdiction still carries the conviction STATE. convictionCount is how many convictions that one offence ROW stands for (MA prints it as "No. of Convictions"): "" does not mean one. federal is THREE-STATE — true/false when the registry publishes a federal-vs-state column (Oklahoma only today), null when it does not, so never coerce it with a falsy check. ★ riskLevel AND tier ARE RISK CLASSIFICATIONS AND NOTHING ELSE — the registry's assessment of the danger the person is judged to present ("Sexually Violent Predator", "Level 3", "Tier II"), in each state's own vocabulary, with no cross-state scale. THEY DO NOT CARRY REGISTRATION STATUS. Whether someone is currently confined, absconded, deported, deceased or living in the community is a different question with a different answer, and it is stateData.status — which needs include: ["stateData"]. Florida is the state where this matters most, because FDLE publishes a status on every registrant and no risk level at all: on a Florida record riskLevel is "" and the words you want ("Confinement", "Released - Subject to Registration", "Absconded", "Deceased", …) are in stateData.status. A "" riskLevel means the registry publishes no risk classification, never that the person is low risk. THERE IS NO DATE FILTER ON THESE FIELDS: dob is the one date you can narrow a query on. |
| `offenses` | `Offense[]` | optional | The full offense list. [] UNLESS you send include: ["stateData"] — an empty array on a light call says nothing about what the registry holds. |
| `stateData` | `StateData \| null` | optional | The full 34-field extensive superset: stateOffenderId, status, designation, registrationStarts, registrationEnds, sentenceCompletionDate, verificationRequirement, lawAgency, judgmentOfConvictionUrl, vehicles[], photos[], professionalLicenses[], shoeSize, shoeWidth, build, complianceStatus, isLifetimeRegistration, lastVerificationDate, addressVerificationDate, incarcerationStatus, comments, criminalHistory, adjudication, registrationDuration, skinTone, residencyRestriction, employmentRestriction, exclusionZones[], district, psa, quadrant, birthCity, birthState, birthCountry. null UNLESS you send include: ["stateData"] — a null here on a light call says nothing about what the registry holds. HOW TO GET IT: add "include": ["stateData"] alongside "query" in the POST body ({"query": {…}, "include": ["stateData"]}); it is a per-request parameter available on every key, it brings offenses[] with it, and it is the only way this object is populated. ★ status IS THE FIELD MOST PEOPLE ARE LOOKING FOR AND IT LIVES ONLY HERE: where the registrant stands with the registry, in the registry's own words, and the answer to "is this person still at liberty?". It is free text and each state has its own vocabulary, so match on the exact strings the state you care about publishes — there is no cross-state enum. Florida (FDLE) publishes nine: "Released - Subject to Registration", "Confinement", "Supervised - FL Dept of Corrections", "Supervised - US Probation", "Supervised - FL Dept of Juvenile Justice", "Deported", "Deceased", "Absconded", "Civil Commitment"; other states use their own ("Compliant", "Non-Compliant", …). Two of those conditions also have fields you can read without string matching: flags.absconder, and incarcerationStatus where the registry publishes it. status is NOT a risk level — see offense.riskLevel. MANY OF THESE ARE SINGLE-STATE and are ""/[] everywhere else, always: skinTone/residencyRestriction/employmentRestriction/exclusionZones are IA-only, district/psa/quadrant are DC-only, professionalLicenses is UT-only, birthCity/birthState are WY-only (Guam fills birthCountry), and registrationStarts/sentenceCompletionDate are OK-only, shoeSize/shoeWidth are TX-only, and build is MN-only. Its five date fields (registrationEnds, lastVerificationDate, addressVerificationDate, sentenceCompletionDate, registrationStarts) are ISO-8601 as of 2026-08-04 — a BREAKING change, and 100% of sentenceCompletionDate/registrationStarts values changed shape because Oklahoma publishes MM-DD-YYYY. They carry the same datePrecision and datesAsPublished companions as offense. WATCH registrationEnds SPECIFICALLY: 32,569 records publish a DURATION there rather than a date — Wisconsin "15 Years"/"Life" (25,218), Oklahoma "Lifetime" (6,609), North Dakota "LIFETIME" (742) — so those come back "" with precision "unparseable" and the word kept in datesAsPublished. An empty registrationEnds on a WI/OK/ND record is NOT "no end date on file"; check datePrecision, then isLifetimeRegistration and registrationDuration. sentenceCompletionDate is the date the SENTENCE completes and is NOT registrationEnds (the registration term, often decades later or "Lifetime") and NOT offense.releaseDate (release from custody); Oklahoma prints future dates there for people still serving, so it is a scheduled/recorded completion date, not proof a sentence was served. |
| `flags` | `object` | optional | THREE-STATE: { absconder, predator } are each true, false, or null. true = the registry affirmatively reports the designation; false = it affirmatively reports the person does NOT have it; null = the registry does not publish it at all. null is NOT false — a falsy check silently merges "confirmed not an absconder" with "we have no idea". |
| `images` | `object[]` | optional | Photo URLs ([{ url }]) served by the publishing jurisdiction. ALWAYS RETURNED: photos are not behind include, and [] genuinely means we hold no photo for this person. images[0] is the CURRENT photo and anything after it is photo HISTORY, newest first. THE ARRAY LENGTH VARIES BY STATE and is not fixed: Oklahoma records carried exactly one element until 2026-08-04 and now carry up to five, and a record merged across two registries carries both registries' photos. Never assume one photo per record. |
| `source` | `SourceRef` | optional | The PRIMARY source (the registry whose data won on merge): jurisdiction, registryName, recordUrl, scrapedAt, lastCheckedAt, sourceUpdatedAt. sourceUpdatedAt is null on most registries — few publish a last-updated date. |
| `sources` | `SourceRef[]` | optional | EVERY REGISTRY THIS PERSON APPEARS ON, in one array, deduplicated so a registry appears once — each entry carrying that registry's name (registryName), its two-letter code (jurisdiction) and its own link to this registrant (recordUrl), plus scrapedAt / lastCheckedAt / sourceUpdatedAt. This is how you answer "which registries is this person on?" without issuing one query per state: a man registered in South Carolina who works in Texas comes back as ONE record whose sources[] names the South Carolina registry, the Texas DPS registry and NSOPW, each with its own link. More than one entry is genuine cross-registry corroboration and it raises matchConfidence. source (singular) is simply whichever of these won the merge and supplied the top-level values; it is always also present in sources[], so read sources[] when you want the whole picture and source when you want the one row the record was built from. ★ sources[].recordUrl — WITH its jurisdiction — IS THE DURABLE KEY FOR A PERSON ON A REGISTRY. It is the registry's own permanent address for them, it does not move when your query scope changes, and it is what you should store and diff on. recordId must not be used for that; see recordId above. |
| `matchState` | `enum \| null` | optional | HOW THIS RECORD WAS CHECKED AGAINST THE DOB OR AGE YOU SUPPLIED — the field to branch your confidence on, rather than matchConfidence. dob_match: the registry holds a full date of birth and it equals yours (the strongest confirmation this API can give). year_match: the registry publishes only a birth YEAR and that year equals your date's year — the day and month were never checked because the source never published them. age_match: no date and no year is published, but the registry's age agrees with the age your date implies, within a year for birthday drift — weaker, and roughly one person in a hundred of that age will coincide. no_dob_age_year: the registry published no date, no year and no age, so nothing could be checked; the record is returned because the NAME matched, and unverified is true. age_mismatch: the record matched on NAME, publishes only an age, and that age CONTRADICTS the date you sent — returned only if you ask for it with onAgeMismatch: "flag", and always unverified, so it can never be read as a confirmed identification. dob_mismatch: the record had a date, year or age and it DISAGREED with yours — these are filtered OUT of records[], so you will not normally see this value; it is documented so you know a true conflict is excluded rather than silently shown. null: you did not supply a dob or an age, so there was nothing to verify against and the value is not meaningful. See Searching by date of birth. |
| `dobVerification` | `enum` | optional | The raw match-state token (backward-compat). matchState is the clean, distinct field to read; see Searching by date of birth. |
| `unverified` | `boolean` | optional | True when the record had no DOB and no age to verify against — shown anyway, flagged. |

## Example record

```json
{
  "recordId": "rec_717a89b01deda9457ced",
  "uuid": "0000000",
  "recordType": "sex_offender",
  "matchConfidence": 1.0,
  "matchBasis": ["lastName", "firstName", "dob", "lastName:exact", "name_match"],
  "matchedName": { "value": "EXAMPLE PLACEHOLDER SURNAME", "type": "legal" },
  "name": {
    "first": "EXAMPLE", "middle": "PLACEHOLDER", "last": "SURNAME",
    "suffix": "", "full": "EXAMPLE PLACEHOLDER SURNAME"
  },
  "aliases": [],
  "nicknames": [],
  "dob": "1968-02-09",
  "birthYear": 1968,
  "dobPrecision": "exact",
  "age": "58",
  "sex": "M",
  "race": "White",
  "ethnicity": "",
  "height": "5'10''",
  "weight": "165lbs",
  "eyeColor": "Hazel",
  "hairColor": "Brown",
  "marks": "Tattoo on L_arm , Scar on (Leg Right)",
  "addresses": [
    {
      "type": "residence", "line1": "1 EXAMPLE DR", "city": "MIDDLETOWN",
      "county": "Butler", "state": "OH", "zipcode": "45042",
      "lat": null, "lng": null
    }
  ],
  "offense": {
    "crime": "Rape",
    "statute": "2907.02",
    "riskLevel": "(Pre AWA) Sexual Predator",
    "tier": "(Pre AWA) Sexual Predator",
    "convictionDate": "1988-10-11",
    "registrationDate": "",
    "releaseDate": "1999-11-18",
    "caseNumber": "",
    "victimAge": "Juvenile",
    "victimSex": "Male",
    "jurisdiction": "OH",
    "offenseDate": "",
    "datePrecision": {
      "convictionDate": "exact",
      "offenseDate": "none",
      "registrationDate": "none",
      "releaseDate": "exact"
    },
    "datesAsPublished": {
      "convictionDate": "10/11/1988",
      "releaseDate": "11/18/1999"
    },
    "convictionCounty": "",
    "convictionCity": "",
    "convictionCount": "",
    "federal": null
  },
  "offenses": [],
  "stateData": null,
  "flags": { "absconder": null, "predator": true },
  "images": [],
  "source": {
    "jurisdiction": "OH",
    "registryName": "State Sex Offender Registry",
    "recordUrl": "https://…",
    "scrapedAt": "2026-08-02T00:00:00Z",
    "lastCheckedAt": "2026-08-02T00:00:00Z",
    "sourceUpdatedAt": null
  },
  "sources": [
    {
      "jurisdiction": "OH",
      "registryName": "State Sex Offender Registry",
      "recordUrl": "https://…",
      "scrapedAt": "2026-08-02T00:00:00Z",
      "lastCheckedAt": "2026-08-02T00:00:00Z",
      "sourceUpdatedAt": null
    }
  ],
  "dobVerification": "",
  "matchState": null,
  "unverified": false
}
```

What to notice: `ethnicity`, `registrationDate` and `caseNumber` are `""` — Ohio does not publish them. `federal` is `null`, meaning Ohio publishes no federal-vs-state column; only Oklahoma does. `flags.absconder` is `null` (Ohio does not publish it) while `flags.predator` is `true`. `stateData` and `offenses` are empty only because this call omitted `include`; `images` is empty because this record has no photo — `images` is never withheld. `matchState` is `null` because the query supplied no `dob` or `age`. And `birthYear` is `1968` even though the year was never published on its own — it is the year of the full date, so year logic reads one field whatever the jurisdiction published.

## GET /v1/records/{recordId} — Get a record

Fetch a single normalized record by id — for re-displaying or refreshing a specific person you already found.

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

Pass **either** identifier a search published for the person: our `recordId` (`rec_…`) or the registry’s own `uuid`. This is our equivalent of a legacy `uuid` / `personUuid` lookup. Returns the full Record object, including every source that corroborated the person.

Both identifiers resolve against the same durable index, so a lookup is deterministic: the id a search published for a person resolves to that person for as long as the identifier is current.

**A `404` means the identifier is not current — never that the person is unregistered.** A `uuid` belongs to the publishing jurisdiction, and jurisdictions re-issue their own identifiers, sometimes for an entire registry at once; the registrant is then published under a new `uuid`. Re-search by name and state and store the id the new search returns. Treat a `uuid` as a cache key to refresh, not as a primary key.

**Contract revision 2026-08-04 — `recordId` is namespaced by jurisdiction.** `recordId` is derived from jurisdiction + `uuid`, so it is unique across jurisdictions and remains deterministic for a given merge. Values issued before that revision are superseded: re-run your searches and key on `sources[].recordUrl` with its `jurisdiction`, which is the durable identifier.

**A `uuid` is unique only together with its jurisdiction, so this endpoint can return `409`.** It is the jurisdiction’s own identifier, and jurisdictions assign them independently — `20059` identifies a Florida registrant, a Pennsylvania one *and* a Wisconsin one; 55,893 identifiers are shared across jurisdictions, covering 147,624 records. This endpoint returns a single record and does not disambiguate on your behalf: an explicit `409` is the correct contract when an identifier is ambiguous. Use `GET /v1/compat/sexoffender?uuid=…&state=XX`, which returns every match with its jurisdiction, or use `recordId`, which is namespaced per jurisdiction and never collides.

### Path parameters

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recordId` | `string` | required | The recordId (rec_…) or the registry uuid from a search result. Both resolve. |

### Request

**cURL**

```bash
curl https://api.offendersearch.app/v1/records/rec_4b1e \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

**Node**

```javascript
const res = await fetch(
  "https://api.offendersearch.app/v1/records/rec_4b1e",
  { headers: { "X-API-Key": process.env.OFFENDERSEARCH_KEY } },
);
const record = await res.json();
console.log(record.name.full, record.source.jurisdiction);
```

**Python**

```python
import os, requests

resp = requests.get(
    "https://api.offendersearch.app/v1/records/rec_4b1e",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
)
record = resp.json()
print(record["name"]["full"], record["source"]["jurisdiction"])
```

### Response

```json
{
  "recordId": "rec_4b1e",
  "uuid": "b1e4-…",
  "recordType": "sex_offender",
  "name": { "first": "John", "middle": "A", "last": "Doe", "full": "John A. Doe" },
  "dob": "1980-04-12",
  "dobPrecision": "exact",
  "dobVerification": "dob_match",
  "unverified": false,
  "source": {
    "jurisdiction": "NJ",
    "registryName": "State Sex Offender Registry",
    "recordUrl": "https://…",
    "lastCheckedAt": "2026-07-25T09:14:00Z"
  }
}
```

404 when no CURRENT record carries this identifier — re-search by name and state, because the registry may have re-issued it. 409 when the identifier matches records in more than one jurisdiction; the detail names them, and you disambiguate with GET /v1/compat/sexoffender?uuid=…&state=XX or by using the recordId.

---

# Verification reports

> Generate a branded, timestamped PDF of a search you already ran, with a source citation on every record — one consolidated document for your audit file.

HTML: https://offendersearch.app/docs/reports · Markdown: https://offendersearch.app/docs/reports.md

## A defensible artifact for your audit file

A verification report is a branded, timestamped PDF of a search you already ran — showing every matching offender and every field on file (photos included), with a source citation on every record. It is your report of public-record data, clearly labeled as Offendersearch output; it does not reproduce, mirror, or impersonate any government website. It is a separate, callable endpoint any valid key can use — there is no per-key entitlement — billed at $0.02 per document.

Request one by passing the `searchId` of a prior `/v1/search` call — up to 7 days afterward — plus, optionally, the `viewerName` and `viewerEmail` of whoever is viewing it. Because the search call was already billed, the report meters only the +$0.02 PDF. The response is a single consolidated PDF for the whole search, with a source-attribution section and the report id in the `X-Report-Id` header.

The document contains: the search criteria verbatim and when the search ran; every matching record with every field on file, including photos and the labelled `matchState`; a source-attribution section citing the publishing jurisdiction for each record; the viewer identity and stated purpose where you supplied them; the report id; and a legal disclaimer.

**The report is a snapshot of one search, not a live query.** It renders the result set of the `searchId` you name, so the completeness the search reported is the completeness the document reflects.

## POST /v1/report — Verification report

A branded, timestamped PDF report of your FULL search results with a source citation on every record — a defensible artifact for your audit file. One consolidated document per search: your report of public-record data, clearly labeled as Offendersearch output; it does not reproduce, mirror, or impersonate any government website.

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

Pass the `searchId` of a search you already ran — up to **7 days** afterward — and you get back **one consolidated PDF** covering that entire result set, never a separate document per source. A one-shot mode is also supported: send an inline `query` instead of a `searchId` and the endpoint runs the search itself, which meters a search call **as well as** the PDF.

The report captures who viewed it (the optional `viewerName` / `requesterName`) and when, the search criteria, each matching offender, and a **source-attribution** section that cites the source of the data — plus a legal disclaimer. It is available to every account and billed at $0.02 per document; because the underlying search was already billed, the `searchId` path meters only the PDF. Omit the call and there is no extra charge.

The response is `application/pdf`, delivered as a file attachment. The generated report id is returned in the `X-Report-Id` header and printed on the document.

### What you can do

- **One consolidated document.** A single PDF for the whole search — not one document per source.
- **Full source attribution.** Cites the source of every record, with a citation on each.
- **Names the viewer.** viewerName / requesterName, plus optional viewerEmail, purpose and reference, are printed on the report for your audit file. All are optional — the viewer identity is a record, not an access gate.
- **Audit-ready & clearly labeled.** Clearly labeled as Offendersearch output and carries a legal disclaimer; it does not reproduce or impersonate any government site.

### Body

Send a searchId (the normal path), or — in the legacy one-shot mode — every field POST /v1/search accepts (query, jurisdictions, freshness, locationScoped, …). Supply one or the other; neither is a 422.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `searchId` | `string` | optional | The id returned by a prior POST /v1/search or /v1/searches call, up to 7 days old. This is the normal path and it bills only the PDF. A searchId that is unknown, older than 7 days, or belongs to another account is a 404/422. |
| `viewerName` | `string` | optional | Optional. Who is viewing the report; printed at the top of the PDF. requesterName is accepted as an alias. |
| `viewerEmail` | `string` | optional | Optional email of the viewer. Printed on the PDF. |
| `purpose` | `string` | optional | Optional stated purpose for the lookup. Printed on the PDF. |
| `reference` | `string` | optional | Optional caller reference / case id. Printed on the PDF. |

### Request

**cURL**

```bash
curl -X POST https://api.offendersearch.app/v1/report \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -o report.pdf -D - \
  -d '{
    "searchId": "srch_9f2c1a7b3e4d",
    "viewerName": "Jane Doe, ACME HR",
    "purpose": "Volunteer background screening"
  }'
# -o writes the PDF to report.pdf; -D - prints the response headers,
# including X-Report-Id, to stdout.
```

**Node**

```javascript
import { writeFileSync } from "node:fs";

const res = await fetch("https://api.offendersearch.app/v1/report", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    searchId: "srch_9f2c1a7b3e4d",            // id of a search you already ran
    viewerName: "Jane Doe, ACME HR",          // optional — printed on the PDF
    purpose: "Volunteer background screening", // optional
  }),
});

const reportId = res.headers.get("X-Report-Id");
writeFileSync(`${reportId}.pdf`, Buffer.from(await res.arrayBuffer()));
console.log("saved consolidated report", reportId);
```

**Python**

```python
import os, requests

resp = requests.post(
    "https://api.offendersearch.app/v1/report",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    json={
        "searchId": "srch_9f2c1a7b3e4d",              # id of a search you already ran
        "viewerName": "Jane Doe, ACME HR",            # optional — printed on the PDF
        "purpose": "Volunteer background screening",  # optional
    },
)
report_id = resp.headers["X-Report-Id"]
with open(f"{report_id}.pdf", "wb") as f:            # response body is the PDF
    f.write(resp.content)
print("saved consolidated report", report_id)
```

### Response

```json
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="offendersearch-verification-rpt_9f2c1a7b.pdf"
X-Report-Id: rpt_9f2c1a7b

%PDF-1.7 …(binary PDF body: cover, search criteria, matching
records, a source-attribution section, and
the legal disclaimer)…
```

Returns application/pdf as a file attachment; the report id is in the X-Report-Id header. 401 for a missing/invalid key or session; 404 when the searchId is unknown on this account; 422 when neither a searchId nor a query was supplied, when the search is older than the 7-day retention window, or when the query is invalid (e.g. faceId).

---

# 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 · Markdown: https://offendersearch.app/docs/migration.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.
