# Criminal Search API documentation

> National criminal-records search behind one REST API, from the same account and key as the Sex Offender API. One normalized record schema, cached by default with live verification on request, labelled match strength, and neutral jurisdiction codes.

- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed; see https://offendersearch.app/docs/criminal/sandbox.md
- **Authentication:** `X-API-Key` request header on every request except `GET /v1/criminal/sources`
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **HTML documentation:** https://offendersearch.app/docs/criminal
- **Per-section markdown:** https://offendersearch.app/docs/criminal/{section}.md

The standard Criminal Search endpoints are not a consumer report. Their results may not be used for any purpose under the Fair Credit Reporting Act (employment, tenant screening, credit, insurance); the notice — *Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.).* — is present in the `legal` block of every search response. FCRA-regulated criminal searches are available only through FCRA Partner Access — for approved consumer reporting agencies and screening platforms under a written FCRA agreement — as FCRA orders on the FCRA API (https://offendersearch.app/docs/fcra.md).

## Contents

- [Quickstart](https://offendersearch.app/docs/criminal/quickstart.md) — Your first authenticated criminal search, and the five checks to read off every response.
- [Access & keys](https://offendersearch.app/docs/criminal/authentication.md) — The X-API-Key header, automatic access to Criminal Search and live mode, and what a key controls.
- [Errors & status codes](https://offendersearch.app/docs/criminal/errors.md) — Every status code, the stable error envelope, and why a 200 can still be an incomplete live search.
- [Criminal Search API sandbox & test keys](https://offendersearch.app/docs/criminal/sandbox.md) — A test key, the sandbox base URL, and fixed criminal test names, including the exclusion scenarios. Never billed.
- [Search - POST /v1/criminal/search](https://offendersearch.app/docs/criminal/search.md) — The primary endpoint. Cached by default, live verification on request, and every parameter.
- [Filtering criminal searches](https://offendersearch.app/docs/criminal/filtering.md) — Filters, residence vs jurisdiction locations, chargeMatch, the counts that show what was removed, and five worked examples.
- [Matching, confidence & partial names](https://offendersearch.app/docs/criminal/matching.md) — The surname anchor, name strategies, the closed match vocabulary, and how matchConfidence is built.
- [Searching by date of birth](https://offendersearch.app/docs/criminal/date-of-birth.md) — Labelled match strength on every record, and how one dob query reaches date, year and age evidence.
- [Result completeness & source status](https://offendersearch.app/docs/criminal/result-completeness.md) — On a live search, an incomplete answer is labelled - counts, sourceStatus and incompleteReason.
- [Pagination & response size](https://offendersearch.app/docs/criminal/pagination.md) — Top-level page and perPage, a 200-record page cap, and counts.records as the total.
- [Coverage today & the sources endpoint](https://offendersearch.app/docs/criminal/coverage.md) — The public coverage catalog with per-jurisdiction currency - the source of truth, no key required.
- [All data sources](https://offendersearch.app/docs/criminal/data-sources.md) — Every source we cover today and what is coming soon, by state, with the code each record carries.
- [Jurisdictions & codes](https://offendersearch.app/docs/criminal/jurisdictions.md) — The two neutral code shapes, the record-kind vocabulary, and the jurisdictions catalog.
- [Resolve a website to a code](https://offendersearch.app/docs/criminal/resolve.md) — Map a roster or court website you know to the neutral jurisdiction code to search with.
- [Clearances](https://offendersearch.app/docs/criminal/clearances.md) — Dismiss a false-positive criminal match once per subject; a new booking or case in that source brings the person back.
- [Async live jobs & polling](https://offendersearch.app/docs/criminal/async.md) — When a live search goes async, the 202 body, and the poll contract for collecting the result.
- [Batch & CSV search](https://offendersearch.app/docs/criminal/batch.md) — Up to 1000 cached lookups per call, JSON or CSV, row in and row out, with per-row fault isolation.
- [Criminal Search API reference](https://offendersearch.app/docs/criminal/api-reference.md) — Every endpoint and every object, each linked to its request and response fields.
- [The Criminal Record object](https://offendersearch.app/docs/criminal/record-object.md) — One normalized record across every jurisdiction, the dob object, lifecycle, and re-fetch by id.
- [Normalized fields reference](https://offendersearch.app/docs/criminal/normalized-fields.md) — Every outcome, severity, category, NCIC code, custody event and case class, with the source's own text served beside it.
- [Verification reports](https://offendersearch.app/docs/criminal/verification-reports.md) — A timestamped PDF attesting a search was performed, with a neutral citation on every record.
- [Migrating from another provider](https://offendersearch.app/docs/criminal/migration.md) — Moving from a legacy provider: the parameter map, the additive recordType note, and what you gain.

---

# Quickstart

> Make your first criminal-records search in cURL, and learn the five checks to run on every response: status, sourcesIncomplete, warnings, counts, matchState.

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

## Authenticate

Every request carries your secret key in the `X-API-Key` header — the same key you use for the Sex Offender API. Criminal Search is a product on your existing account, not a separate credential.

```bash
export OFFENDERSEARCH_BASE=https://api.offendersearch.app
export OFFENDERSEARCH_KEY="os_live_…"
```

## Your first search

`query.lastName` is required. A nameless query is declined with `422 guard_unbounded_query` rather than answered with an arbitrary slice. `page` and `perPage` are top-level request fields, not inside `query`.

```bash
curl -X POST "$OFFENDERSEARCH_BASE/v1/criminal/search" \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "query": { "lastName": "Hamilton", "firstName": "Alex", "state": "TX" },
        "page": 1, "perPage": 50
      }'
```

The default is the **cached** answer: it consults no live source, so `status` is `complete` and `sourceStatus` is `[]`.

```json
{
  "status": "complete",
  "counts": { "records": 12, "recordsReturned": 12,
              "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false,
              "sourcesOnFile": 44, "sourcesPending": 27 },
  "warnings": [],
  "page": 1, "perPage": 50, "totalPages": 1,
  "sourceStatus": [],
  "cappedOmittedSources": [],
  "omittedFields": ["sourceData"],
  "records": [ "… up to perPage normalized records, each with a matchState …" ],
  "proof": { "status": "none" },
  "coverage": { "scope": { "state": "TX", "county": null },
                "sourcesOnFile": 44, "sourcesPending": 27, "pendingSources": [ "…" ] },
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "meterEvents": [ { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null } ],
  "legal": { "notice": "Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.)." }
}
```

## Read the response, in this order, every time

1. **`status`** — `"complete"` means the search finished. `"partial"` means at least one **live** source could not be checked to the end. A cached search is always `"complete"`.
2. **`counts.sourcesIncomplete`** — `> 0` means one or more live sources fell short, so an empty result is a **lower bound**, not a confirmed absence. Always `0` on a cached search.
3. **`warnings`** — `[]` means nothing shortened this answer, so `warnings == []` is a valid completeness assertion. An entry begins `INCOMPLETE SEARCH:` and names the sources when a live source fell short.
4. **`counts.records`** — the total matched, before your page slice. `records.length` (and `counts.recordsReturned`) is only the current page.
5. **Per record: `matchState`, `matchConfidence`, `matchBasis`** — did the date of birth verify, and how did the name match?

## Get the per-source annex

There is no separate annex to fetch: every normalized field is already on the record in every response, including the residence `address`, `sentence`, `parole`, `custody`, `priorFelony` and `veteranStatus`. `omittedFields` reads `["sourceData"]` by default and `[]` when you send `include: ["sourceData"]`; the records are identical either way. Every field: [the CriminalRecord object](https://offendersearch.app/docs/criminal/record-object#object-criminalrecord).

## Where to go next

- [Search](https://offendersearch.app/docs/criminal/search.md) — every parameter, both modes, and worked examples.
- [Matching & confidence](https://offendersearch.app/docs/criminal/matching.md) — the required surname anchor and the confidence arithmetic.
- [Date of birth](https://offendersearch.app/docs/criminal/date-of-birth.md) — how one `dob` query reaches date, year and age evidence.
- [Completeness & source status](https://offendersearch.app/docs/criminal/result-completeness.md) — what an empty live result does not mean.
- [Coverage today](https://offendersearch.app/docs/criminal/coverage.md) — the public source of truth for what is covered right now.

## Test the criminal search API in the sandbox

Run POST /v1/criminal/search against the sandbox with a test key (os_test_…): fixed test names return a clear record, a felony and a misdemeanor conviction, an arrest, a warrant, a person in custody and possible matches, the same every time and never billed.

- **Sandbox base URL:** `https://sandbox.offendersearch.app`. Not `https://api.offendersearch.app`, which takes live keys only.
- **Test key:** in the dashboard, Developers → API keys (https://offendersearch.app/dashboard/keys) → **Create test key**. Test keys begin `os_test_`; send yours in the `X-API-Key` header.

POST /v1/criminal/search

```bash
curl -X POST "https://sandbox.offendersearch.app/v1/criminal/search" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "query": {
        "firstName": "Felony",
        "lastName": "Record",
        "dob": "1975-06-15"
      }
    }'
```

- Every test name: [Criminal search test names](https://offendersearch.app/docs/criminal/sandbox#criminal-test-names)
- Postman collection: https://offendersearch.app/downloads/offendersearch-sandbox.postman_collection.json
- Criminal Search API sandbox: https://offendersearch.app/docs/criminal/sandbox.md
- Run any scenario from the browser: the HTML page's "Try it here" explorer (sandbox host and `os_test_` keys only, never billed).

---

# Access & keys

> Authenticate with the X-API-Key header on every criminal call. Criminal Search and live mode are available on every account automatically — no entitlement to enable; the sources endpoint needs no key at all.

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

## The X-API-Key header

Authenticate every `/v1/criminal/*` request with your secret key in the `X-API-Key` header. Keys are created, named, rotated and revoked from the dashboard, and a key's secret is shown in full only once, at creation. It is the **same** key you already use for the Sex Offender API.

```bash
curl "https://api.offendersearch.app/v1/criminal/jurisdictions?state=TX" \
  -H "X-API-Key: os_live_…"
```

A missing or invalid key returns `401`; an account whose free trial is used up, or that has no active subscription, returns `402`. The gateway answers both before Criminal Search runs, so the body is the flat `{"detail": "..."}` sentence, not the nested criminal error.

## Criminal Search and live verification are automatic

- **Criminal Search is available on every account** — there is no product to enable and no entitlement to request, and no `403`. Any valid key on an account with an active trial or subscription can call every `/v1/criminal/*` endpoint immediately.
- **Live verification** is included too: a `live` block works from any account with no separate add-on. You pay per live source that completes (see billing) — but nothing is gated behind an entitlement.

The one endpoint that needs **no key at all** is `GET /v1/criminal/sources` — so you can audit coverage before you even sign up.

## What a key controls

An API key carries **no** per-key feature flags of its own — it simply authenticates you. Every option in these docs — `nameStrategy`, `include`, `live`, `prefixMatch` — is a per-request parameter available to any key on any account.

**Rotation.** Issue a second key, deploy it, then revoke the first — both are valid at once, so rotation needs no downtime window. Each key can be revoked on its own, so issue one key per environment or service.

## Legal

The standard Criminal Search endpoints are not a consumer report. You may not use their results for employment, tenant screening, credit, insurance, or any other FCRA-covered purpose. The notice — *Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.).* — is present in the `legal` block of every search response, every batch row that ran and every finished async job, and there is no request parameter that removes it. FCRA-regulated criminal searches run as FCRA orders, only under FCRA Partner Access — see the [FCRA API](https://offendersearch.app/docs/fcra.md).

---

# Errors & status codes

> Every status code the Criminal Search API returns, the stable error.code envelope you switch on, and why a 200 can still describe an incomplete live search.

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

## The error shape

Criminal endpoints return errors as a single, consistent nested object. `code` is a **stable machine token**; the wording of `message` can change. **Branch on `code`, render `message`.** The two exceptions are `401` and `402`: the gateway answers those before Criminal Search runs, in the flat `{"detail": "..."}` shape the Sex Offender API uses.

```json
{ "error": { "code": "guard_unbounded_query",
             "message": "a lastName predicate is required — an unbounded query returns a lottery, not an answer" } }
```

A `200` can still describe an **incomplete live search**: read `status` and `counts`, never `records` alone.

## Status codes

| HTTP | `code` | When you will see it |
| --- | --- | --- |
| `200` | — | A cached search, or a synchronous live search, completed. |
| `202` | — | A live search that runs as an async job — more than two sources, or any source that cannot answer in-request; poll `GET /v1/criminal/searches/{searchId}`. |
| `400` | `no_report_input` | A report request with neither a `searchId` nor an inline `query`. |
| `401` | — (`detail`) | The `X-API-Key` header is missing or not a valid key. Body: `{"detail": "Provide a valid X-API-Key or session token."}`. |
| `402` | — (`detail`) | The free trial is used up, or the account has no active subscription. Same flat `detail` body; the sentence says which. |
| `404` | `not_found` | No search or record carries the id you asked for, or it belongs to another account. |
| `413` | `batch_too_large` | A batch of more than 1000 rows. |
| `422` | `guard_unbounded_query` | `query.lastName` is missing — an unbounded query is declined rather than answered with a lottery. |
| `422` | `prefix_too_short` | An explicit name-prefix search below the three-character floor. |
| `422` | `invalid_dob` | `query.dob` is not a `YYYY-MM-DD` date. |
| `422` | `invalid_page` | `page` is below 1, or `perPage` is not an integer from 1 to 200. In a batch, that row comes back as an error row instead. |
| `422` | `invalid_body` | The request body is not a JSON object — not JSON at all, or a JSON array, string or number. |
| `422` | `invalid_query` / `invalid_live` | `query` is not an object, a `query` field that takes text (a name, `state`, `county`, `city`, `zip`, `address`) carries a list, object or boolean, or `live` is something other than an object. |
| `422` | `no_live_sources` | A `live` block that named nothing to check: an empty `jurisdictions` list, or `scope: "matched"` when the cached search found no records — and no website was sent. |
| `422` | `no_website` | A `resolve` call with neither `website` nor `websites`. |
| `422` | `query_too_broad` | The name and filters match more stored records than one search can read in full within its time budget. Refused rather than answered partially; `detail` says what to add. |
| `422` | `bad_batch` / `empty_batch` | An unparseable batch body or CSV with no header row, or zero queries in the batch. |
| `503` | `unavailable` | Criminal Search could not be reached. Retry with backoff. |
| `503` | `catalog_unavailable` | `GET /v1/criminal/sources` could not read the source catalog. |
| `504` | `upstream_timeout` | Criminal Search did not answer within 65 seconds, or the connection to it failed. |

> **Rate limiting is a queue first, a refusal second.** The gateway holds a request for up to 45 seconds when the service is busy. When it does refuse — `429` from the gateway or `503` with `{"error":{"code":"retry"}}` from the service — the response carries a `Retry-After` header; wait that long and resend. Nothing was searched and nothing was billed. Keep your own concurrency modest: a cached search over a common surname can return up to 200 records per page and many pages.

## One error, verbatim

```json
// 422 Unprocessable Entity — nameless query declined
{ "error": { "code": "guard_unbounded_query",
             "message": "a lastName predicate is required — an unbounded query returns a lottery, not an answer" } }
```

**`422` is a deliberate contract, not a failure.** A query with nothing to narrow on cannot 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.

## The criminal Error object — API error response fields

The body of every error the criminal service returns: one nested `error` object with a stable `code` to branch on and a `message` to log or show. A `401` or `402` comes from the gateway in the [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror) shape instead.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `error` | `object` |  | The error. Always present on a criminal-service error. |
| `error.code` | `string` — one of `"no_report_input"`, `"not_found"`, `"batch_too_large"`, `"guard_unbounded_query"`, `"prefix_too_short"`, `"no_live_sources"`, `"no_website"`, `"bad_batch"`, `"empty_batch"`, `"query_too_broad"`, `"invalid_dob"`, `"invalid_page"`, `"invalid_body"`, `"invalid_query"`, `"invalid_live"`, `"invalid_filter_value"`, `"invalid_date_range"`, `"invalid_location"`, `"too_many_locations"`, `"ambiguous_location"`, `"radius_too_large"`, `"filters_with_async_live"`, `"invalid_group_by"`, `"invalid_parameter"`, `"stats_internal_only"`, `"unavailable"`, `"catalog_unavailable"`, `"filters_unavailable"`, `"stats_unavailable"`, `"retry"`, `"upstream_timeout"` |  | Stable machine token. Branch on this, never on `message`. `400 no_report_input`, `403 stats_internal_only`, `404 not_found`, `413 batch_too_large`, `422` for `guard_unbounded_query` · `prefix_too_short` · `invalid_dob` · `invalid_page` · `invalid_body` · `invalid_query` · `invalid_live` · `no_live_sources` · `no_website` · `bad_batch` · `empty_batch` · `query_too_broad` · `invalid_filter_value` · `invalid_date_range` · `invalid_location` · `too_many_locations` · `ambiguous_location` · `radius_too_large` · `filters_with_async_live` · `invalid_group_by` · `invalid_parameter`, `503` for `unavailable` · `catalog_unavailable` · `filters_unavailable` · `stats_unavailable`, `504 upstream_timeout`. Example `"guard_unbounded_query"`. |
| `error.message` | `string` |  | One human-readable sentence saying what was wrong. Its wording can change; log it, do not parse it. Example `"a lastName predicate is required — an unbounded query returns a lottery, not an answer"`. |

## The GatewayError object — 401 and 402 bodies

The body of a `401` (missing or invalid `X-API-Key`) or a `402` (free trial used up, or no active subscription). The gateway answers these before the criminal service runs, so the shape is the Sex Offender API's flat `detail` string.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `detail` | `string` |  | One sentence naming the problem and the next step: send a valid key, add a payment method, or start a subscription. Example `"Provide a valid X-API-Key or session token."`. |

---

# Criminal Search API sandbox & test keys

> Test the Criminal Search API in the sandbox with an os_test_ key: fixed test names for search, batch, records, exclusions, reports and sources. Never billed.

HTML: https://offendersearch.app/docs/criminal/sandbox · Markdown: https://offendersearch.app/docs/criminal/sandbox.md

## Criminal search API test keys: create one in the dashboard

The criminal search sandbox is the Criminal Search API on a separate server: search, batch, stored searches, records, reports, jurisdictions, sources and resolve, with the same paths and response shapes as live. It answers from a fixed set of made-up people, so every request returns the same result every time, and nothing you send is billed.

1. In the dashboard, open [Developers → API keys](https://offendersearch.app/dashboard/keys).
2. Choose **Create test key**. Test keys are for business accounts and need no subscription.
3. Copy the key. Test keys begin `os_test_`. Like a live key, the full secret is shown once, so put it in your secrets store straight away.

One test key works on every API’s sandbox. Each key in the list is labelled **Test · Sandbox** or **Live**, with the base URL it works on. Send it in the same `X-API-Key` header the live API uses. An account holds up to 3 active test keys and can create 10 a day. A revoked test key stops working on the sandbox within a few minutes.

## Criminal search sandbox base URL vs live

|  | Sandbox | Production |
| --- | --- | --- |
| Base URL | `https://sandbox.offendersearch.app` | `https://api.offendersearch.app` |
| Key prefix | `os_test_` | `os_live_` |
| Data | The fixed test people listed below | The criminal records dataset |
| Billing | Never billed, never recorded to usage | Your plan’s prices |
| `sandbox` field | `true` on every response | Absent |
| `X-Offendersearch-Environment` | `sandbox` on every response | Absent |

Each key works on its own server only, so a mixed-up configuration is caught on the first request rather than after a release. A test key sent to the live API, or a live key sent to the sandbox, is refused with `401` and a `detail` message that says so:

401 — a test key sent to the live API

```json
{
  "detail": "This is a test key (os_test_…). Test keys work only on the sandbox at https://sandbox.offendersearch.app, which answers with sample data. To search real records, send a live key (os_live_…) to https://api.offendersearch.app."
}
```

401 — a live key sent to the sandbox

```json
{
  "detail": "This is a live key. The sandbox at https://sandbox.offendersearch.app accepts test keys (os_test_…) only — create one in the dashboard under Developers → API keys. Send live keys to https://api.offendersearch.app."
}
```

A revoked test key gets `401` with: “This test key has been revoked. Create a new test key in the dashboard under Developers → API keys.”

Each test key can send 60 requests a minute and 2,000 a day, and one IP address 120 a minute, across every API on the sandbox. Past a limit the sandbox answers `429` with a `Retry-After` header. See [Sandbox rate limits](https://offendersearch.app/docs/sandbox.md#rate-limits).

## Criminal search test names

Send these to `POST /v1/criminal/search` on the sandbox. They work the same way on `POST /v1/criminal/batch`, and searches and records open with `GET /v1/criminal/searches/{searchId}` and `GET /v1/criminal/records/{recordId}`. Read `recordType` before describing a record: an arrest is not a conviction, and a warrant means the person is sought. Field definitions are on the [criminal Record object](https://offendersearch.app/docs/criminal/record-object.md).

| Send | What comes back |
| --- | --- |
| `firstName: Clear`, `lastName: Record` | **Clear record.** No match: a complete answer with zero records. |
| `firstName: Felony`, `lastName: Record`, `dob: 1975-06-15` | **Felony conviction.** One court case: a felony burglary conviction with case number, court, charges, plea, disposition and sentence. DOB-verified. |
| `firstName: Felony`, `lastName: Record` | **Felony conviction, no date of birth sent.** The same court case, matched on name alone: matchState no_dob_age_year and unverified true, because no date of birth was sent to check. |
| `firstName: Felony`, `lastName: Record`, `dob: 1990-01-01` | **Felony record name, different date of birth.** No match: the record's date of birth contradicts the one sent. |
| `firstName: Misdemeanor`, `lastName: Record`, `dob: 1988-11-02` | **Misdemeanor conviction.** One court case: a misdemeanor theft conviction with a fine. |
| `firstName: Arrest`, `lastName: Only`, `dob: 1992-04-20` | **Arrest only.** One jail booking with a charge at booking and no disposition. A booking is not a conviction. |
| `firstName: Active`, `lastName: Warrant`, `dob: 1985-09-09` | **Active warrant.** One open warrant (currentStatus active_warrant). |
| `firstName: In`, `lastName: Custody`, `dob: 1979-12-31` | **Currently in custody.** One state prison record: currentlyIncarcerated true, custodyFacility, projected release date. |
| `firstName: Possible`, `lastName: Match` | **Possible matches, name only.** Three records with the same name in three states and no date of birth on any of them: possibles to review, not a confirmed identity. |

POST /v1/criminal/search — sandbox

```bash
curl -X POST "https://sandbox.offendersearch.app/v1/criminal/search" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "query": {
        "firstName": "Felony",
        "lastName": "Record",
        "dob": "1975-06-15"
      }
    }'
```

In a batch, each row gets the same answer it gets from `POST /v1/criminal/search`, in input order.

POST /v1/criminal/batch — sandbox

```bash
curl -X POST "https://sandbox.offendersearch.app/v1/criminal/batch" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '[
      {
        "firstName": "Clear",
        "lastName": "Record"
      },
      {
        "firstName": "Felony",
        "lastName": "Record",
        "dob": "1975-06-15"
      }
    ]'
```

## Criminal search exclusion test names: Excluded, Sanctioned, Clear Provider

These names test exclusion-list records. Send each one with and without `include: ["exclusions"]` to see what a default criminal search returns and what the request adds. The `exclusion` block is defined on the [criminal Record object](https://offendersearch.app/docs/criminal/record-object.md).

| Send | What comes back |
| --- | --- |
| `firstName: Excluded`, `lastName: Provider`, `dob: 1968-03-22` | **Excluded provider, conviction-based.** One exclusion-list record (recordType healthcare_exclusion, currentStatus excluded): a Medicare/Medicaid exclusion under authority 1128(a)(1), a criminal conviction, so it is returned by default. The exclusion block carries the authority, convictionBased true and the exclusion date. DOB-verified. |
| `firstName: Sanctioned`, `lastName: Provider` | **Administrative exclusion, default search.** No records: this person's only entry is an administrative exclusion (a license suspension, not a conviction), which a search returns only when it sends include: ["exclusions"]. |
| `firstName: Sanctioned`, `lastName: Provider`, `include: exclusions` | **Administrative exclusion, include exclusions.** With include: ["exclusions"], the administrative exclusion comes back: convictionBased false, no date of birth on the list, so matchState no_dob_age_year and unverified true. counts.exclusionsIncluded is true. |
| `firstName: Clear`, `lastName: Provider`, `dob: 1980-01-01`, `include: exclusions` | **Clear exclusion check.** No match on any exclusion list, conviction-based or administrative: a complete answer with zero records. |

## Criminal reports, sources and jurisdictions in the sandbox

The rest of the Criminal Search API runs on the sandbox too, with the same paths and shapes as live. The test names above apply wherever a name is searched.

| Send | What comes back |
| --- | --- |
| `GET /v1/criminal/sources (no key), GET /v1/criminal/jurisdictions, POST /v1/criminal/resolve` | **Criminal sources, jurisdictions, resolve.** The production criminal service's own catalogue, jurisdiction list and website resolver, unchanged. |
| `POST /v1/criminal/report {"searchId": "<a sandbox criminal search id>"} (or an inline query)` | **Criminal verification report.** The production criminal report PDF for the sample records. Never billed. |

## Criminal search sandbox examples in cURL, Node and Python

Felony conviction: Felony Record 1975-06-15, called three ways. Export your test key as `OFFENDERSEARCH_TEST_KEY` first.

POST /v1/criminal/search — sandbox — cURL

```bash
curl -X POST "https://sandbox.offendersearch.app/v1/criminal/search" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "query": {
        "firstName": "Felony",
        "lastName": "Record",
        "dob": "1975-06-15"
      }
    }'
```

POST /v1/criminal/search — sandbox — Node

```javascript
// Node 18+ — fetch is built in.
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';

const res = await fetch(`${BASE}/v1/criminal/search`, {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.OFFENDERSEARCH_TEST_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "query": {
      "firstName": "Felony",
      "lastName": "Record",
      "dob": "1975-06-15"
    }
  }),
});
const body = await res.json();

console.log(res.headers.get('X-Offendersearch-Environment')); // "sandbox"
console.log(body.sandbox);                 // true
console.log(body.records[0].recordType);   // read it before describing the record
```

POST /v1/criminal/search — sandbox — Python

```python
import os
import requests

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")

r = requests.post(
    f"{BASE}/v1/criminal/search",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_TEST_KEY"]},
    json={
        "query": {
            "firstName": "Felony",
            "lastName": "Record",
            "dob": "1975-06-15",
        },
    },
    timeout=30,
)
body = r.json()

print(r.headers["X-Offendersearch-Environment"])  # sandbox
print(body["sandbox"])                       # True
print(body["records"][0]["recordType"])
```

## Postman collection for the criminal search sandbox

One collection covers every API’s sandbox, with a folder per API, ready to import into Postman or Insomnia: https://offendersearch.app/downloads/offendersearch-sandbox.postman_collection.json. The **Criminal Search API** folder has one request for every scenario on this page. Set `{{apiKey}}` to your test key; `{{baseUrl}}` is preset to the sandbox. Ids from earlier responses go in `{{criminalSearchId}}`.

## Move criminal search from the sandbox to production

The sandbox and the live API differ only in the base URL and the key. Keep both in configuration, not in code, and going live is a configuration change:

.env

```bash
# Sandbox
OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app
OFFENDERSEARCH_KEY=os_test_…

# Production — change these two values and nothing else
OFFENDERSEARCH_BASE=https://api.offendersearch.app
OFFENDERSEARCH_KEY=os_live_…
```

- Create a live key on the same [Developers → API keys](https://offendersearch.app/dashboard/keys) page.
- Remove any branch that reads `sandbox`. The field does not exist on live.
- Live answers are real people. Read [Matching & confidence](https://offendersearch.app/docs/criminal/matching.md) and [Completeness & source status](https://offendersearch.app/docs/criminal/result-completeness.md) before you decide what your interface shows.
- Standard criminal search results are not a consumer report. For hiring or tenant decisions, use the [FCRA API](https://offendersearch.app/docs/fcra.md).
- Live calls are billed; see [pricing](https://offendersearch.app/pricing).

---

# Search - POST /v1/criminal/search

> The primary endpoint, in two modes: cached by default, live verification when you send a live block. Full parameter reference, sync vs async, worked examples.

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

## Two modes, decided by the request body

There is one search endpoint. Omit `live` for the **cached** answer — one indexed query across the continuously-updated corpus, sub-second, no live source consulted. Send a `live` block for **live verification** — the actual jurisdiction(s) are re-checked in-request and merged with the cached answer through the same matcher.

| Mode | How you ask | What it does | Billing |
| --- | --- | --- | --- |
| Cached *(default)* | omit `live` | One indexed query across the whole corpus. No live source is ever consulted. | one `criminal_call` |
| Live verification | send a `live` block | Re-checks the actual jurisdiction(s) in-request, then merges with the cached answer. | `criminal_call` + `criminal_live_source` per completed source, to a $2.00 ceiling |

## POST /v1/criminal/search

**Authentication:** `X-API-Key` header. No entitlement is required — Criminal Search and the `live` mode are available on every account automatically.

Every body field is in the request and response tables below, and every search filter in the criminal Query object.

## Exclusion lists — include: ["exclusions"]

The HHS-OIG, SAM.gov and state Medicaid exclusion lists are searched in the same call as every other record kind, as exclusion-list records (`recordType: "healthcare_exclusion"`, see the record object). Two tiers exist, told apart by `exclusion.convictionBased`. **Conviction-based exclusions are returned on every search** — an exclusion whose authority is a criminal conviction (HHS-OIG 1128(a)(1)–(4), (c)(3)(G) and (b)(1)–(3), or a SAM or state entry whose stated reason names a conviction) ranks and matches like any other record. **Administrative exclusions are returned on request**: add `"exclusions"` to the top-level `include` array to also receive exclusions whose basis is a license action, an administrative debarment or a loan default; without it that tier is omitted and `counts.exclusionsIncluded` is `false`. The same token works as a batch-wide default on `POST /v1/criminal/batch`.

```bash
curl -X POST "https://api.offendersearch.app/v1/criminal/search" \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
  -d '{ "query": { "lastName": "Hamilton", "firstName": "Alex", "dob": "1989-04-23" },
        "include": ["exclusions"] }'
```

## Live verification — sync vs async

A live check of **≤2 sync-eligible sources** runs synchronously (`200`, inline). More sources, or any source that cannot answer in-request, fans out to an **asynchronous** job (`202` + poll). Most jurisdictions are not sync-eligible, so most live searches return `202` — build your client for it. One public code can also cover several systems; `liveSourcesRequested` in the `202` body reports the true count.

```bash
curl -X POST "https://api.offendersearch.app/v1/criminal/search" \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
  -d '{ "query": { "lastName": "Hamilton", "state": "WA", "dob": "1989-04-23" },
        "live": { "jurisdictions": ["WA-KING"] } }'
```

On a live search, read `counts.sourcesIncomplete` first: when it is `> 0`, an empty result is a lower bound, not a confirmed absence, and `warnings[]` names the sources that fell short.

## The response envelope

Every top-level key is always present — test the value, never for key existence. On a cached search you get every key except `usage` and `resolution` (`sourceStatus` is `[]`, and `coverage` is absent only when the source catalog cannot be read). On a live search you additionally get `usage`, a populated `sourceStatus`, and `resolution` if a website drove the request.

```json
{
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1,
              "sourcesQueried": 1, "sourcesComplete": 1, "sourcesIncomplete": 0, "candidatesCapped": false,
              "exclusionsIncluded": false, "sourcesOnFile": 33, "sourcesPending": 15 },
  "warnings": [],
  "page": 1, "perPage": 50, "totalPages": 1,
  "sourceStatus": [
    { "code": "WA-KING", "source": "WA-KING", "ok": true, "incomplete": false,
      "incompleteReason": null, "records": 1,
      "lastCheckedAt": "2026-08-25T15:04:11+00:00", "live": true }
  ],
  "cappedOmittedSources": [],
  "omittedFields": ["sourceData"],
  "records": [
    { "externalId": "WA-KING:booking:889201",
      "name": { "first": "ALEX", "middle": "Q", "last": "HAMILTON", "suffix": "" },
      "matchConfidence": 0.95, "matchBasis": "lastName:exact firstName:notQueried dob:match",
      "matchState": "dob_match", "unverified": false, "liveChecked": true }
  ],
  "proof": { "status": "none" },
  "coverage": { "scope": { "state": "WA", "county": null },
                "sourcesOnFile": 33, "sourcesPending": 15, "pendingSources": [ "…" ] },
  "usage": { "liveChecks": 1, "liveChargeUsd": 0.02, "capped": false,
             "display": "1 live checks · $0.02" },
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "meterEvents": [
    { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null },
    { "kind": "criminal_live_source", "quantity": 1, "chargeCeilingUsd": 2.0 }
  ],
  "legal": { "notice": "Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.)." }
}
```

## FCRA-regulated use

Results from `POST /v1/criminal/search` are not a consumer report and may not be used for FCRA-covered decisions. Approved consumer reporting agencies and screening platforms under a written FCRA agreement place FCRA orders instead, which apply the federal seven-year rule, state conviction limits and re-verification — see the [FCRA API](https://offendersearch.app/docs/fcra.md).

**Hiring or renting to someone?** FCRA workflows are meant primarily for tenant screening and hiring decisions. If a result could help decide whether a person gets a job or a home, run it through the [FCRA API](https://offendersearch.app/docs/fcra.md), not this endpoint.

## Criminal search API request and response fields

`POST /v1/criminal/search` · auth: `X-API-Key`

Search criminal records for one person. Without a `live` block it returns the cached answer; with one it re-verifies the named jurisdictions at the source during the request. Bills one `criminal_call`, plus $0.02 per completed live source up to $2.00.

### Request

**Body** (application/json)

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `query` | [Query](https://offendersearch.app/docs/criminal/search#object-query) | required | The person to search for. `lastName` is required inside it. |
| `live` | [LiveRequest](https://offendersearch.app/docs/criminal/async#object-liverequest) | optional | Send it to re-verify named jurisdictions at the source during this request. Omit it for the cached answer. |
| `locations` | array of [Location](https://offendersearch.app/docs/criminal/filtering#object-location) | optional | Up to 10 places; a record is kept when it matches any of them, and lists the ones it matched in `matchedLocations`. Send either `locations` or the flat `state`, `county`, `jurisdiction`, `city`, `zipcode` and `address` query keys, not both (`422 ambiguous_location`). See [Locations](https://offendersearch.app/docs/criminal/filtering#locations). |
| `filters` | [SearchFilters](https://offendersearch.app/docs/criminal/filtering#object-searchfilters) | optional | Narrow the matches by record category, severity, outcome, charge, custody status, date or source. Filters are ANDed; values inside one list are ORed. Every record a filter removes is counted in `counts.filteredOut`. See [Filtering](https://offendersearch.app/docs/criminal/filtering). |
| `sort` | `string` — one of `"relevance"`, `"offenseDate"`, `"filedDate"`, `"dispositionDate"` | optional · default `"relevance"` | Result order when `filters` or `locations` are sent. A date sort puts the record's latest date of that kind first and records without one last. A search with neither, and a synchronous live search, keep their usual order. |
| `include` | `array of string` — one of `"sourceData"`, `"coverage"`, `"exclusions"`, `"facets"`, `"normalizedDetail"` | optional · default `[]` | Extra blocks to return. `"coverage"` adds `coverage.sources[]` (every source in scope with its status) when `query.state` is set. `"sourceData"` empties `omittedFields`; records carry the same fields either way. `"exclusions"` also returns [exclusion-list records](https://offendersearch.app/docs/criminal/record-object#exclusions) whose basis is not a criminal conviction (license actions, administrative debarments, loan defaults). Without it, conviction-based exclusions are returned like any other record and the administrative tier is omitted; `counts.exclusionsIncluded` says which. `"facets"` adds `facets` (counts over every matched record) to a search that sends `filters` or `locations`. `"normalizedDetail"` adds `rule` to every charge in `normalized.cases[].charges[]`. |
| `page` | `integer` | optional · default `1` | 1-based page number, at the top level of the body (not inside `query`). Below 1 is refused with `422 invalid_page`. |
| `perPage` | `integer` | optional · default `50` | Records per page, at the top level. An integer from 1 to 200; anything else (0, 201, a non-integer) is refused with `422 invalid_page`. A page never carries more than 200 records. |
| `subjectId` | `string` | optional | Your id for the person being screened — the resident, visitor, applicant or employee id from your own system, 1–128 characters. Sending it turns [clearances](https://offendersearch.app/docs/criminal/clearances) on for this search: a person a reviewer marked `not_this_person` for this subject leaves `records[]` (or is annotated — see `clearanceMode`), the response carries `cleared[]` and `counts.clearedSuppressed`, and a kept record a clearance names carries a `clearance` object. A criminal `personRef` is scoped to one source, so a clearance hides that source's records for the person and no other source's. Omit `subjectId` and the response is exactly what it was before clearances existed. Stored only as a per-account hash, never as the id you sent. Example `"resident-48213"`. |
| `clientId` | `string` | optional | Your id for the facility, tenant or client this subject belongs to, 1–128 characters. A platform that screens for many facilities sends it so that one facility's clearances never reach another facility's results; a direct customer omits it. Meaningful only with `subjectId`. Stored only as a per-account hash. Example `"facility-07"`. |
| `clearanceMode` | `string` — one of `"suppress"`, `"annotate"`, `"ignore"` | optional · default `"suppress"` | What a `not_this_person` clearance does to the record it names. `suppress` (the default whenever `subjectId` is sent) removes the record from `records[]` and lists it in `cleared[]`. `annotate` keeps the record and attaches its `clearance` object, for an interface that shows cleared people greyed out. `ignore` answers as if no clearances existed. A `this_person` verdict, and a cleared person who has re-surfaced (`clearance.staleReason` set), are kept and annotated in every mode. |

### Response

**200** (application/json) — A cached search, or a live search with up to 2 sources that answer quickly, finished.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `status` | `string` — one of `"complete"`, `"partial"` |  | `complete`: the search finished. `partial`: at least one live source could not be searched to the end, so `records` is a lower bound. A cached search is always `complete`. On `partial`, read `counts.sourcesIncomplete` and `sourceStatus[]` before treating an empty `records` as absence. |
| `counts` | [Counts](https://offendersearch.app/docs/criminal/result-completeness#object-counts) |  | Totals for this search. |
| `warnings` | `array of string` |  | One plain sentence per condition that shortened the answer. `[]` when nothing did, which makes `warnings == []` a valid completeness check. The first entry begins `INCOMPLETE SEARCH:` (naming the sources) whenever a live source fell short. A cached search returns `[]`: there is no candidate limit, and a search too broad to read in full is refused with `422 query_too_broad` instead of answered short. |
| `page` | `integer` |  | The page returned. |
| `perPage` | `integer` |  | The `perPage` you sent (1 to 200), echoed back. `totalPages` is computed from it. |
| `totalPages` | `integer` |  | Pages at this `perPage`. `1` when there are no records. |
| `sourceStatus` | array of [SourceStatus](https://offendersearch.app/docs/criminal/result-completeness#object-sourcestatus) |  | One row per live source checked in this request. `[]` on a cached search. |
| `cappedOmittedSources` | `array of string` |  | Always `[]` on this API; present for envelope parity with the Sex Offender API, where it names sources a record cap cut out. Nothing is ever cut here: every matching stored record is read, or the search is refused with `422 query_too_broad`. |
| `omittedFields` | `array of string` — one of `"sourceData"` |  | `["sourceData"]` by default and `[]` when `include: ["sourceData"]` is sent. No record key is withheld either way. |
| `records` | array of [CriminalRecord](https://offendersearch.app/docs/criminal/record-object#object-criminalrecord) |  | The current page of matched records, ranked by `matchConfidence`, then surname, then first name. |
| `proof` | `object` |  | Always `{"status": "none"}` on this API; present for envelope parity with the Sex Offender API. |
| `proof.status` | `string` — one of `"none"` |  | Always `"none"`. |
| `resolution` | array of [ResolutionEntry](https://offendersearch.app/docs/criminal/resolve#object-resolutionentry) |  | Present only when `query.website` or `live.websites` was sent: what each website resolved to. |
| `usage` | [Usage](https://offendersearch.app/docs/criminal/async#object-usage) |  | Present only on a live search: how many live checks completed and what they cost. |
| `searchId` | `string` |  | This search's id, `crs_` + 20 hex. Pass it to [POST /v1/criminal/report](https://offendersearch.app/docs/criminal/verification-reports#reference-post-v1-criminal-report) to render it; it also appears in [GET /v1/criminal/history](https://offendersearch.app/docs/criminal/verification-reports#reference-get-v1-criminal-history). Example `"crs_7f2a1c9e0b4d6a8f1e23"`. |
| `meterEvents` | array of [MeterEvent](https://offendersearch.app/docs/criminal/async#object-meterevent) |  | What this request billed: one `criminal_call`, plus one `criminal_live_source` carrying the completed count on a live search. |
| `legal` | [Legal](https://offendersearch.app/docs/criminal/api-reference#object-legal) |  | The not-a-consumer-report notice. Present on every search response. |
| `coverage` | [SearchCoverage](https://offendersearch.app/docs/criminal/result-completeness#object-searchcoverage) |  | Which sources stand behind this answer for the state or county searched. |
| `cleared` | array of [ClearedEntry](https://offendersearch.app/docs/criminal/clearances#object-clearedentry) |  | Present only when the request carried `subjectId`. One entry per record a `not_this_person` [clearance](https://offendersearch.app/docs/criminal/clearances) removed from `records[]`, so an interface can say "2 previously cleared people hidden" and link each to its clearance. `[]` when nothing was suppressed, including every search in `annotate` or `ignore` mode. |
| `facets` | [SearchFacets](https://offendersearch.app/docs/criminal/filtering#object-searchfacets) |  | Present only when the request sent `include: ["facets"]` with `filters` or `locations`. Counts over every record that passed, not only this page. |
| `appliedFilters` | `object` |  | Present when the request sent `filters`, `locations`, a `sort` other than `relevance`, or `include: ["facets"]`. The filters as validated: every filter in force (a `false` boolean is left out), `locations` with the keys each was read as (`kind`, `state`, `county`, `city`, `zipcode`, `lat`, `lng`, `radiusMiles`), and always `unknown` and `chargeMatch`. Example `{"severity":["felony"],"convictedOnly":true,"lookbackYears":7,"unknown":"keep","chargeMatch":"any"}`. |

**202** (application/json) — A live search with more than 2 sources, or with a source that cannot answer quickly, is running as a job. Poll `poll`.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `searchId` | `string` |  | The job's id, `crs_` + 20 hex. Example `"crs_7f2a1c9e0b4d6a8f1e23"`. |
| `status` | `string` — one of `"pending"` |  | Always `"pending"` on the 202. |
| `mode` | `string` — one of `"async"` |  | Always `"async"`. |
| `liveSourcesRequested` | `integer` |  | How many live sources the job will check. Example `6`. |
| `poll` | `string` |  | Relative URL to poll: [GET /v1/criminal/searches/{searchId}](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid). Example `"/v1/criminal/searches/crs_7f2a1c9e0b4d6a8f1e23"`. |
| `reason` | `string` |  | One sentence saying why the search runs asynchronously. Example `"6 live sources (> 2 or not sync-eligible) — running asynchronously"`. |
| `resolution` | array of [ResolutionEntry](https://offendersearch.app/docs/criminal/resolve#object-resolutionentry) |  | Present only when a website was resolved. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | Missing or invalid `X-API-Key`. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 402 | — | Free trial used up, or no active subscription. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 422 | `guard_unbounded_query` | No `lastName`. |
| 422 | `prefix_too_short` | An explicit prefix shorter than 3 characters. |
| 422 | `invalid_dob` | `dob` is not a `YYYY-MM-DD` date. |
| 422 | `invalid_page` | `page` is below 1, or `perPage` is not an integer from 1 to 200. |
| 422 | `no_live_sources` | A `live` block that resolves to no jurisdiction. |
| 422 | `invalid_filter_value` | A filter value outside its list, a wrong type, an unknown filter key, or a bad `sort`, `unknown` or `chargeMatch`. The message lists the allowed values. |
| 422 | `invalid_date_range` | A date range that is not ISO, not a real date, or has `from` after `to`. |
| 422 | `invalid_location` | A malformed location: an unknown key, no place in it, a bad state or ZIP, a partial radius, or a radius, `city` or `zipcode` on `kind: "jurisdiction"`. |
| 422 | `too_many_locations` | More than 10 `locations`. |
| 422 | `ambiguous_location` | `locations` sent together with the flat `state`, `county`, `jurisdiction`, `city`, `zipcode` or `address` query keys. |
| 422 | `radius_too_large` | `radiusMiles` above 100. |
| 422 | `filters_with_async_live` | `filters` or `locations` on a live search that has to run as a job (more than 2 sources). |
| 422 | `invalid_body` | Body is not a JSON object (or is empty). |
| 422 | `invalid_query` | `query` is not an object. |
| 422 | `invalid_live` | `live` is not an object. |
| 422 | `query_too_broad` | The surname alone would match too many people; add a first name, a date of birth, a birth year or a state. |
| 503 | `retry` | Criminal Search could not run this search just now; nothing was searched or billed. The gateway re-sends for up to 45 s first, so you see this rarely. `Retry-After: 1`. |
| 503 | `unavailable` | Criminal Search could not be reached. Retry with backoff. |
| 503 | `filters_unavailable` | Filtering is switched off for the moment. A filtered search is never answered unfiltered; retry after `Retry-After` seconds, or search without `filters` and `locations`. |
| 504 | `upstream_timeout` | Criminal Search took longer than 65 seconds to answer. Retry, or narrow the query. |

## The criminal Query object — every search filter

The person you are looking for. `lastName` is the only required field; every other field narrows within that surname. Send it as `query` on [POST /v1/criminal/search](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search) and [POST /v1/criminal/report](https://offendersearch.app/docs/criminal/verification-reports#reference-post-v1-criminal-report), or as one row of `queries[]` on [POST /v1/criminal/batch](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch). The fields can also be sent flat at the top level of the body.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`POST /v1/criminal/report`](https://offendersearch.app/docs/criminal/verification-reports#reference-post-v1-criminal-report).

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `lastName` | `string` | required | The surname, case-insensitive. Every search carries it: without it the request is refused with `422 guard_unbounded_query`. A record whose surname neither equals your value nor (under `nameStrategy: "prefix"`) starts with it is never returned. Example `"Hamilton"`. |
| `last` | `string` | optional | Alias for `lastName`. |
| `firstName` | `string` | optional | Given name, case-insensitive and nickname-folded (`Rob` and `Robert` compare equal). On a cached search it narrows: the record's first name must start with your value, and it then scores `firstName:exact` or `firstName:prefix`. On a live check the source's candidates are scored instead: a different first name lowers `matchConfidence` and the record stays, and a record that publishes no first name stays flagged `firstName:absent`. Example `"Alex"`. |
| `first` | `string` | optional | Alias for `firstName`. |
| `dob` | `string (date)` | optional | Full date of birth. One value is compared against every kind of birth evidence a record carries: a full date, a month and year, a year, or an age. A record whose evidence contradicts it is dropped; a record with no evidence stays with `matchState: "no_dob_age_year"`. Any other date format is refused with `422 invalid_dob`. Example `"1989-04-23"`. |
| `birthYear` | `integer` | optional | Year of birth when you do not have the full date. It is a hard filter: only records whose birth year is on file and equal to it are returned, so age-only records are excluded. Send `dob` instead when you have the date. Example `1989`. |
| `birthMonth` | `integer` | optional | Month of birth, `1`–`12`, used with `birthYear` to compare month and year where a record publishes both. Example `4`. |
| `age` | `integer` | optional | Age in years, compared against a record's full date or published age within ±1 year, or its birth year within ±2. When `dob` is also sent, the `dob` decides. Example `36`. |
| `state` | `string` | optional | Two-letter USPS code. Keeps records held in that state's jurisdictions, and scopes the `coverage` block to that state. Example `"TX"`. |
| `county` | `string` | optional | County (jurisdiction) name, case-insensitive. Keeps records whose `county` equals it. Example `"Bexar"`. |
| `jurisdiction` | `string` | optional | Alias for `county`. |
| `city` | `string` | optional | Residence city, case-insensitive. Compared with the record's `address.city`, so only records that publish a residence can match. Example `"Bexar"`. |
| `zipcode` | `string` | optional | Residence ZIP, matched as a prefix of the record's `address.zipcode`: `"750"` matches `75001`. Only records with a residence address can match. Example `"78205"`. |
| `zip` | `string` | optional | Alias for `zipcode`. |
| `address` | `string` | optional | Street text that must appear, case-insensitively, in the record's `address.line1`. Example `"Main St"`. |
| `streetAddress` | `string` | optional | Alias for `address`. |
| `prefixMatch` | `array of string or string` | optional | Marks a prefix as deliberate on `"lastName"`, `"firstName"` or `"both"` (a list or a comma-separated string also works). A named field shorter than 3 characters is refused with `422 prefix_too_short`. Example `"lastName"`. |
| `website` | `string` | optional | A roster or records website. It scopes the search to the jurisdiction it resolves to; an explicit `state` or `county` you also send wins. The outcome is reported in the response's `resolution[]`. An unsupported site adds no scope and the search still runs. Example `"https://sheriff.examplecounty.gov/inmates"`. |
| `nameStrategy` | `string` — one of `"prefix"`, `"exact"` | optional · default `"prefix"` | How the surname is compared. `prefix` returns surnames equal to or starting with your value; `exact` returns only equal surnames. An implicit `prefix` on a surname shorter than 3 characters runs as `exact`; an explicit one is refused with `422 prefix_too_short`. |

---

# Filtering criminal searches

> Filter a criminal search by severity, outcome, charge, custody, date and location. Every record a filter removes is counted; unknown values are kept by default.

HTML: https://offendersearch.app/docs/criminal/filtering · Markdown: https://offendersearch.app/docs/criminal/filtering.md

## Criminal search filters: narrow a named search

A criminal search always starts from a name. Add `filters` to keep only the records you care about (felonies, convictions, court cases, a date window, a custody status) and `locations` to say where the person lived or where their records are. Both are optional request keys on [POST /v1/criminal/search](https://offendersearch.app/docs/criminal/search.md); a search without them answers exactly as before, and a filtered search is billed like any other search.

Filters test the [normalized fields](https://offendersearch.app/docs/criminal/normalized-fields.md) every record carries, so the same request works in every jurisdiction. The source's own words are still returned beside them.

**A filter never hides a record silently.** Every record a filter or location removes is counted in `counts.filteredOut`, by the filter that removed it, and every record kept only because its value is unknown is counted in `counts.unknownKept`. If filtering is ever switched off, a filtered search is refused with `503 filters_unavailable`; it is never answered unfiltered.

## How criminal record filters combine

Every filter you send must pass (AND). The values inside one list are alternatives (OR): `"severity": ["felony", "misdemeanor"]` keeps either. A list filter also takes a single string.

- **Record filters** test the whole record: `recordCategory`, `custodyStatus`, `hasPhoto`, `sources`, `excludeVacatedOrSealed`, the date ranges (`offenseDate`, `filedDate`, `dispositionDate`, each tested against the record's latest date of that kind) and `lookbackYears` (the latest of the three within that many years of today).
- **Charge filters** test the record's charges: `severity`, `outcome`, `convictedOnly`, `chargeCategories`, `statute` (the start of the published citation) and `chargeText` (text anywhere in the published description). `severityBasis` narrows `severity` to the bases you trust and does nothing on its own. `convictedOnly: true` adds `convicted` to the outcomes a charge may have.

### chargeMatch: any vs all

`chargeMatch` decides how charge filters meet. With `"any"` (the default) one charge must pass every charge filter, so "a felony conviction" means one charge that is both. With `"all"` each filter may be met by a different charge. Take this record, searched with `"severity": ["felony"], "convictedOnly": true`:

```json
"cases": [ { "caseNumber": "2019-CF-0412", "charges": [
  { "description": "BURGLARY OF HABITATION", "disposition": "DISMISSED" },   // felony, dismissed
  { "description": "THEFT < $750",           "disposition": "GUILTY" }      // misdemeanor, convicted
] } ]
```

| chargeMatch | Result |
| --- | --- |
| `"any" (default)` | Filtered out, counted under `filteredOut.severity`: no single charge is both a felony and a conviction. |
| `"all"` | Returned with `matchesFilters: [{case: 0, charge: 0}, {case: 0, charge: 1}]`: the felony is met by one charge and the conviction by the other. |

A record that passes a charge filter carries `matchesFilters`: the `{case, charge}` positions, in `normalized.cases[].charges[]`, of the charges that met them. Every charge is still returned.

## Criminal filter counts: filteredOut, unknownKept, quarantined

- `counts.filteredOut`: records removed, keyed by the filter that removed each one. A record is counted once, under the first filter it failed. Location misses are `locations`, or `noResidence` when a residence test had no published address to test. A record that fails the per-charge test is counted under the first charge filter you sent. For a name too common to search unfiltered, the index applies your filters together before matching, and those records are counted once under `filters`.
- `counts.unknownKept`: returned records that passed a filter only because their value is unknown, for `severity`, `outcome`, `custodyStatus` and the date ranges.
- `counts.quarantined`: candidate records whose normalized values are withheld (`normalized.quality: "quarantined"`). Their own fields are returned as usual.
- `appliedFilters`: the filters as validated, with `unknown` and `chargeMatch` always present.

### Unknown values: keep or exclude

`filters.unknown` decides what `severity`, `outcome`, `convictedOnly`, `custodyStatus` and the date filters do with a value that is not known. `"keep"` (the default) returns the record and counts it in `unknownKept`; `"exclude"` removes it and counts it in `filteredOut`.

Records marked vacated, expunged or sealed are returned, marked `normalized.vacatedOrSealed: true`, unless you send `excludeVacatedOrSealed: true`.

## Locations: residence vs jurisdiction in a criminal search

| kind | Matches | Notes |
| --- | --- | --- |
| `residence` | Where the person lives: the published address (`state`, `county`, `city`, `zipcode`, or a radius). | A record with no published address cannot match; it is counted as `noResidence`. |
| `jurisdiction` | Where the record is: the state and county of the court, jail or agency. | `city`, `zipcode` and a radius are refused with `422 invalid_location`. |
| `any (default)` | Either of the two. | `city`, `zipcode` and a radius test the residence only. |

- **Up to 10 locations, ORed.** A record is kept when it matches any of them, and lists the 0-based indexes of the ones it matched in `matchedLocations`.
- **A radius applies to the residence only.** `lat`, `lng` and `radiusMiles` (0.1 to 100 miles) go together and are measured to the published address. A residence without coordinates cannot be tested and counts as `noResidence`.
- **Locations define the candidates.** Each location runs its own search: a `jurisdiction` location searches its state and county, a `residence` location searches its ZIP or city when it has one, and `any` or a radius searches the name across every record. The candidates are pooled, then each is tested against every location. A name too broad to read in full is refused with `422 query_too_broad`, never answered in part.
- **One way to say where.** Send `locations` or the flat `state`, `county`, `city` and `zipcode` query keys, not both: both is `422 ambiguous_location`. A county matches with or without the word "County".

## Criminal search filter examples

### 1. A known person and two places they lived

You know the name and date of birth, a ZIP the person lived in, and that they also spent time in Oklahoma. One location tests the residence; the other matches either an Oklahoma address or an Oklahoma record.

```bash
curl -X POST https://api.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "query": { "lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12" },
        "locations": [
          { "kind": "residence", "zipcode": "77060" },
          { "kind": "any", "state": "OK" }
        ]
      }'
```

```json
{
  "status": "complete",
  "counts": { "records": 2, "recordsReturned": 2,
              "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0,
              "candidatesCapped": false, "exclusionsIncluded": false,
              "filteredOut": { "locations": 3, "noResidence": 1 },
              "unknownKept": {}, "quarantined": 0 },
  "records": [
    { "externalId": "TX-HARRIS-DC:case:201944871",
      "name": { "first": "JORDAN", "middle": "", "last": "RIVERA", "suffix": "" },
      "matchState": "dob_match",
      "address": { "type": "residence", "line1": "16966 NORTHCHASE DR APT 313",
                   "city": "HOUSTON", "county": "Harris", "state": "TX", "zipcode": "77060",
                   "lat": null, "lng": null },
      "matchedLocations": [0] },
    { "externalId": "OK-TULSA:booking:2021-118342",
      "name": { "first": "JORDAN", "middle": "", "last": "RIVERA", "suffix": "" },
      "matchState": "dob_match",
      "matchedLocations": [1] }
  ],
  "appliedFilters": {
    "locations": [ { "kind": "residence", "zipcode": "77060" },
                   { "kind": "any", "state": "OK" } ],
    "unknown": "keep", "chargeMatch": "any"
  }
}
```

- Each record says which location it matched in `matchedLocations` (0 is the ZIP, 1 is Oklahoma).
- `filteredOut.noResidence: 1` is a candidate that matched neither location and had no published address to test against the ZIP. It was not dropped silently: it is counted.

### 2. Felony convictions in the last 7 years, anywhere

Charge filters test charges. With the default `chargeMatch: "any"`, one charge must be both a felony and a conviction, and the record's latest offense, filing or disposition date must be within 7 years.

```bash
curl -X POST https://api.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "query": { "lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12" },
        "filters": { "severity": ["felony"], "convictedOnly": true, "lookbackYears": 7 }
      }'
```

```json
{
  "status": "complete",
  "counts": { "records": 2, "recordsReturned": 2,
              "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0,
              "candidatesCapped": false, "exclusionsIncluded": false,
              "filteredOut": { "severity": 4, "lookbackYears": 1 },
              "unknownKept": { "severity": 1 }, "quarantined": 0 },
  "records": [
    { "externalId": "TX-HARRIS-DC:case:201944871",
      "cases": [ { "caseNumber": "201944871",
                   "charges": [ { "statute": "PC 22.02", "description": "AGG ASSAULT W/DEADLY WEAPON",
                                  "severity": "felony", "disposition": "GUILTY" } ] } ],
      "normalized": {
        "recordCategory": "court", "topSeverity": "felony",
        "hasConviction": true, "convictionCount": 1,
        "cases": [ { "stage": "closed", "caseClass": "felony",
                     "charges": [ { "category": "assault", "notACharge": false,
                                    "severity": "felony", "severityBasis": "published",
                                    "degree": null, "outcome": "convicted", "convicted": true,
                                    "custodyEvent": null } ] } ] },
      "matchesFilters": [ { "case": 0, "charge": 0 } ] }
  ],
  "appliedFilters": { "severity": ["felony"], "convictedOnly": true, "lookbackYears": 7,
                      "unknown": "keep", "chargeMatch": "any" }
}
```

- `matchesFilters` points at the charge that met every charge filter, by its position in `normalized.cases[].charges[]`. Every charge on the record is still returned.
- `unknownKept.severity: 1` is a returned record whose severity could not be classified. It is kept because `unknown` defaults to `keep`; send `"unknown": "exclude"` to remove such records instead.

### 3. In custody or wanted, within 25 miles of Dallas

A radius tests where the person lives, so `kind: "any"` with a radius matches on the residence only. A location with no ZIP or city searches the name across every record, so a very common name can be refused with `422 query_too_broad`; add a first name or date of birth.

```bash
curl -X POST https://api.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "query": { "lastName": "Rivera", "firstName": "Jordan" },
        "locations": [ { "kind": "any", "lat": 32.78, "lng": -96.80, "radiusMiles": 25 } ],
        "filters": { "custodyStatus": ["in_custody", "wanted"] }
      }'
```

```json
{
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1,
              "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0,
              "candidatesCapped": false, "exclusionsIncluded": false,
              "filteredOut": { "noResidence": 6, "locations": 2, "custodyStatus": 3 },
              "unknownKept": {}, "quarantined": 0 },
  "records": [
    { "externalId": "TX-DALLAS:booking:26-0041177",
      "currentStatus": "in_custody",
      "normalized": { "recordCategory": "jail", "custodyStatus": "in_custody",
                      "residence": { "city": "GARLAND", "state": "TX", "zip5": "75040",
                                     "lat": 32.93, "lng": -96.62, "geoPrecision": "street" } },
      "matchedLocations": [0] }
  ],
  "appliedFilters": {
    "custodyStatus": ["in_custody", "wanted"],
    "locations": [ { "kind": "any", "lat": 32.78, "lng": -96.8, "radiusMiles": 25.0 } ],
    "unknown": "keep", "chargeMatch": "any"
  }
}
```

- `noResidence: 6` are candidates with no published address, or an address without coordinates, so the radius could not be tested. They are counted, not hidden.
- `appliedFilters` echoes each location with the keys it was read as.

### 4. Violent or weapons charges, with a published severity only

`severityBasis` works together with `severity`: list every severity you accept and the bases you trust. `"unknown": "exclude"` removes charges and records whose severity could not be decided at all.

```bash
curl -X POST https://api.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "query": { "lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12" },
        "filters": {
          "chargeCategories": ["assault", "weapons"],
          "severity": ["felony", "misdemeanor", "infraction"],
          "severityBasis": ["published"],
          "unknown": "exclude"
        }
      }'
```

```json
{
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1,
              "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0,
              "candidatesCapped": false, "exclusionsIncluded": false,
              "filteredOut": { "chargeCategories": 2, "severity": 2 },
              "unknownKept": {}, "quarantined": 0 },
  "records": [
    { "externalId": "TX-HARRIS-DC:case:201944871",
      "matchesFilters": [ { "case": 0, "charge": 0 } ] }
  ],
  "appliedFilters": {
    "severity": ["felony", "misdemeanor", "infraction"], "severityBasis": ["published"],
    "chargeCategories": ["assault", "weapons"],
    "unknown": "exclude", "chargeMatch": "any"
  }
}
```

- With `chargeMatch: "any"`, one charge must be an assault or weapons charge and carry a severity the source published itself.
- `severityBasis` must be sent with `severity`; on its own it is refused with a 422.

### 5. Convicted court records, vacated or sealed left out

A screening-style search: court records only, a conviction on at least one charge, and records marked vacated or sealed left out by choice. `include: ["facets"]` adds counts over the whole result.

```bash
curl -X POST https://api.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "query": { "lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12" },
        "filters": { "recordCategory": ["court"], "outcome": ["convicted"],
                     "excludeVacatedOrSealed": true },
        "include": ["facets"]
      }'
```

```json
{
  "status": "complete",
  "counts": { "records": 3, "recordsReturned": 3,
              "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0,
              "candidatesCapped": false, "exclusionsIncluded": false,
              "filteredOut": { "recordCategory": 5, "excludeVacatedOrSealed": 1, "outcome": 2 },
              "unknownKept": { "outcome": 1 }, "quarantined": 0 },
  "records": [ "…" ],
  "facets": {
    "recordCategory": { "court": 3 },
    "severity": { "felony": 1, "misdemeanor": 1, "unknown": 1 },
    "outcome": { "convicted": 2, "dismissed": 1, "unknown": 1 },
    "jurisdiction.state": { "TX": 2, "OK": 1 },
    "chargeCategories": { "assault": 1, "theft": 1, "traffic": 1 },
    "custodyStatus": { "disposed": 2, "unknown": 1 }
  },
  "appliedFilters": { "recordCategory": ["court"], "outcome": ["convicted"],
                      "excludeVacatedOrSealed": true, "unknown": "keep", "chargeMatch": "any" }
}
```

- `facets` counts every record that passed, not only this page. A record with several outcomes or charge categories counts once under each.
- Without `excludeVacatedOrSealed`, a record marked vacated or sealed is returned with `normalized.vacatedOrSealed: true`.

## Sorting and facets for filtered criminal searches

`sort` orders a search that sends `filters` or `locations`: `relevance` (the default), or `offenseDate`, `filedDate` or `dispositionDate`, newest first by the record's latest date of that kind, with records that have none last.

`include: ["facets"]` adds `facets`: counts of every record that passed (not only the page) by record category, highest severity, outcome, state, charge category and custody status. `include: ["normalizedDetail"]` adds the rule that decided each charge's outcome, for audits.

Filters work on cached searches and on live checks of up to 2 sources. A live search that has to run as a job refuses them with `422 filters_with_async_live`.

## Criminal filter errors

| error.code | When |
| --- | --- |
| `invalid_filter_value` | A value outside a filter's list, a wrong type, an unknown filter key, a `statute` or `chargeText` under 2 characters, a `lookbackYears` outside 1–100, or a bad `sort`, `unknown` or `chargeMatch`. The message lists the allowed values. |
| `invalid_date_range` | A date range with neither `from` nor `to`, a value that is not `YYYY-MM-DD` or not a real date, or `from` after `to`. |
| `invalid_location` | An empty `locations` list; an unknown key, a bad state or ZIP, or no place in a location; `lat`, `lng` and `radiusMiles` not sent together or out of range; a radius below 0.1; a radius, `city` or `zipcode` on `kind: "jurisdiction"`. |
| `too_many_locations` | More than 10 locations. |
| `ambiguous_location` | `locations` sent together with the flat `state`, `county`, `jurisdiction`, `city`, `zipcode` or `address` query keys. |
| `radius_too_large` | `radiusMiles` above 100. |
| `filters_with_async_live` | `filters` or `locations` on a live search that has to run as a job. Filters work on cached searches and on live checks of up to 2 sources. |
| `filters_unavailable (503)` | Filtering is switched off for the moment. A filtered search is never answered unfiltered; retry after `Retry-After` seconds, or search without filters. |

## The SearchFilters object — criminal search filter fields

The `filters` of a criminal search. Every key is optional; filters are ANDed and the values inside one list are ORed. A list filter also takes a single string. Any other key is `422 invalid_filter_value`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search).

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `recordCategory` | `array of string` — one of `"court"`, `"jail"`, `"prison"`, `"warrant"`, `"registry"`, `"exclusion"`, `"other"` | optional | The kind of record (`normalized.recordCategory`). |
| `severity` | `array of string` — one of `"felony"`, `"misdemeanor"`, `"infraction"` | optional | Charge severity. With `unknown: "keep"` an unclassified charge also passes. |
| `severityBasis` | `array of string` — one of `"published"`, `"statute_code"`, `"statute_table"`, `"case_type"`, `"charge_text"` | optional | Narrows `severity`: a charge's severity counts only when it was decided on one of these bases. Must be sent with `severity`; on its own it is a 422 `invalid_filter_value`. |
| `outcome` | `array of string` — one of `"convicted"`, `"pending"`, `"dismissed"`, `"nolle_prosequi"`, `"acquitted"`, `"deferred_or_diverted"`, `"vacated"`, `"warrant"`, `"transferred"`, `"closed_unspecified"`, `"administrative"` | optional | Charge outcome class. With `unknown: "keep"` a charge whose outcome is unknown also passes. |
| `convictedOnly` | `boolean` | optional · default `false` | `true` adds `convicted` to the outcomes a charge may have. Alone it means a convicted charge; with `outcome` the charge may have any of those outcomes or `convicted`. |
| `chargeCategories` | `array of string` — one of `"supervision"`, `"homicide"`, `"sex_offense"`, `"robbery"`, `"domestic_violence"`, `"dui"`, `"assault"`, `"weapons"`, `"burglary"`, `"fraud"`, `"theft"`, `"drug_distribution"`, `"drug_possession"`, `"traffic"`, `"public_order"`, `"other"` | optional | What the charge is about. |
| `chargeMatch` | `string` — one of `"any"`, `"all"` | optional · default `"any"` | `any`: one charge must meet every charge filter. `all`: each charge filter may be met by a different charge on the record. |
| `statute` | `string` | optional | The start of the published statute citation, ignoring spaces and case: `22.02` matches `22.02(a)(1)`. At least 2 characters. Example `"22.02"`. |
| `chargeText` | `string` | optional | Text that appears anywhere in the published charge description, ignoring case. At least 2 characters. Example `"assault"`. |
| `custodyStatus` | `array of string` — one of `"in_custody"`, `"released"`, `"on_parole"`, `"on_probation"`, `"active_warrant"`, `"wanted"`, `"registered"`, `"disposed"`, `"absconder"`, `"deceased"`, `"excluded"` | optional | The record's custody status. With `unknown: "keep"` a record whose status is unknown also passes. |
| `offenseDate` | [DateRange](https://offendersearch.app/docs/criminal/filtering#object-daterange) | optional | The record's latest offense date must fall in this range. |
| `filedDate` | [DateRange](https://offendersearch.app/docs/criminal/filtering#object-daterange) | optional | The record's latest filing date must fall in this range. |
| `dispositionDate` | [DateRange](https://offendersearch.app/docs/criminal/filtering#object-daterange) | optional | The record's latest disposition date must fall in this range. |
| `lookbackYears` | `integer` | optional | The latest of the record's offense, filing and disposition dates must be within this many years of today. A whole number from 1 to 100. Example `7`. |
| `hasPhoto` | `boolean` | optional · default `false` | `true` keeps only records with a published photograph. |
| `sources` | `array of string` | optional | Keep only records from these source codes. Example `["TX-HARRIS-DC"]`. |
| `excludeVacatedOrSealed` | `boolean` | optional · default `false` | `true` leaves out records marked `normalized.vacatedOrSealed`. By default they are returned, marked. |
| `unknown` | `string` — one of `"keep"`, `"exclude"` | optional · default `"keep"` | What `severity`, `outcome`, `convictedOnly`, `custodyStatus` and the date filters do with a value that is not known. `keep` returns the record and counts it in `counts.unknownKept`; `exclude` removes it and counts it in `counts.filteredOut`. |

## The DateRange object — date filter fields

An inclusive ISO date range. Send `from`, `to` or both; `from` after `to`, a non-ISO value or an impossible date is `422 invalid_date_range`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search).

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `from` | `string (date)` | optional | First date in the range. Example `"2019-01-01"`. |
| `to` | `string (date)` | optional | Last date in the range. Example `"2026-10-23"`. |

## The Location object — residence and jurisdiction fields

One place in `locations[]`. It needs at least one of `state`, `county`, `city`, `zipcode`, or a radius (`lat`, `lng` and `radiusMiles` together). Any other key is `422 invalid_location`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search).

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `kind` | `string` — one of `"residence"`, `"jurisdiction"`, `"any"` | optional · default `"any"` | `residence` matches where the person lives (the published address). `jurisdiction` matches where the record is (the court, jail or agency's state and county). `any` matches either. A radius, `city` and `zipcode` describe a residence: on `jurisdiction` they are `422 invalid_location`, and on `any` they test the residence only. |
| `state` | `string` | optional | Two-letter state code. Example `"TX"`. |
| `county` | `string` | optional | County name; `Harris` and `Harris County` match the same county. Example `"Harris"`. |
| `city` | `string` | optional | City of the residence, matched exactly, ignoring case. Example `"Houston"`. |
| `zipcode` | `string` | optional | Five-digit ZIP of the residence. A ZIP+4 is read as its first five digits; `zip` is accepted as another name for this key. Example `"77060"`. |
| `lat` | `number` | optional | Latitude of the radius centre, -90 to 90. Requires `lng` and `radiusMiles`. Example `32.78`. |
| `lng` | `number` | optional | Longitude of the radius centre, -180 to 180. Requires `lat` and `radiusMiles`. Example `-96.8`. |
| `radiusMiles` | `number` | optional | Miles from `lat`/`lng` to the person's published residence, 0.1 to 100. Above 100 is `422 radius_too_large`; below 0.1 is `422 invalid_location`. A residence without coordinates cannot be tested and counts as `noResidence`. Example `25`. |

## The SearchFacets object — facet counts on a filtered search

Counts over every record that passed the filters: the whole result, not the page. Each key maps a value to a number of records.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `recordCategory` | `object` |  | Records by `normalized.recordCategory`. Example `{"court":9,"jail":5}`. |
| `severity` | `object` |  | Records by their highest charge severity; `unknown` when none is classified. Example `{"felony":2,"misdemeanor":9,"unknown":3}`. |
| `outcome` | `object` |  | Records by outcome class; a record with several outcomes counts once under each. Example `{"convicted":6,"dismissed":4,"pending":4}`. |
| `jurisdiction.state` | `object` |  | Records by the state where the record is; the 60 largest. Example `{"TX":12,"OK":2}`. |
| `chargeCategories` | `object` |  | Records by charge category; a record with several categories counts once under each. Example `{"traffic":7,"theft":3,"assault":2}`. |
| `custodyStatus` | `object` |  | Records by `normalized.custodyStatus`. Example `{"released":10,"in_custody":4}`. |

---

# Matching, confidence & partial names

> A required surname anchor, prefix and exact name strategies, a conservative nickname table, no fuzzy surname matching, and the exact matchConfidence arithmetic.

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

## The surname is the anchor

The surname rules hold in every mode — on the cache and on a live source — because the same matcher scores every source's output.

| Rule | Detail |
| --- | --- |
| `lastName` is required | A query with no surname is refused with `422 guard_unbounded_query`. Both `lastName` and the shorthand `last` are accepted. |
| Case-insensitive | `smith`, `Smith`, `SMITH` are identical. |
| The surname must corroborate | A record whose surname neither equals nor prefix-matches your `lastName` is **dropped**. |
| A cached `firstName` narrows too | On a cached search the record's first name must start with the value you send, case-insensitively. |
| A missing query field does not filter | Omit `firstName` and the first name is not considered at all (`firstName:notQueried`). |
| On a live check, a record missing a first name is KEPT | We cannot disprove your `firstName`, so the record is kept and flagged `firstName:absent`, with a small confidence penalty. On a cached search a `firstName` you send requires one. |

## The two surname strategies — nameStrategy

| `nameStrategy` | Surname matches | Default? |
| --- | --- | --- |
| `prefix` | the record's surname **equals** your value, **or starts with** it | **Yes** |
| `exact` | the record's surname **equals** your value | no |

> **There is no fuzzy (typo) surname matching in this API.** `Smyth` does not match `Smith`; `Ross` does not match `Rose`. In this domain a false positive is the expensive error — telling a customer that an innocent person has a criminal record is materially worse than a miss — so the matcher never introduces a surname it was not given.

## First-name matching and nicknames

When you supply `firstName`, it is scored **after** the surname corroborates. Both sides are folded through a small, conservative **nickname table** first — `Bob`/`Bobby`/`Rob` → `Robert`, `Bill`/`Will` → `William`, `Jim` → `James` — in both directions. A nickname hit reads as `firstName:exact` (the two folded names became equal). On a **cached** search the record's first name must also start with the value you send — so `Rob` reaches `ROBERT` (`firstName:exact`), `Alex` reaches `ALEXANDER` (`firstName:prefix`), and `Bob` does not reach `ROBERT`. On a **live** check the source's own candidates are scored, and there a first-name **mismatch does not drop the record** — it only lowers its confidence.

## Prefix (partial-name) matching — prefixMatch

`prefixMatch` requests prefix matching on a named field explicitly, and is where the **3-character floor** lives. It accepts `"firstName"`, `"lastName"`, `"both"`, or a list.

```json
{ "query": { "lastName": "hamil", "firstName": "al", "prefixMatch": "lastName" } }
```

| You sent | 2-char surname behaviour |
| --- | --- |
| nothing (default) | falls back to **exact** — no error (so `Li`, `Ng`, `Ho`, `Vo` work) |
| `nameStrategy: "prefix"` | **422 `prefix_too_short`** — you asked for prefix |
| `prefixMatch: "lastName"` | **422 `prefix_too_short`** — you asked for prefix |
| `nameStrategy: "exact"` | exact — no error, no floor |

## The match vocabulary on the wire

Every record tells you **how** it matched, in `matchBasis` (space-separated `field:how` tokens) and `matchDetail` (one plain sentence). The surname tokens are `exact` and `prefix`; the given tokens are `exact`, `prefix`, `absent`, `mismatch`, `notQueried`; the dob tokens are `match`, `year`, `monthYear`, `age`, `ageFromDob`, `absent`, `sourceConfirmed`, `sourceNotConfirmed`.

```json
{
  "name": { "first": "ROBERT", "middle": "E", "last": "SMITH", "suffix": "" },
  "matchConfidence": 0.99,
  "matchBasis": "lastName:exact firstName:exact dob:match",
  "matchDetail": "name and full date of birth agree",
  "matchState": "dob_match",
  "unverified": false
}
```

## Exclusion-list records and the date of birth

Exclusion-list records (`recordType: "healthcare_exclusion"`) go through the same matcher as every other kind, and what they can reach depends on what the list publishes. Most entries on the HHS-OIG list carry a **full date of birth**, so a `dob` in the query reaches `matchState: "dob_match"` and the record scores like a court record with the same evidence. SAM.gov entries and most state Medicaid lists publish **no date of birth**: those records are kept and flagged `unverified: true` (`matchState: "no_dob_age_year"`), rank below every record with birth evidence, and are never dropped for a date they could not disagree with. Where a list publishes an address it is carried on the record, so `city`, `zipcode` and `address` narrow those records the way they narrow a registry entry. The conviction tier and the administrative tier match identically; the difference is only whether the record is returned at all without `include: ["exclusions"]`.

## How matchConfidence is built

`matchConfidence` is not a probability — it is a **sum of fixed evidence contributions, clamped to `[0.0, 0.99]`**, so a name-only match can never present as certainty.

| Contribution | Adds |
| --- | --- |
| Surname **exact** (base) | `+0.55` |
| Surname **prefix** | `+0.42` |
| First name **exact** (after nickname folding) | `+0.25` |
| First name **prefix** (`Alex` → `Alexander`) | `+0.12` |
| First name **absent** (live checks only) | `−0.05` |
| First name **mismatch** (live checks only) | `−0.20` |
| DOB `dob_match` | `+0.40` |
| DOB `year_match` | `+0.30` |
| DOB `age_match` | `+0.18` |
| DOB `no_dob_age_year` | `+0.00` |
| DOB not confirmed by the source (`dob:sourceNotConfirmed`) | `−0.15` |

> **A high `matchConfidence` on a name-only match is still a candidate, not a confirmation.** For anything consequential, gate on `matchState: "dob_match"` with `lastName:exact` and `firstName:exact` — and honour the FCRA restriction: this data may not be used for FCRA-covered decisions at all.

---

# 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 - one query reaches date, year and age evidence.

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

## One query reaches every kind of birth evidence

Jurisdictions do not all publish a date of birth: some publish only a birth year, many jail and inmate records publish only an **age**, a few publish neither. **You do not have to compensate for that.** A `dob` you send is matched against a full date, else a year, else an age — so one query reaches all of them. Widening happens when we match; it never happens in what we store or send.

## matchState — how each record matched

| `matchState` | The record holds | Meaning | `unverified` |
| --- | --- | --- | --- |
| `dob_match` | a full date | The record's full date of birth equals yours. Strongest confirmation. | `false` |
| `year_match` | a year (and maybe a month) | The birth years agree (months too, if both publish one). Never a full-date confirmation. | `false` |
| `age_match` | an age (or a date or year checked against your age) | The age is consistent with the date you sent (within ±1 year), or the record is consistent with the age you sent. A plausibility check, not a confirmation. | `false` |
| `no_dob_age_year` | nothing comparable | Nothing to check against — returned because the **name** matched. | **`true`** |
| `dob_mismatch` | conflicting evidence | A date, year or age that **conflicted** with your query. **Dropped — you never see this in `records[]`.** | — |

When you query no `dob`/`age`/`birthYear`, nothing is checked, so every record reads `matchState: "no_dob_age_year"`. Every record also carries `unverified`, a coarse boolean twin: `true` iff `matchState == "no_dob_age_year"`. `matchState` is `null` only on a record fetched by id, which scores nothing.

## dobVerification — confirmed even when no date is published

Some jurisdictions never publish a date of birth, so their records can only ever reach `no_dob_age_year` from stored data. For some of them, a live search can still test the `dob` you sent: the jurisdiction is asked whether it holds that record under that date. Every record reports the route in `dobVerification`.

| `dobVerification` | Meaning |
| --- | --- |
| `record` | Your date was compared with birth evidence the record itself carries (a date, a year or an age). `matchState` says how it came out. |
| `source_confirmed` | The record carries no birth evidence, and a live check confirmed that the jurisdiction holds this record under the date you sent. Scored as `dob_match`. **The record's `dob` stays `null`.** |
| `source_not_confirmed` | The record carries no birth evidence, and a live check of the date you sent did not return it. The jurisdiction does not say whether it holds a different date or none, so the record is **kept**, flagged, and ranked below unchecked records. |
| `unverified` | Nothing was available to check your date against. |

> **A confirmation is not a date.** On a `source_confirmed` record `matchState` is `dob_match`, `unverified` is `false` and `matchBasis` ends in `dob:sourceConfirmed` — while `dob.date` is still `null` and `dobPrecision` is still `"none"`. We were told your date matches; we were not given a date, and we never write yours onto the record.

```json
{
  "dob": { "date": null, "birthYear": null, "age": null, "dobPrecision": "none" },
  "matchState": "dob_match",
  "matchBasis": "lastName:exact firstName:exact dob:sourceConfirmed",
  "matchConfidence": 0.99,
  "unverified": false,
  "dobVerification": "source_confirmed",
  "liveChecked": true
}
```

- It needs a **full** `dob` and a `live` block. A search without `live` never produces it, and a `birthYear` or `age` alone cannot be confirmed this way.
- When the jurisdiction holds a **different** date for the record, that is a `dob_mismatch`: the record is dropped, exactly as if a conflicting date had been published.
- `source_not_confirmed` is deliberately not a mismatch. Treat it as a weaker candidate than `unverified`, not as a different person.
- `dobVerification` is `unverified` when you queried no `dob`, `age` or `birthYear`, and `null` on a record fetched by id.

## What you can send

| Field | Type | Notes |
| --- | --- | --- |
| `dob` | string `YYYY-MM-DD` | A full date. Matched against a full date, else a year, else an age. |
| `birthYear` | integer | A year on its own. A record publishing a conflicting year is dropped; an age-only or undated record is kept and labelled `age_match` or `no_dob_age_year` (`unverified: true`). |
| `birthMonth` | integer `1–12` | Refines a year match when both sides publish a month. Ignored unless a year is in play. |
| `age` | integer | Checked against a record's full date or published age within ±1 year, or its birth year within ±2. With `dob` also sent, the `dob` decides. |

## dobPrecision — what kind of birth evidence a record carries

Every record reports the precision of its own date of birth. `dob.date` is a full `YYYY-MM-DD` date or it is `null` — **never a fabricated day**. A source that publishes a `1/1` sentinel collapses to `year` (or `age`, or `none`); the January-1st is never emitted as a real birthday, so you can hand `dob.date` to a date parser without a length check. A placeholder date — `1900-01-01`, `0001-01-01`, any `9999` date, or any year before 1900 or after this year — is **no birth evidence at all**: `date` and `birthYear` are `null`, the record reads `age` or `none`, and it is never dropped for disagreeing with your `dob`.

| `dobPrecision` | `dob.date` | `dob.birthYear` | `dob.age` |
| --- | --- | --- | --- |
| `full` | `"1989-04-23"` | `1989` | when published |
| `month_year` | `null` | `1989` | when published |
| `year` | `null` | `1989` | when published |
| `age` | `null` | `null` | the published age |
| `none` | `null` | `null` | `null` |

## Records we could not verify are KEPT and flagged, not dropped

A `dob`- or `age`-filtered response is a **mixture** — `dob_match`, `year_match`, `age_match` and `no_dob_age_year` records side by side. Only a genuine **conflict** (`dob_mismatch`) is excluded. If you present the result as "confirmed matches", the unverified records make that claim false.

```js
const verified   = res.records.filter(r => !r.unverified);
const candidates = res.records.filter(r =>  r.unverified);
```

---

# Result completeness & source status

> On a live search, counts.sourcesIncomplete and the closed incompleteReason enum tell you whether an empty result is a confirmed absence or only a lower bound.

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

## The incompleteness signal is a property of live verification

A **cached** search always returns a whole answer from the corpus in one query, so `sourceStatus[]` is `[]`, `status` is `"complete"`, and an empty `records` genuinely means "nobody in the corpus matched". The "is it whole?" question only arises for **live** sources, where we reach out in real time.

## sourceStatus[] — the live-verification audit trail

On a cached search this is `[]`. On a live search, one entry per live source. **`incomplete` says whether the search finished.** A source that did not finish is `ok: false`, `incomplete: true`, with an `incompleteReason` — read `incomplete` to decide whether a `0` is *no match* or *unknown*.

```json
{ "code": "TX-LUBBOCK", "source": "TX-LUBBOCK", "ok": false, "incomplete": true,
  "incompleteReason": "timeout", "records": 0,
  "lastCheckedAt": "2026-08-25T15:04:12+00:00", "live": true }
```

> A **failed** live check is reported, not hidden, and **not billed** — you pay only for checks that completed.

## incompleteReason — the closed vocabulary

`incompleteReason` is non-`null` **only** when `incomplete` is `true`. The retryable reasons are the transport ones; `truncated_over_cap` is stable (narrow instead); the rest are not retryable automatically. In every case, an incomplete source's `0` means *unknown*, not *no match*. Today a live source reports `timeout`, `source_down`, `truncated_over_cap` or `not_searched`, and a website reports `unsupported_site`; the other values are reserved in the vocabulary, so handle every one.

| `incompleteReason` | What to do |
| --- | --- |
| `timeout` | ★ **Retry** — a capacity event that clears. |
| `source_down` | ★ **Retry**, with backoff. |
| `rate_limited` | ★ **Retry** later. |
| `captcha_unsolved` | **Retry** — transient; do not record absence. |
| `proxy_required` | **Retry.** |
| `truncated_over_cap` | **Do not retry — narrow.** Add a first name or a date of birth. |
| `paid_gateway` | Not retryable automatically. Record *not determined*. |
| `login_required` | Not retryable automatically. Record *not determined*. |
| `unsupported_site` | A `live.websites` entry that maps to no covered jurisdiction. Resolve it or drop it. |
| `not_searched` | The source was not searched live for this request: the code has no live check, or the job ended before reaching it. Confirm the code against the catalog. |

## Reading an empty live result

Two very different events produce the same empty list on a live search, and the response tells you which one you are holding.

| What happened | What the response says |
| --- | --- |
| Every source completed and nobody matched. **This is an answer.** | `status: "complete"`, `counts.sourcesIncomplete: 0` |
| One or more sources could not be checked to the end. **This is a lower bound.** | `status: "partial"`, `counts.sourcesIncomplete > 0`, and a `warnings[]` sentence naming the sources |

```js
if (res.counts.sourcesIncomplete > 0) {
  // Record "not determined", not "not found". Re-run the live check.
}
```

## counts

Every search carries a `counts` block. The three live counts are `0` on a cached search; `sourcesIncomplete > 0` is the one count that means something may be missing. `sourcesOnFile` and `sourcesPending` describe the sources behind the answer on every search. Every field is in the Counts object below.

> `sourcesQueried: 0` next to `sourcesIncomplete: 0` on a cached search means "complete", not "nothing was searched". `sourcesOnFile` is the number of sources that stood behind the answer.

## coverage

Every search response carries a `coverage` block (absent only when the source catalog cannot be read): which sources stand behind the answer for the state and county you searched, and whether any source for that scope is not available yet. Every field is in the SearchCoverage object below.

```json
"counts":   { "records": 0, "recordsReturned": 0,
              "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false,
              "sourcesOnFile": 8, "sourcesPending": 0 },
"coverage": { "scope": { "state": "WA", "county": "Pierce", "countyRecognized": true },
              "sourcesOnFile": 8, "sourcesPending": 0, "pendingSources": [] }
```

The codes are the same ones `GET /v1/criminal/sources` lists and every record carries in `sources[].code`. `coverage` does not change `status`: a cached search is still `complete`. It tells you how much weight an empty result can bear — a zero with `sourcesPending: 0` for the county you asked about is a stronger zero than one with a pending source named beside it.

## The criminal Counts object — counts on every search

Totals on every criminal search. The three live counts are `0` on a cached search; `sourcesOnFile` and `sourcesPending` describe the sources behind the answer on every search.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `records` | `integer` |  | Total records matched, before the page slice. Use it, not `records.length`, for the total. |
| `recordsReturned` | `integer` |  | Records on this page. |
| `sourcesQueried` | `integer` |  | Live sources checked in this request. `0` on a cached search. |
| `sourcesComplete` | `integer` |  | Of `sourcesQueried`, the sources searched to the end. `status` is `complete` exactly when this equals `sourcesQueried`. |
| `sourcesIncomplete` | `integer` |  | Live sources that could not be searched to the end. Above `0`, `records` is a lower bound and an empty result is not evidence that a person is absent. |
| `candidatesCapped` | `boolean` |  | Always `false`; kept for compatibility. Every stored record that matches the name and filters is read and scored. A search whose matches cannot all be read within the time budget is refused with `422` (`error.code: query_too_broad`) rather than answered partially. Narrow it with `firstName` or `state` (a county, city or zipCode alone does not narrow it). |
| `exclusionsIncluded` | `boolean` |  | `true` when the request carried `include: ["exclusions"]`, so exclusion-list records whose basis is not a criminal conviction were eligible to be returned. `false` on every other search: conviction-based exclusions are still returned like any other record, and the administrative tier (license actions, administrative debarments, loan defaults) was left out. See [exclusion-list records](https://offendersearch.app/docs/criminal/record-object#exclusions). |
| `sourcesOnFile` | `integer` |  | Sources available for the state or county searched: that county's own, the state's statewide ones and the federal ones. Equals `coverage.sourcesOnFile`. Present whenever `coverage` is. |
| `sourcesPending` | `integer` |  | Sources for that scope that are not available yet. Equals `coverage.sourcesPending`. Above `0`, read `coverage.pendingSources` before treating an empty result as a clear. |
| `clearedSuppressed` | `integer` |  | Records a `not_this_person` [clearance](https://offendersearch.app/docs/criminal/clearances) removed from `records[]`. Present only when the request carried `subjectId`; `0` in `annotate` and `ignore` modes. `records` already excludes them, so `records + clearedSuppressed` is the count before clearances. Each one is listed in `cleared[]`. |
| `filteredOut` | `object` |  | Present when the request sent `filters`, `locations`, a `sort` other than `relevance`, or `include: ["facets"]`. Records the filters removed, keyed by the filter that removed each one; every removed record is counted once. Keys: `locations`, `noResidence` (matched no location, and a residence test found no published address), `sources`, `recordCategory`, `custodyStatus`, `hasPhoto`, `excludeVacatedOrSealed`, `severity`, `outcome`, `convictedOnly`, `chargeCategories`, `offenseDate`, `filedDate`, `dispositionDate`, `lookbackYears`, `statute`, `chargeText`, `chargeMatch`. A record that fails the per-charge test is counted under the first charge filter that, on its own, no charge of the record meets, or under `chargeMatch` when each filter is met by some charge but no single charge meets them all. `{}` when nothing was removed. For a name too common to search unfiltered, the index applies your filters together before matching; those records are counted once under `filters`. Example `{"severity":3,"outcome":6,"noResidence":2}`. |
| `unknownKept` | `object` |  | Present when the request sent `filters`, `locations`, a `sort` other than `relevance`, or `include: ["facets"]`. Returned records that passed a filter only because their value is unknown (`unknown: "keep"`, the default), by filter: `severity`, `outcome`, `custodyStatus`, `offenseDate`, `filedDate`, `dispositionDate`. `{}` with `unknown: "exclude"`. Example `{"severity":4}`. |
| `quarantined` | `integer` |  | Present when the request sent `filters`, `locations`, a `sort` other than `relevance`, or `include: ["facets"]`. Candidate records whose normalized values are withheld (`normalized.quality: "quarantined"`); their own fields are returned as usual. `0` on a synchronous live search. Example `0`. |

## The SearchCoverage object — coverage on every search

Which sources stand behind a search for the state or county searched, and which sources for that scope are not available yet. Codes are the public codes [GET /v1/criminal/sources](https://offendersearch.app/docs/criminal/coverage#reference-get-v1-criminal-sources) lists. It does not change `status`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `scope` | `object` |  | The scope the counts describe. |
| `scope.state` | `string` or null |  | The two-letter state searched, or `null` when the query named none. |
| `scope.county` | `string` or null |  | The county searched, or `null`. |
| `scope.countyRecognized` | `boolean` |  | Present when a county was searched: `true` when the catalog lists sources of that county's own, `false` when it lists none. |
| `sourcesOnFile` | `integer` |  | Sources available for the scope. |
| `sourcesPending` | `integer` |  | Sources for the scope that are not available yet. |
| `pendingSources` | array of [SearchCoverageSource](https://offendersearch.app/docs/criminal/result-completeness#object-searchcoveragesource) |  | The pending sources, named. Present when the query names a `state`. |
| `sources` | array of [SearchCoverageSource](https://offendersearch.app/docs/criminal/result-completeness#object-searchcoveragesource) |  | Every source in scope with its `status`. Present when the request sends `include: ["coverage"]` and the query names a `state`. |

## The SearchCoverageSource object — the sources behind a search

One source in a search's coverage block.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `code` | `string` |  | Public jurisdiction code, the same code as on `sources[].code` and in `live.jurisdictions`. |
| `name` | `string` |  | Jurisdiction label: county or state plus the kind of record. |
| `recordType` | `string` |  | The kind of record, in words (`"County jail roster"`, `"Court records"`, `"Prison system"`). |
| `scope` | `string` — one of `"county"`, `"state"`, `"federal"` |  | `county`, `state` or `federal`. |
| `status` | `string` — one of `"live"`, `"pending"` |  | `live` (available) or `pending` (not available yet). Present on `sources[]` entries; every `pendingSources[]` entry is pending. |

## The criminal SourceStatus object — one live source

One live source's outcome within a search. Present only on a live search; a cached search returns `sourceStatus: []`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `code` | `string` or null |  | Public jurisdiction code checked. `null` on an `unsupported_site` row (a website that resolved to no covered jurisdiction). |
| `source` | `string` or null |  | The same value as `code`, for parity with the Sex Offender API's `sourceStatus[].source`. |
| `ok` | `boolean` |  | `true` when the source was reached and read. |
| `incomplete` | `boolean` |  | `true` when this source could not be checked to the end. It then counts in `counts.sourcesIncomplete`, turns `status` to `partial`, and its `records` is a lower bound. |
| `incompleteReason` | `string` or null — one of `"captcha_unsolved"`, `"proxy_required"`, `"source_down"`, `"rate_limited"`, `"truncated_over_cap"`, `"paid_gateway"`, `"login_required"`, `"timeout"`, `"unsupported_site"`, `"not_searched"` |  | Why the source fell short, from a closed list. Non-null exactly when `incomplete` is `true`. A failed check is not billed. |
| `records` | `integer` |  | Records this source contributed to the merged answer. |
| `lastCheckedAt` | `string (date-time)` |  | When this source was checked, in this request. |
| `live` | `boolean` |  | `true` for a source checked live in this request; `false` on an on-file fallback row. |
| `answeredFrom` | `string` — one of `"on_file"` |  | Present only on an on-file fallback row: the live check could not run, the source's stored records were in the query's scope, so the row was answered from file — `ok: true`, `incomplete: false`, `live: false`, not billed, and `warnings[]` gains a `LIVE CHECK NOT AVAILABLE:` sentence. |
| `liveOutcome` | `string` — one of `"captcha_unsolved"`, `"proxy_required"`, `"source_down"`, `"rate_limited"`, `"truncated_over_cap"`, `"paid_gateway"`, `"login_required"`, `"timeout"`, `"unsupported_site"`, `"not_searched"` |  | Present only with `answeredFrom`: why the live check did not run (an `incompleteReason` token; `not_searched` when the source has no live connector). |

---

# Pagination & response size

> page and perPage are top-level fields on the criminal endpoint, one page carries at most 200 records, and counts.records is the total before the page slice.

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

## page and perPage are top-level

`page` and `perPage` are **top-level request fields**, not inside `query` — a deliberate difference from the Sex-Offender API. Sending them inside `query` silently gives you the defaults (`page: 1`, `perPage: 50`). They are echoed back at the top level exactly as you sent them. A `page` below 1, or a `perPage` that is not an integer from 1 to 200, is refused with `422 invalid_page`.

| Parameter | Where | Default | Constraint |
| --- | --- | --- | --- |
| `page` | request top level | `1` | `>= 1` |
| `perPage` | request top level | `50` | **1–200**; outside it, `422 invalid_page` |

## Behaviour

- **`counts.records` is the total** and does not change as you page. Walk `page = 1 … totalPages` to retrieve the whole result set; each record appears exactly once.
- **Every matching stored record is read** for the surname, first name and location filters, and the whole result is ranked before it is paged. A search too broad to read in full within the time budget is refused with `422` (`error.code: query_too_broad`) rather than answered partially; `counts.candidatesCapped` is always `false`. Add a `firstName` or a `state`.
- **Every page is its own search request**, billed as one `criminal_call`; on a live search each page request runs the live checks again.
- **`totalPages`** is `ceil(records / perPage)`, minimum `1`.
- **A page past the end returns an empty `records` array** — `page` echoes the number you asked for, `counts.records` and `totalPages` are unchanged, and `recordsReturned` is `0`. So `while (records.length) page++` terminates.

```json
// perPage 200 on a 512-record result spans 3 pages
{ "page": 1, "perPage": 200, "totalPages": 3, "counts": { "records": 512, "recordsReturned": 200 } }

// page 99 of that same result — past the end
{ "page": 99, "perPage": 200, "totalPages": 3, "counts": { "records": 512, "recordsReturned": 0 }, "records": [] }
```

**A cap is not a page.** One response carries at most 200 records, and `perPage` slices an answer you can walk in full. Send a `perPage` from 1 to 200; `0`, `201` or a non-integer is refused with `422 invalid_page`, so the echoed `perPage` and `totalPages` always describe the pages you can walk. Read `counts.records` against `counts.recordsReturned`: if they differ, there is more to fetch.

---

# Coverage today & the sources endpoint

> GET /v1/criminal/sources is the machine-readable source of truth for coverage, with per-jurisdiction currency. No API key required, so you can audit it first.

HTML: https://offendersearch.app/docs/criminal/coverage · Markdown: https://offendersearch.app/docs/criminal/coverage.md

## Coverage today

The dataset spans five kinds of criminal record: **county jail** rosters, **statewide DOC** (state-prison) inmate systems, **court** records and dispositions, **warrant** systems, and statewide **registries**, each addressed by its own jurisdiction code and each source marked live or coming soon. The corpus is continuously updated. For every individual source, see All data sources.

> The authoritative, always-current answer to what is covered is the API itself, not this page. Read `GET /v1/criminal/sources` at integration rather than hard-coding a list — coverage grows over time.

## GET /v1/criminal/sources

The coverage catalog plus per-jurisdiction currency — the honest, public answer to "how current is your data?", one row per jurisdiction with the timestamp of its last update. **No API key required**, so you can check it before you buy and audit it afterwards. Each row names only a state, a county and a record kind — never a portal or vendor.

```bash
curl "https://api.offendersearch.app/v1/criminal/sources"
```

```json
{
  "catalog": { "total": 1544, "live": 1162, "comingSoon": 382, "states": 51 },
  "coverage": { "jurisdictions_total": 1566, "jurisdictions_live_connector": 1183,
                "jurisdictions_pending_connector": 383, "states_covered": 52, "…": "…" },
  "sources": [
    { "id": "TX-DOC", "name": "Texas — statewide prison system", "covers": ["TX"],
      "scope": "state", "recordType": "Prison system", "status": "live",
      "health": { "lastSuccessAt": "2026-10-13T04:58:00+00:00",
                  "ageSeconds": 30914, "freshFor": ["daily", "weekly"] } }
  ],
  "count": 1544
}
```

The counts are the catalog at the time of writing; they change as coverage grows, so read them from the response. Each `health` block is computed when the catalog is served, which is cached for up to five minutes. Every field is in the request and response tables below.

> **None of this is a filter.** A jurisdiction contributes its records to a cached search whatever its age says; freshness never withholds anything. Read this endpoint to *know* a jurisdiction's currency, not to predict which ones will answer.

## Criminal data sources API response fields

`GET /v1/criminal/sources` · no API key

The coverage catalog: every source by public code, whether it is available, and when its records were last updated. No API key needed, so coverage can be checked before you sign up. Not billed.

### Request

No parameters and no API key.

### Response

**200** (application/json) — The catalog. Cached for 5 minutes (`Cache-Control: public, max-age=300`).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `catalog` | `object` |  | Totals over the same rows as `sources[]`, so the two always agree. |
| `catalog.total` | `integer` |  | Rows in `sources[]`. |
| `catalog.live` | `integer` |  | Rows with `status: "live"`. |
| `catalog.comingSoon` | `integer` |  | Rows with `status: "pending"`. |
| `catalog.states` | `integer` |  | Distinct states among the rows, the federal `US` row excluded. |
| `coverage` | [Coverage](https://offendersearch.app/docs/criminal/coverage#object-coverage) |  | Totals by jurisdiction. |
| `sources` | array of [SourceInfo](https://offendersearch.app/docs/criminal/coverage#object-sourceinfo) |  | Every source in the catalog. |
| `count` | `integer` |  | Rows in `sources[]`. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 503 | `unavailable` | Criminal Search could not be reached. |
| 503 | `catalog_unavailable` | The catalog could not be read. |

## The criminal SourceInfo object — one catalog row

One source in the coverage catalog.

Returned or accepted by [`GET /v1/criminal/sources`](https://offendersearch.app/docs/criminal/coverage#reference-get-v1-criminal-sources).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Public jurisdiction code, the same code as on `sources[].code` and in `live.jurisdictions`. Example `"TX-BEXAR"`. |
| `name` | `string` |  | Jurisdiction label: county or state plus the kind of record. Example `"Bexar County, TX"`. |
| `covers` | `array of string` |  | The state it covers, or `US` for a federal source. Example `["TX"]`. |
| `scope` | `string` — one of `"county"`, `"state"`, `"federal"` |  | `county`, `state` or `federal`. |
| `recordType` | `string` |  | The kind of record, in words: `County jail roster`, `Court records`, `Exclusion list` and the other kind labels. Example `"County jail roster"`. |
| `status` | `string` — one of `"live"`, `"pending"` |  | `live` (available today) or `pending` (in the catalog, not available yet). |
| `health` | `object` |  | When this source's records on file were last updated. |
| `health.lastSuccessAt` | `string` or null |  | Time of the latest complete update of this source's records, ISO-8601. `null` when none is on file. Example `"2026-08-24T05:12:00+00:00"`. |
| `health.ageSeconds` | `integer` or null |  | Seconds since `lastSuccessAt`. `null` with it. |
| `health.freshFor` | `array of string` — one of `"daily"`, `"weekly"` |  | `daily` when `ageSeconds` is at most 36 hours, `weekly` when at most 8 days; `[]` otherwise. It does not change what a search returns. Example `["daily","weekly"]`. |

## The criminal Coverage object — coverage totals

Coverage totals for the whole product, by jurisdiction.

Returned or accepted by [`GET /v1/criminal/sources`](https://offendersearch.app/docs/criminal/coverage#reference-get-v1-criminal-sources), [`GET /v1/criminal/jurisdictions`](https://offendersearch.app/docs/criminal/jurisdictions#reference-get-v1-criminal-jurisdictions).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `jurisdictions_total` | `integer` |  | Every jurisdiction in the catalog. |
| `jurisdictions_live_connector` | `integer` |  | Jurisdictions covered today. |
| `jurisdictions_pending_connector` | `integer` |  | Jurisdictions in the catalog that are not covered yet. |
| `states_covered` | `integer` |  | Distinct state codes in the catalog, `US` included. |
| `jurisdictions_verified` | `integer` |  | Part of `jurisdictions_live_connector`: covered jurisdictions on a shared or statewide record system. |
| `jurisdictions_custom_verified` | `integer` |  | Part of `jurisdictions_live_connector`: covered jurisdictions on a record system of their own. |
| `jurisdictions_config_pending` | `integer` |  | Part of `jurisdictions_pending_connector`: jurisdictions not covered yet. |
| `jurisdictions_statewide_fallback` | `integer` |  | Part of `jurisdictions_live_connector`: covered jurisdictions reached only through a statewide system. |

---

# All data sources

> Every county, state and federal criminal data source we cover and what is coming soon, with the code each record carries. The same list as the sources API.

HTML: https://offendersearch.app/docs/criminal/data-sources · Markdown: https://offendersearch.app/docs/criminal/data-sources.md

## Every source, in one list

The complete list of criminal data sources — every county, state and federal source that is **live** today, and every one **coming soon** — with the code each record carries in `sources[].code`. The live list is served as markdown at https://offendersearch.app/docs/criminal/data-sources.md and as JSON by the sources endpoint, with no API key required:

```bash
curl "https://api.offendersearch.app/v1/criminal/sources"
```

Rows whose record type reads **Exclusion list** are the government health-program and federal award exclusion lists: `US-EXCLUSION` covers the HHS Office of Inspector General’s List of Excluded Individuals/Entities (republished monthly) and the SAM.gov exclusions placed by federal agencies (published daily); `<ST>-EXCLUSION` is one state’s Medicaid exclusion or sanction list (mostly monthly). Their records are exclusion-list records (`recordType: "healthcare_exclusion"`), returned by default when the exclusion rests on a criminal conviction and on request otherwise (`include: ["exclusions"]`).

---

# Jurisdictions & codes

> The two neutral code shapes - ST-COUNTY for a county, ST-RECORD-KIND for a statewide or federal system - and the coverage catalog filtered by state and kind.

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

## A code names a state and a jurisdiction, and nothing else

A **jurisdiction code** is the neutral handle for one place we cover. It is what `sources[].code` reports on a record, what you pass as `live.jurisdictions`, and what the website resolver returns. There is deliberately **no token for how we obtain the data** — no portal, platform or vendor ever appears in a code, a source name, a coverage total, or an error.

## The two shapes

| Shape | For | Form | Examples |
| --- | --- | --- | --- |
| County | a county-level jurisdiction | `<ST>-<COUNTY>` | `AL-BALDWIN`, `TX-DALLAS`, `TX-HARRIS` |
| Statewide / federal | a state or federal system, by the **kind of record** it holds | `<ST>-<RECORD-KIND>` | `TX-DOC`, `OK-COURT`, `US-WARRANT` |

`<ST>` is the two-letter USPS state code (`US` for a federal system). `<COUNTY>` is the county name, upper-cased, spaces hyphenated — `Palm Beach` → `PALM-BEACH`. Most county codes are the county name alone; some carry an extra word that tells two places apart (`TX-ANDREWS-COUNTY`, `IN-DUBOIS-COUNTY-JAIL`), so read codes from the catalog rather than build them.

## The record-kind vocabulary

| Suffix | Means | Example |
| --- | --- | --- |
| `DOC` | a state prison / Department of Corrections inmate system | `TX-DOC` |
| `COURT` | statewide court records / dispositions | `OK-COURT` |
| `WARRANT` | a warrant system | `US-WARRANT` |
| `REGISTRY` | a statewide registry | `FL-REGISTRY` |
| `JAIL` | a statewide county-jail roster system | `IL-JAIL` |

## GET /v1/criminal/jurisdictions

Covered jurisdictions as codes — every jurisdiction in the product's manifest, filterable by `state`, by `recordKind` (`jail-roster`, `court`, `doc-inmate`, `registry`, `warrant`) and by `live=true` for only those reachable for live verification. Requires an API key.

```bash
curl "https://api.offendersearch.app/v1/criminal/jurisdictions?state=TX&live=true" \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

```json
{
  "coverage": { "jurisdictions_total": 1566, "jurisdictions_live_connector": 1183,
                "jurisdictions_pending_connector": 383, "states_covered": 52, "…": "…" },
  "jurisdictions": [
    { "code": "TX-DOC", "state": "TX", "county": "(statewide)",
      "recordKind": "doc-inmate", "level": "state", "live": true },
    { "code": "TX-ANGELINA", "state": "TX", "county": "Angelina",
      "recordKind": "jail-roster", "level": "county", "live": true }
  ],
  "count": 41
}
```

> The `coverage` block reports the true totals from the product's own manifest — covered today vs known-but-pending — so a jurisdiction not yet covered is counted, never silently omitted. **Don't parse a code for meaning beyond state + place**; enumerate the live values from the catalog.

## Criminal jurisdictions API request and response fields

`GET /v1/criminal/jurisdictions` · auth: `X-API-Key`

Every covered jurisdiction by public code, state, county and record kind, filtered by the query parameters. Not billed.

### Request

**Query parameters**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `state` | `string` | optional | Two-letter state, case-insensitive. Keeps rows in that state. Example `"TX"`. |
| `recordKind` | `string` — one of `"jail-roster"`, `"court"`, `"doc-inmate"`, `"registry"`, `"warrant"` | optional | Keeps rows of that kind. Example `"court"`. |
| `live` | `boolean` | optional | `true` keeps only jurisdictions that can be re-verified live. Any other value applies no filter. Example `true`. |

### Response

**200** (application/json) — The filtered list.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `coverage` | [Coverage](https://offendersearch.app/docs/criminal/coverage#object-coverage) |  | Totals by jurisdiction for the whole product (not filtered). |
| `jurisdictions` | array of [JurisdictionInfo](https://offendersearch.app/docs/criminal/jurisdictions#object-jurisdictioninfo) |  | The matching jurisdictions. |
| `count` | `integer` |  | Rows in `jurisdictions[]` after filtering. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | Missing or invalid `X-API-Key`. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 402 | — | Free trial used up, or no active subscription. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 503 | `unavailable` | Criminal Search could not be reached. Retry with backoff. |
| 504 | `upstream_timeout` | Criminal Search took longer than 65 seconds to answer. Retry, or narrow the query. |

## The JurisdictionInfo object — one covered jurisdiction

One covered jurisdiction: its public code and its place, nothing else.

Returned or accepted by [`GET /v1/criminal/jurisdictions`](https://offendersearch.app/docs/criminal/jurisdictions#reference-get-v1-criminal-jurisdictions).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `code` | `string` |  | Public jurisdiction code. Example `"TX-BEXAR"`. |
| `state` | `string` |  | Two-letter state, or `US`. Example `"TX"`. |
| `county` | `string` or null |  | County name; `"(statewide)"` on a statewide row; `null` when the catalog names none. Example `"Bexar"`. |
| `recordKind` | `string` — one of `"jail-roster"`, `"court"`, `"doc-inmate"`, `"registry"`, `"warrant"`, `"exclusion"` |  | `jail-roster`, `court`, `doc-inmate`, `registry`, `warrant` or `exclusion`. |
| `level` | `string` — one of `"county"`, `"state"`, `"federal"` |  | `county`, `state` or `federal`. |
| `live` | `boolean` |  | `true` when the jurisdiction can be re-verified live (`live.jurisdictions`). |

---

# Resolve a website to a code

> Map a roster or court website you already know to the neutral jurisdiction code to use in a search. A one-directional lookup: your site in, our code out.

HTML: https://offendersearch.app/docs/criminal/resolve · Markdown: https://offendersearch.app/docs/criminal/resolve.md

## Your site in, our neutral code out

You may already know the **roster or records website** for a place you care about — a county sheriff's inmate page, a court portal — without knowing our jurisdiction code for it. The resolver maps a website to that code, so you can drive a search from a URL. It is **one-directional**: your site in, a neutral code out. It never returns, logs, or reveals our own data sources.

## POST /v1/criminal/resolve

**Authentication:** `X-API-Key`. Available on every account exactly like search — no entitlement required. **Not metered** — a cheap in-memory lookup that consults no data source, so it emits no `criminal_call`. A request with neither `website` nor `websites` returns `422 no_website`.

```bash
curl -X POST "https://api.offendersearch.app/v1/criminal/resolve" \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
  -d '{ "website": "https://sheriff.examplecounty.gov/inmates" }'
```

One website → one result object. A list of `websites` → a `results[]` wrapper, one entry per input, in order.

```json
{
  "input": "https://sheriff.examplecounty.gov/inmates",
  "supported": true,
  "code": "TX-BEXAR",
  "state": "TX",
  "county": "Bexar",
  "note": "equivalent data available for this jurisdiction"
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `input` | string | Your website, echoed back verbatim so you can line results up with inputs. |
| `supported` | boolean | `true` = we have equivalent data and `code` is populated. `false` = not covered. |
| `code` | string \\| null | The **public jurisdiction code** to use in a search. `null` when unsupported. |
| `state` | string \\| null | The jurisdiction's state, when we can identify it — even for an unsupported site. |
| `county` | string \\| null | The jurisdiction's county, when we can identify it. |
| `note` | string | A plain-language explanation — e.g. `"equivalent data available for this jurisdiction"` or `"Baldwin, AL is not currently covered"`. |

> An unsupported site is `supported: false` with a plain-language reason — **not** an error. Only a request with no site at all is a `422 no_website`.

## Using a website inline in a search

You don't have to resolve first. `query.website` **scopes** a cached search to the resolved jurisdiction; `live.websites` **live-verifies** the resolved jurisdictions, exactly as if you had listed their codes. Whenever a search resolves a website, the response carries a top-level `resolution[]` array so you can see which code each site mapped to. A `query.website` we do not cover adds no scope: the search runs nationwide (or with only the `state`/`county` you sent), and `warnings[]` says so with a `WEBSITE NOT COVERED:` sentence.

## Website resolver API request and response fields

`POST /v1/criminal/resolve` · auth: `X-API-Key`

Map a roster or records website you already use to the public jurisdiction code that covers it, or say it is not covered. Not billed.

### Request

**Body** (application/json)

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `website` | `string` | optional | One roster or records website. Returns one [ResolveResult](https://offendersearch.app/docs/criminal/resolve#object-resolveresult). Example `"https://sheriff.examplecounty.gov/inmates"`. |
| `websites` | `array of string` | optional | A list of websites, resolved in order. Returns a [ResolveListResponse](https://offendersearch.app/docs/criminal/resolve#object-resolvelistresponse). |

### Response

**200** (application/json) — A [ResolveResult](https://offendersearch.app/docs/criminal/resolve#object-resolveresult) for `website`, or a [ResolveListResponse](https://offendersearch.app/docs/criminal/resolve#object-resolvelistresponse) for `websites`.

The body is [the `ResolveResult` object](https://offendersearch.app/docs/criminal/resolve#object-resolveresult). What one website resolved to. The response names the state, the county and the public code, never the website's host.

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | Missing or invalid `X-API-Key`. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 402 | — | Free trial used up, or no active subscription. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 422 | `no_website` | Neither `website` nor `websites` was sent. |
| 503 | `unavailable` | Criminal Search could not be reached. Retry with backoff. |
| 504 | `upstream_timeout` | Criminal Search took longer than 65 seconds to answer. Retry, or narrow the query. |

## The ResolveResult object — one website's result

What one website resolved to. The response names the state, the county and the public code, never the website's host.

Returned or accepted by [`POST /v1/criminal/resolve`](https://offendersearch.app/docs/criminal/resolve#reference-post-v1-criminal-resolve).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `input` | `string` |  | Your website, echoed back exactly. Example `"https://sheriff.examplecounty.gov/inmates"`. |
| `supported` | `boolean` |  | `true` when the jurisdiction is covered and `code` is set. |
| `code` | `string` or null |  | The public jurisdiction code, or `null` when unsupported. Example `"TX-BEXAR"`. |
| `state` | `string` or null |  | The jurisdiction's state when it can be identified, even for an unsupported site. `null` otherwise. Example `"TX"`. |
| `county` | `string` or null |  | The jurisdiction's county when it can be identified. `null` otherwise. Example `"Bexar"`. |
| `note` | `string` |  | One sentence explaining the outcome (`"equivalent data available for this jurisdiction"`, `"not currently covered"`). Example `"equivalent data available for this jurisdiction"`. |

## The ResolveListResponse object — resolving a list of websites

The answer when `websites` (a list) is sent.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `count` | `integer` |  | Number of results, equal to the number of websites sent. |
| `results` | array of [ResolveResult](https://offendersearch.app/docs/criminal/resolve#object-resolveresult) |  | One result per website, in the order sent. |

## The ResolutionEntry object — resolution[] on a search

What one website resolved to, as reported in a search's `resolution[]`. The same fields as a [ResolveResult](https://offendersearch.app/docs/criminal/resolve#object-resolveresult) without `state` and `county`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `input` | `string` |  | Your website, echoed back exactly. |
| `supported` | `boolean` |  | `true` when the jurisdiction is covered and `code` is set. |
| `code` | `string` or null |  | The public jurisdiction code, or `null` when unsupported. |
| `note` | `string` |  | One sentence explaining the outcome. |

---

# Clearances

> Record once that a person a criminal search returned is not your subject; later searches for the subjectId hide them. One source per personRef, no extra charge.

HTML: https://offendersearch.app/docs/criminal/clearances · Markdown: https://offendersearch.app/docs/criminal/clearances.md

## Clearances: dismiss a false-positive criminal match once, per subject

A criminal search for a common name returns people who are not the person you are screening. A clearance records, for one **subject** — a resident, a visitor, an applicant, an employee, identified by your own `subjectId` — that one returned person **is** or **is not** that subject. Every later `POST /v1/criminal/search` that sends the same `subjectId` and answers synchronously (`200`) honours it: a person marked `not_this_person` leaves `records[]` and is listed in `cleared[]`. A polled async result, a batch row and a report are not filtered by clearances today.

Clearances are keyed on `personRef`, the identifier that groups the records one source holds for the same person (see [the Criminal Record object](https://offendersearch.app/docs/criminal/record-object.md)). They are **included in your search price, no extra charge**, they share the `/v1/clearances` routes and the same `subjectId` with the [Sex Offender API](https://offendersearch.app/docs/clearances.md), and `product` says which dataset a verdict belongs to.

> **A clearance applies only to the exact person cleared, never to a name or a date of birth.** The person you cleared was returned precisely because they share a name — and often a birthday — with your subject. Matching a clearance by name would hide the subject’s own record if it ever appeared. So the match is `personRef` only, and a record whose `personRef` cannot be resolved is never suppressed.

> **A criminal personRef is scoped to one source.** A county jail, a state corrections department and a court each issue their own. Clearing a person from one source clears that source’s records for them — including a later booking or case there, which comes back once flagged `offenses_changed` for a fresh look. It does not clear a different source’s record for the same name: review each source’s person separately.

## Who uses criminal clearances

| Who | What a clearance does for them |
| --- | --- |
| **Visitor management systems** | A regular visitor who shares a name with someone in a county roster is shown to the desk once, reviewed once and cleared once. Every later sign-in for the same visitor id runs the full search and comes back clean. |
| **Skilled nursing and assisted living** | Residents are screened at referral and re-screened on a schedule; standing volunteers the same. A clearance recorded against the resident id is honoured at every re-screen. |
| **Home care and staffing** | Caregivers and applicants are screened at hire and again between credentialing cycles. The look-alike cleared at hire stays cleared at the re-check; a new booking or case still comes through. |
| **Schools, nonprofits and volunteer programs** | Coaches, chaperones and parent helpers who return every season are reviewed by a person once. |
| **Tenant screening** | An applicant’s file is one subject. A look-alike reviewed once stays cleared across the checks that file goes through, with who decided and why on record. |
| **Platforms that screen for many facilities** | Send a `clientId` per facility with every search and every clearance. One facility’s verdicts never reach another facility’s results. |

## The clearance flow: search, review, clear, search again

### 1. Search with a subjectId

Send your id for the person being screened as `subjectId` — and, if you screen for more than one facility or client, your id for that client as `clientId`. Nothing else about the request changes. The response gains `counts.clearedSuppressed` and `cleared[]`; on a first search both are empty.

```bash
curl -X POST https://api.offendersearch.app/v1/criminal/search \\
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \\
  -H "Content-Type: application/json" \\
  -d '{
        "query": { "lastName": "Rivera", "firstName": "Jordan", "dob": "1988-04-12", "state": "TX" },
        "subjectId": "resident-48213",
        "clientId": "facility-07"
      }'
```

```json
{
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1,
              "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false,
              "sourcesOnFile": 44, "sourcesPending": 27, "clearedSuppressed": 0 },
  "warnings": [],
  "page": 1, "perPage": 50, "totalPages": 1,
  "sourceStatus": [],
  "cappedOmittedSources": [],
  "omittedFields": ["sourceData"],
  "cleared": [],
  "records": [
    { "recordType": "jail_booking",
      "externalId": "TX-BEXAR:booking:734115",
      "personRef": "prs_8b1d4e6f0a2c3c9e7f1a",
      "name": { "first": "JORDAN", "middle": "", "last": "RIVERA", "suffix": "" },
      "dob": { "date": "1988-04-12", "birthYear": 1988, "age": 38, "dobPrecision": "full" },
      "matchState": "dob_match", "matchConfidence": 0.99,
      "sources": [ { "code": "TX-BEXAR", "level": "county",
                     "recordUrl": "https://…/booking/734115", "live": false } ] }
  ],
  "proof": { "status": "none" },
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "meterEvents": [ { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null } ],
  "legal": { "notice": "Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.)." }
}
```

### 2. A person reviews the match

The booking shares the resident’s name and date of birth, so `matchState` is `dob_match`. A reviewer compares the booking photograph where one is published, the charges and the source’s own page (`sources[].recordUrl`) with the resident and decides. Keep the `searchId` and the `personRef`: the verdict cites both.

### 3. Record the verdict

`POST /v1/clearances` with `product: "criminal"`. `searchId` must be a criminal search in your account’s 14-day history whose stored results contain the `personRef` — the proof that the person was reviewed from a real result. A criminal record has no `recordId`, so leave that field out; a second verdict for the same subject, client and person replaces the first.

```bash
curl -X POST https://api.offendersearch.app/v1/clearances \\
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \\
  -H "Content-Type: application/json" \\
  -d '{
        "product": "criminal",
        "subjectId": "resident-48213",
        "clientId": "facility-07",
        "personRef": "prs_8b1d4e6f0a2c3c9e7f1a",
        "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
        "verdict": "not_this_person",
        "reviewer": "admissions-desk",
        "note": "Booking photo does not match the resident."
      }'
```

```json
{
  "id": "clr_2a9c05e7d41b83f0",
  "product": "criminal",
  "verdict": "not_this_person",
  "personRef": "prs_8b1d4e6f0a2c3c9e7f1a",
  "recordId": null,
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "subjectId": "resident-48213",
  "clientId": "facility-07",
  "reviewer": "admissions-desk",
  "note": "Booking photo does not match the resident.",
  "createdAt": "2026-10-18T14:02:11+00:00",
  "revokedAt": null,
  "supersedes": null
}
```

### 4. Search again — the cleared person is gone, and accounted for

The next search for the same `subjectId` and `clientId` runs in full, then removes the cleared person. `counts.records` counts what you received; `counts.clearedSuppressed` says how many were removed, and `cleared[]` names each one with the clearance that applied.

```json
{
  "status": "complete",
  "counts": { "records": 0, "recordsReturned": 0,
              "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false,
              "sourcesOnFile": 44, "sourcesPending": 27, "clearedSuppressed": 1 },
  "warnings": [],
  "page": 1, "perPage": 50, "totalPages": 1,
  "cleared": [
    { "personRef": "prs_8b1d4e6f0a2c3c9e7f1a",
      "clearanceId": "clr_2a9c05e7d41b83f0",
      "verdict": "not_this_person",
      "clearedAt": "2026-10-18T14:02:11+00:00",
      "recordId": null }
  ],
  "records": [],
  "searchId": "crs_0b6e2d91a7c84f3e5d10",
  "legal": { "notice": "Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.)." }
}
```

Send `clearanceMode: "annotate"` to keep cleared people in `records[]` with a `clearance` object attached, or `"ignore"` to answer as if no clearances existed. A `this_person` verdict never removes a record; it is kept and annotated in every mode.

## What brings a cleared person back

A clearance is a verdict about the person as they were reviewed. When something about them changes in that source, or the match becomes stronger than the one that was cleared, the person returns to `records[]` with `clearance.stale: true` and a `staleReason` — in every mode, and it cannot be turned off. Review the record again and record a new verdict; until you do, the person stays in the results.

| staleReason | What changed since the clearance |
| --- | --- |
| `identity_changed` | The person’s name or date of birth. |
| `offenses_changed` | Their cases or charges — including a new booking or case in the same source. |
| `status_changed` | Their custody status, release date or facility. |
| `stronger_match` | This search matched them more strongly than the search that was cleared — for example the subject’s date of birth now matches where only the name did. |

```json
{
  "recordType": "jail_booking",
  "externalId": "TX-BEXAR:booking:801962",
  "personRef": "prs_8b1d4e6f0a2c3c9e7f1a",
  "name": { "first": "JORDAN", "middle": "", "last": "RIVERA", "suffix": "" },
  "matchState": "dob_match",
  "clearance": {
    "clearanceId": "clr_2a9c05e7d41b83f0",
    "verdict": "not_this_person",
    "clearedAt": "2026-10-18T14:02:11+00:00",
    "stale": true,
    "staleReason": "offenses_changed"
  }
}
```

## Clearance rules: personRef only, per source, per client, until revoked

- **personRef only.** A clearance names one `personRef` and suppresses only records that carry it. Never a name, never a date of birth. A record whose `personRef` cannot be resolved is never suppressed.
- **One source at a time.** A criminal `personRef` belongs to one source, so a clearance covers that source’s records for the person and no other source’s. The same name in another county or a state system is a different `personRef` and a separate review.
- **Per subject, per client.** A verdict is scoped to the `subjectId` it was recorded for, and to the `clientId` when one was sent. Nothing one client clears affects another client’s results.
- **Stored as hashes.** `subjectId` and `clientId` are kept only as per-account hashes, never as the ids you sent. They are echoed on the request that carried them and are `null` on `GET /v1/clearances/{clearanceId}`.
- **Proof of review.** The `searchId` must be in your 14-day search history and that search must have returned the `personRef` — otherwise `404 search_not_found` or `409 person_not_in_search`.
- **No expiry.** A clearance applies until you revoke it. Re-surfacing is how a changed person comes back; nothing comes back on a timer.
- **Revoke, don’t delete.** `DELETE` sets `revokedAt`; the next search for that subject shows the person again, and the clearance stays readable with `includeRevoked=true`.
- **Scoped to the account.** Clearances survive API key rotation and are shared by every key and dashboard user on the account.
- **Included in your search price, no extra charge.** Nothing on `/v1/clearances` is metered, and a search with a `subjectId` is billed exactly like one without.
- **Additive.** A request without `subjectId` returns exactly what it did before clearances existed — no `cleared` key, no `clearedSuppressed`, no `clearance` on any record.

## Clearance endpoints

The `/v1/clearances` routes are shared with the Sex Offender API and documented in full at [https://offendersearch.app/docs/clearances.md](https://offendersearch.app/docs/clearances.md); `product: "criminal"` is what makes a verdict a criminal one. Every route authenticates with the `X-API-Key` header or a dashboard session.

| Method | Path | What it does |
| --- | --- | --- |
| POST | `/v1/clearances` | Record a verdict for one subject and one `personRef`. `201` with the Clearance. |
| GET | `/v1/clearances` | List a subject’s clearances (`subjectId` required; `clientId`, `product`, `includeRevoked` optional). |
| GET | `/v1/clearances/{clearanceId}` | Retrieve one clearance by id. |
| DELETE | `/v1/clearances/{clearanceId}` | Revoke it. Returns `{ id, revokedAt }`. |

The search-side fields — `subjectId`, `clientId` and `clearanceMode` on the request; `cleared[]`, `counts.clearedSuppressed` and the record’s `clearance` on the response — are in the [Search](https://offendersearch.app/docs/criminal/search.md) reference, and the two objects they carry are defined below.

## The ClearedEntry object — one person a clearance hid from this response

One record a `not_this_person` clearance removed from `records[]` for the `subjectId` sent. `cleared[]` carries one of these per suppressed record, so an interface can say how many previously cleared people are hidden and link each to its clearance.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `personRef` | `string` |  | The cleared person, as the search identified them. Example `"prs_3c9e1f0a7b2d4e6f8a1b"`. |
| `clearanceId` | `string` |  | The clearance that applied. `GET /v1/clearances/{clearanceId}` returns it. Example `"clr_7d2f41c0a9b3e8f6"`. |
| `verdict` | `string` — one of `"not_this_person"` |  | Always `not_this_person`; a `this_person` verdict never removes a record. |
| `clearedAt` | `string (date-time)` |  | When the clearance was recorded. |
| `recordId` | `string` or null |  | `null`: a criminal record is cited by `externalId` with its `sources[].code`, and the cleared person is identified by `personRef`. |

## The RecordClearance object — the clearance attached to a kept record

The clearance that names a record's person, attached as `clearance` when the request carried `subjectId` and the record was kept: in `clearanceMode: "annotate"`, on a `this_person` verdict, or because a cleared person has re-surfaced (`staleReason` set). A record no clearance names carries no `clearance` key.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `clearanceId` | `string` |  | The clearance. `GET /v1/clearances/{clearanceId}` returns it. Example `"clr_7d2f41c0a9b3e8f6"`. |
| `verdict` | `string` — one of `"not_this_person"`, `"this_person"` |  | The verdict recorded for this person and subject. |
| `clearedAt` | `string (date-time)` |  | When the verdict was recorded. |
| `stale` | `boolean` |  | `true` when this person was cleared as `not_this_person` and is shown anyway, because something about them changed since the clearance or this search matched them more strongly than the search that was cleared. Review the record again and record a new verdict; until you do, the person stays in `records[]`. |
| `staleReason` | `string` or null — one of `"identity_changed"`, `"offenses_changed"`, `"status_changed"`, `"stronger_match"` |  | Why the person re-surfaced. `identity_changed` — their name or date of birth changed. `offenses_changed` — their cases or charges changed, including a new booking or case in the same source. `status_changed` — their custody status, release date or facility changed. `stronger_match` — this search matched the person more strongly than the search that was cleared (the subject's date of birth now matches where only the name did). `null` when the person has not re-surfaced. |

---

# Async live jobs & polling

> A live search that cannot answer in-request runs as an async job: a 202 with a searchId, then poll GET /v1/criminal/searches/{searchId} until it returns 200.

HTML: https://offendersearch.app/docs/criminal/async · Markdown: https://offendersearch.app/docs/criminal/async.md

## When a live search goes async

A live check of **≤2 sync-eligible sources** runs synchronously — one `200`, results inline. **More than two sources, or any source that cannot answer in-request**, runs asynchronously: a `202` with a `searchId` and a `poll` URL. Both paths run the same live checks and return the same envelope — the only difference is whether you hold the connection open or collect the result later.

Cached searches and synchronous live searches need no polling — their `200` is the answer. Most jurisdictions are not sync-eligible, so most live searches return `202`: build your client for it.

```json
// The 202 body
{
  "searchId": "crs_9a3b71c0e28d4f6a5b12",
  "status": "pending",
  "mode": "async",
  "liveSourcesRequested": 4,
  "poll": "/v1/criminal/searches/crs_9a3b71c0e28d4f6a5b12",
  "reason": "4 live sources (> 2 or not sync-eligible) — running asynchronously"
}
```

## GET /v1/criminal/searches/{searchId}

**Authentication:** `X-API-Key`. A search is only visible to the account that created it — another account's `searchId` returns `404`. The poll returns the stored job record; the shape depends on job state.

| `status` | Meaning | Action | HTTP |
| --- | --- | --- | --- |
| `pending` | Queued / running | Poll again | `202` |
| `complete` | Done | Read `result` | `200` |

When it completes, `result` carries the **full response envelope** — read `result.counts.sourcesIncomplete` and `result.warnings` exactly as you would for a synchronous live search. There is no separate `error` job state: a live source that fails is reported *inside* the completed `result` as an incomplete `sourceStatus[]` row.

```bash
# submit, then poll until 200
SID=$(curl -s -X POST "https://api.offendersearch.app/v1/criminal/search" \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
  -d '{"query":{"lastName":"Hamilton","state":"TX"},
       "live":{"jurisdictions":["TX-BEXAR","TX-LUBBOCK","TX-DOC"]}}' | jq -r '.searchId')

while :; do
  code=$(curl -s -o /tmp/job.json -w '%{http_code}' \
    "https://api.offendersearch.app/v1/criminal/searches/$SID" -H "X-API-Key: $OFFENDERSEARCH_KEY")
  [ "$code" = "200" ] && break
  sleep 2
done
jq '.result.counts' /tmp/job.json
```

## The LiveRequest object — the live block

The `live` block. Its presence turns on live verification: the named jurisdictions are re-checked at the source during the request and the fresh results merge with the cached answer through the same matcher. Up to 2 sources that can answer quickly run synchronously (`200`); more, or any source that cannot, run as an async job (`202`).

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`POST /v1/criminal/report`](https://offendersearch.app/docs/criminal/verification-reports#reference-post-v1-criminal-report).

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `jurisdictions` | `array of string` | optional | Public jurisdiction codes to check (`TX-BEXAR`, `TX-DOC`). A code can cover more than one system; each is checked and reports its own `sourceStatus[]` row. A code that resolves to nothing still runs and is reported `ok: false` with `incompleteReason: "not_searched"`, and is not billed. Example `["TX-BEXAR","TX-DOC"]`. |
| `scope` | `string` — one of `"matched"` | optional | `"matched"` re-checks every jurisdiction that produced a cached hit for this query. |
| `websites` | `array of string` | optional | Roster or records websites to check, resolved to codes and merged with `jurisdictions`. An unsupported site is reported as its own `sourceStatus[]` row with `code: null` and `incompleteReason: "unsupported_site"`; the rest of the search runs. When nothing resolves at all, the request is refused with `422 no_live_sources`, unless every entry was an unsupported website, which returns `200` with `status: "partial"`. Example `["https://sheriff.examplecounty.gov/inmates"]`. |

## Async criminal search API: poll request and response fields

`GET /v1/criminal/searches/{searchId}` · auth: `X-API-Key`

Read an async live job. Returns `202` while it runs and `200` with the full envelope in `result` once every source has an outcome. Polling is free.

### Request

**Path parameters**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `searchId` | `string` | required | The `searchId` from the `202` (or from any search on your account). Example `"crs_7f2a1c9e0b4d6a8f1e23"`. |

### Response

**200** (application/json) — The job is complete; `result` carries the search envelope.

The body is [the `JobRecord` object](https://offendersearch.app/docs/criminal/async#object-jobrecord). An async live job as [GET /v1/criminal/searches/{searchId}](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid) returns it: `pending` with `result: null` until every source has an outcome, then `complete` with the full search envelope in `result`.

**202** (application/json) — The job is still running; `status` is `pending` and `result` is `null`. Poll again.

The body is [the `JobRecord` object](https://offendersearch.app/docs/criminal/async#object-jobrecord). An async live job as [GET /v1/criminal/searches/{searchId}](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid) returns it: `pending` with `result: null` until every source has an outcome, then `complete` with the full search envelope in `result`.

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | Missing or invalid `X-API-Key`. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 402 | — | Free trial used up, or no active subscription. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 422 | `query_too_broad` | The job ran and was refused: the stored query was too broad when it rendered. Body: `searchId` plus the criminal `error` object; nothing was billed. |
| 404 | `not_found` | No search with this id on your account. |
| 503 | `unavailable` | Criminal Search could not be reached. Retry with backoff. |
| 504 | `upstream_timeout` | Criminal Search took longer than 65 seconds to answer. Retry, or narrow the query. |

## The JobRecord object — an async live job

An async live job as [GET /v1/criminal/searches/{searchId}](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid) returns it: `pending` with `result: null` until every source has an outcome, then `complete` with the full search envelope in `result`.

Returned or accepted by [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `searchId` | `string` |  | The job's id. Example `"crs_7f2a1c9e0b4d6a8f1e23"`. |
| `accountId` | `string` |  | Your account id. Example `"acct_1a2b3c"`. |
| `tier` | `string` — one of `"core"`, `"live"` |  | `live` for a live job; `core` for a cached search. |
| `status` | `string` — one of `"pending"`, `"complete"` |  | `pending` on a `202` poll, `complete` on a `200` poll. A job whose stored query was refused when it ran is not a JobRecord: the poll answers `422` with a criminal `error` object (`query_too_broad`) and the history lists it as `refused`. |
| `recordsCount` | `integer` |  | Records the search matched. `0` while pending. |
| `liveCompleted` | `integer` |  | Live sources that completed. `0` while pending. |
| `createdAt` | `string (date-time)` |  | When the search was accepted. |
| `updatedAt` | `string (date-time)` |  | When the job last changed. |
| `result` | [SearchResponse](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search) or null |  | The full search envelope once `status` is `complete`; `null` while pending. |

## The criminal Usage object — the live-check charge

The live-check count and charge, present only on a live search. The count is the true number of completed checks even when the charge is capped.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `liveChecks` | `integer` |  | Live sources that completed. Failed checks are not counted and not billed. Example `120`. |
| `liveChargeUsd` | `number` |  | The live charge for this search: $0.02 per completed source, never more than $2.00. Example `2`. |
| `capped` | `boolean` |  | `true` when the $2.00 ceiling set the charge. Example `true`. |
| `display` | `string` |  | The usage line as shown on your usage page, with the completed count visible even when capped. Example `"120 live checks · capped at $2.00"`. |

## The criminal MeterEvent object — billing events on a response

One billable unit this request emitted.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `string` — one of `"criminal_call"`, `"criminal_live_source"` |  | `criminal_call`: one per search and one per batch row. `criminal_live_source`: one per live search, carrying the completed count. |
| `quantity` | `integer` |  | The billable quantity: `1` for a call, the completed source count for live checks. Example `1`. |
| `chargeCeilingUsd` | `number` or null |  | `2.0` on `criminal_live_source` (the per-search ceiling); `null` on `criminal_call`. Example `null`. |

---

# Batch & CSV search

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

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

## One row in, one result out

Bulk **cached** lookups: each row runs through the same cached engine as `/v1/criminal/search`, so results are identical to running them individually, and every result carries the same envelope. **Live verification is not offered in batch** — every row is cache-only.

- **Three accepted body shapes.** A bare JSON array of query objects, a JSON envelope `{ "queries": [...], ...defaults }` 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 validation is returned with `status: "error"` in place and never aborts the rest of the batch — and is not billed.
- **1000 rows per call.** A larger batch returns `413 batch_too_large`.

**Each row is billed as one call** — a 1,000-row batch is 1,000 calls, and `meterEvents` carries one `criminal_call` (`quantity: 1`) per row that ran. Rows that failed validation are not metered.

## The CSV form

Post a `text/csv` body whose header row names query fields. Unknown columns are ignored, so you can post a CSV export unchanged — a case id or note column is dropped rather than rejected. The endpoint still requires `lastName` per row.

```bash
curl -X POST "https://api.offendersearch.app/v1/criminal/batch" \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: text/csv" \
  --data-binary $'firstName,lastName,state\nAlex,Hamilton,TX\nJordan,,IL\n'
```

```json
{
  "count": 2,
  "results": [
    { "index": 0, "status": "complete",
      "counts": { "records": 12, "recordsReturned": 12, "sourcesQueried": 0,
                  "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false,
                  "sourcesOnFile": 44, "sourcesPending": 27 },
      "records": [ "… 12 records …" ] },
    { "index": 1, "status": "error",
      "error": "a lastName predicate is required — an unbounded query returns a lottery, not an answer",
      "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 0,
                  "sourcesComplete": 0, "sourcesIncomplete": 0, "candidatesCapped": false,
                  "sourcesOnFile": 0, "sourcesPending": 0 }, "records": [] }
  ],
  "meterEvents": [ { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null } ]
}
```

**Read each row's own `counts`.** Row-level completeness is reported per row, exactly as it is on a single search. The nameless row is `status: "error"` and is not billed — `meterEvents` holds one `criminal_call`, for the one row that ran.

## Criminal batch API request and response fields

`POST /v1/criminal/batch` · auth: `X-API-Key`

Run up to 1,000 cached searches in one request, one result per row in input order. Each row that runs bills one `criminal_call`. Live verification is not available in a batch.

### Request

**Body** (application/json)

Three forms are accepted: this envelope, a bare JSON array of [Query](https://offendersearch.app/docs/criminal/search#object-query) objects, or `text/csv` with a header row of Query field names and one query per line.

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `queries` | array of [Query](https://offendersearch.app/docs/criminal/search#object-query) | required | The rows, one Query object each, at most 1,000. A row that fails validation returns `status: "error"` and the others still run. |
| `include` | `array of string` — one of `"sourceData"`, `"coverage"`, `"exclusions"` | optional | Applied to every row, as on a search. |
| `page` | `integer` | optional · default `1` | Page applied to each row's result. |
| `perPage` | `integer` | optional · default `50` | Page size applied to each row's result. An integer from 1 to 200; a row with any other value comes back as an `error` row naming `perPage`. |
| `nameStrategy` | `string` — one of `"prefix"`, `"exact"` | optional | Surname strategy applied to every row that does not set its own. |
| `state` | `string` | optional | Any Query field can be given here as a default; `state` is the common one. |

### Response

**200** (application/json) — The batch ran. Rows that failed validation are reported in place.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `count` | `integer` |  | Number of entries in `results[]`, equal to the number of input rows. |
| `results` | array of [BatchRow](https://offendersearch.app/docs/criminal/batch#object-batchrow) |  | One result per row. |
| `meterEvents` | array of [MeterEvent](https://offendersearch.app/docs/criminal/async#object-meterevent) |  | One `criminal_call` per row that ran. Rows that failed validation are not billed. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | Missing or invalid `X-API-Key`. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 402 | — | Free trial used up, or no active subscription. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 413 | `batch_too_large` | More than 1,000 rows. |
| 422 | `bad_batch` | The body is not valid JSON, not an array or `{queries: [...]}`, or a CSV without a header row. |
| 422 | `empty_batch` | No rows. |
| 422 | `query_too_broad` | The name and filters match more stored records than one search can read in full within its time budget. Refused rather than answered partially; the body also carries `detail`, saying what to add (a first name or a state). In a batch, that row comes back as an error row instead. |
| 503 | `unavailable` | Criminal Search could not be reached. Retry with backoff. |
| 504 | `upstream_timeout` | Criminal Search took longer than 65 seconds to answer. Retry, or narrow the query. |

## The BatchRow object — one row's result

One row's result. A row that ran is the search envelope without `searchId`, `meterEvents`, `usage` and `resolution`, plus `index`. A row that failed validation carries only `index`, `status: "error"`, `error`, zeroed `counts` and `records: []`.

Returned or accepted by [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `index` | `integer` |  | 0-based position of the row in your input. |
| `status` | `string` — one of `"complete"`, `"partial"`, `"error"` |  | `complete`, or `error` when the row failed validation. Batch rows run cached only, so `partial` does not occur. |
| `error` | `string` |  | Present only on `status: "error"`: the row's validation message. |
| `counts` | [Counts](https://offendersearch.app/docs/criminal/result-completeness#object-counts) |  | Totals for this row. |
| `warnings` | `array of string` |  | As on a search. A batch row runs cached, so it is `[]`. |
| `page` | `integer` |  | The page returned for this row. |
| `perPage` | `integer` |  | The perPage for this row, echoed back. A row never carries more than 200 records. |
| `totalPages` | `integer` |  | Pages at this `perPage`. |
| `sourceStatus` | array of [SourceStatus](https://offendersearch.app/docs/criminal/result-completeness#object-sourcestatus) |  | `[]` on every batch row, which runs cached. |
| `cappedOmittedSources` | `array of string` |  | `[]`; present for envelope parity. |
| `omittedFields` | `array of string` — one of `"sourceData"` |  | As on a search. |
| `records` | array of [CriminalRecord](https://offendersearch.app/docs/criminal/record-object#object-criminalrecord) |  | This row's matched records. |
| `proof` | `object` |  | Always `{"status": "none"}`. |
| `proof.status` | `string` — one of `"none"` |  | Always `"none"`. |
| `coverage` | [SearchCoverage](https://offendersearch.app/docs/criminal/result-completeness#object-searchcoverage) |  | Coverage for this row's state or county. |
| `legal` | [Legal](https://offendersearch.app/docs/criminal/api-reference#object-legal) |  | The not-a-consumer-report notice. |

## Test criminal batch search in the sandbox

POST /v1/criminal/batch runs on the sandbox with a test key (os_test_…). Send the criminal test names as rows: each row gets the same answer it gets from POST /v1/criminal/search, in input order, never billed.

- **Sandbox base URL:** `https://sandbox.offendersearch.app`. Not `https://api.offendersearch.app`, which takes live keys only.
- **Test key:** in the dashboard, Developers → API keys (https://offendersearch.app/dashboard/keys) → **Create test key**. Test keys begin `os_test_`; send yours in the `X-API-Key` header.

POST /v1/criminal/batch

```bash
curl -X POST "https://sandbox.offendersearch.app/v1/criminal/batch" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '[
      {
        "firstName": "Clear",
        "lastName": "Record"
      },
      {
        "firstName": "Felony",
        "lastName": "Record",
        "dob": "1975-06-15"
      }
    ]'
```

- Every test name: [Criminal search test names](https://offendersearch.app/docs/criminal/sandbox#criminal-test-names)
- Postman collection: https://offendersearch.app/downloads/offendersearch-sandbox.postman_collection.json
- Criminal Search API sandbox: https://offendersearch.app/docs/criminal/sandbox.md
- Run any scenario from the browser: the HTML page's "Try it here" explorer (sandbox host and `os_test_` keys only, never billed).

---

# Criminal Search API reference

> Every Criminal Search API endpoint and object in one index: what each request sends, what each response returns, and a link to every field definition.

HTML: https://offendersearch.app/docs/criminal/api-reference · Markdown: https://offendersearch.app/docs/criminal/api-reference.md

## How the criminal records API reference is organised

Every endpoint documents what it is sent and what it returns in two tables, next to the guide that explains it. Shared objects — the CriminalRecord that search, batch and the record lookup return, its cases and charges, the live `SourceStatus`, the `Error` body — are defined once and linked from every table that carries them.

## Criminal search API endpoints — request and response fields

Each endpoint links to its reference block: a Request table (path, query and body fields, with type, required and default) and a Response table (every field, with type and when it is null). Every endpoint takes the `X-API-Key` header except `GET /v1/criminal/sources`.

| Endpoint | What it does |
| --- | --- |
| [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search) | **Search.** Search criminal records for one person. Without a `live` block it returns the cached answer; with one it re-verifies the named jurisdictions at the source during the request. Bills one `criminal_call`, plus $0.02 per completed live source up to $2.00. |
| [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid) | **Get an async search.** Read an async live job. Returns `202` while it runs and `200` with the full envelope in `result` once every source has an outcome. Polling is free. |
| [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch) | **Batch.** Run up to 1,000 cached searches in one request, one result per row in input order. Each row that runs bills one `criminal_call`. Live verification is not available in a batch. |
| [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid) | **Get a record.** Fetch one current record by its `externalId`, sending its `sources[].code` as `source` so the lookup is a direct key read. The record comes back unscored: `matchConfidence` is `0.0` and the match fields are empty, because there is no query to score against. Not billed. |
| [`GET /v1/criminal/sources`](https://offendersearch.app/docs/criminal/coverage#reference-get-v1-criminal-sources) | **List sources.** The coverage catalog: every source by public code, whether it is available, and when its records were last updated. No API key needed, so coverage can be checked before you sign up. Not billed. |
| [`GET /v1/criminal/jurisdictions`](https://offendersearch.app/docs/criminal/jurisdictions#reference-get-v1-criminal-jurisdictions) | **List jurisdictions.** Every covered jurisdiction by public code, state, county and record kind, filtered by the query parameters. Not billed. |
| [`POST /v1/criminal/resolve`](https://offendersearch.app/docs/criminal/resolve#reference-post-v1-criminal-resolve) | **Resolve a website.** Map a roster or records website you already use to the public jurisdiction code that covers it, or say it is not covered. Not billed. |
| [`GET /v1/criminal/history`](https://offendersearch.app/docs/criminal/verification-reports#reference-get-v1-criminal-history) | **Search history.** Your account's 50 most recent criminal searches, newest first, with the `searchId` a report needs. Not billed. |
| [`POST /v1/criminal/report`](https://offendersearch.app/docs/criminal/verification-reports#reference-post-v1-criminal-report) | **Verification report.** Render a PDF that records a search was run: the criteria, the sources searched and the records found (for a stored `searchId`, the page that search returned), with the not-a-consumer-report notice. A report on a stored `searchId` bills only the report; an inline `query` bills the search too. |

## Criminal search API objects — CriminalRecord, Case, SourceStatus and more

Each object is defined once, on the page linked here. Wherever an endpoint or another object carries it, the type links back to that one definition.

| Object | What it is |
| --- | --- |
| [`Error`](https://offendersearch.app/docs/criminal/errors#object-error) | The body of every error the criminal service returns: one nested `error` object with a stable `code` to branch on and a `message` to log or show. A `401` or `402` comes from the gateway in the [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror) shape instead. |
| [`GatewayError`](https://offendersearch.app/docs/criminal/errors#object-gatewayerror) | The body of a `401` (missing or invalid `X-API-Key`) or a `402` (free trial used up, or no active subscription). The gateway answers these before the criminal service runs, so the shape is the Sex Offender API's flat `detail` string. |
| [`Query`](https://offendersearch.app/docs/criminal/search#object-query) | The person you are looking for. `lastName` is the only required field; every other field narrows within that surname. Send it as `query` on [POST /v1/criminal/search](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search) and [POST /v1/criminal/report](https://offendersearch.app/docs/criminal/verification-reports#reference-post-v1-criminal-report), or as one row of `queries[]` on [POST /v1/criminal/batch](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch). The fields can also be sent flat at the top level of the body. |
| [`LiveRequest`](https://offendersearch.app/docs/criminal/async#object-liverequest) | The `live` block. Its presence turns on live verification: the named jurisdictions are re-checked at the source during the request and the fresh results merge with the cached answer through the same matcher. Up to 2 sources that can answer quickly run synchronously (`200`); more, or any source that cannot, run as an async job (`202`). |
| [`SearchFilters`](https://offendersearch.app/docs/criminal/filtering#object-searchfilters) | The `filters` of a criminal search. Every key is optional; filters are ANDed and the values inside one list are ORed. A list filter also takes a single string. Any other key is `422 invalid_filter_value`. |
| [`DateRange`](https://offendersearch.app/docs/criminal/filtering#object-daterange) | An inclusive ISO date range. Send `from`, `to` or both; `from` after `to`, a non-ISO value or an impossible date is `422 invalid_date_range`. |
| [`Location`](https://offendersearch.app/docs/criminal/filtering#object-location) | One place in `locations[]`. It needs at least one of `state`, `county`, `city`, `zipcode`, or a radius (`lat`, `lng` and `radiusMiles` together). Any other key is `422 invalid_location`. |
| [`SearchFacets`](https://offendersearch.app/docs/criminal/filtering#object-searchfacets) | Counts over every record that passed the filters: the whole result, not the page. Each key maps a value to a number of records. |
| [`Counts`](https://offendersearch.app/docs/criminal/result-completeness#object-counts) | Totals on every criminal search. The three live counts are `0` on a cached search; `sourcesOnFile` and `sourcesPending` describe the sources behind the answer on every search. |
| [`SearchCoverage`](https://offendersearch.app/docs/criminal/result-completeness#object-searchcoverage) | Which sources stand behind a search for the state or county searched, and which sources for that scope are not available yet. Codes are the public codes [GET /v1/criminal/sources](https://offendersearch.app/docs/criminal/coverage#reference-get-v1-criminal-sources) lists. It does not change `status`. |
| [`SearchCoverageSource`](https://offendersearch.app/docs/criminal/result-completeness#object-searchcoveragesource) | One source in a search's coverage block. |
| [`SourceStatus`](https://offendersearch.app/docs/criminal/result-completeness#object-sourcestatus) | One live source's outcome within a search. Present only on a live search; a cached search returns `sourceStatus: []`. |
| [`ResolutionEntry`](https://offendersearch.app/docs/criminal/resolve#object-resolutionentry) | What one website resolved to, as reported in a search's `resolution[]`. The same fields as a [ResolveResult](https://offendersearch.app/docs/criminal/resolve#object-resolveresult) without `state` and `county`. |
| [`Usage`](https://offendersearch.app/docs/criminal/async#object-usage) | The live-check count and charge, present only on a live search. The count is the true number of completed checks even when the charge is capped. |
| [`MeterEvent`](https://offendersearch.app/docs/criminal/async#object-meterevent) | One billable unit this request emitted. |
| [`Legal`](https://offendersearch.app/docs/criminal/api-reference#object-legal) | The notice carried on every criminal search response and every batch row. |
| [`CriminalRecord`](https://offendersearch.app/docs/criminal/record-object#object-criminalrecord) | One record from one jurisdiction, as search, batch and the record lookup return it. A person found in several jurisdictions (booked in one county, a court case in another, an inmate record statewide) comes back as one record per jurisdiction; relate them by name, `dob` and `state`. Every key is present on every record. Empties mean different things: `""` is a text field the source does not publish, `null` is a date, number, object or demographic that is not known, and `[]` is a list with nothing on file. |
| [`Name`](https://offendersearch.app/docs/criminal/record-object#object-name) | A name in four parts, each `""` when not published. There is no `full` key; join the parts for display. Casing is as published; matching ignores case. |
| [`Dob`](https://offendersearch.app/docs/criminal/record-object#object-dob) | All birth evidence on a record. `dobPrecision` says which parts are real; `date` is a full date or `null`, never a made-up day. A source's placeholder date (`1/1` of a year with nothing else to support it, and the like) never becomes a full `date`; a sentinel (`1900-01-01`, `0001-01-01`, any `9999` date, a year before 1900 or after this year) is no birth evidence, so `birthYear` is `null` too. |
| [`Address`](https://offendersearch.app/docs/criminal/record-object#object-address) | A residence, in the same slots as the Sex Offender API's address. Text fields are `""` when not published. |
| [`Case`](https://offendersearch.app/docs/criminal/record-object#object-case) | A court case and its charges. `filedDate` is ISO or `null`; text fields are `""` when not published. |
| [`Sentence`](https://offendersearch.app/docs/criminal/record-object#object-sentence) | A sentence. Text fields are `""` when not published; dates are ISO or `null`. |
| [`Parole`](https://offendersearch.app/docs/criminal/record-object#object-parole) | Parole posture as published. Dates are ISO or `null`; text fields `""` when not published. |
| [`Custody`](https://offendersearch.app/docs/criminal/record-object#object-custody) | Custody classification, alongside `currentStatus` and `custodyFacility`. Text fields `""` when not published. |
| [`Charge`](https://offendersearch.app/docs/criminal/record-object#object-charge) | One charge on a case. `severity: null` means the source did not classify it, not that it is minor. |
| [`SourceRef`](https://offendersearch.app/docs/criminal/record-object#object-sourceref) | The jurisdiction a record comes from. `code` names a state and a place, nothing else. |
| [`Lifecycle`](https://offendersearch.app/docs/criminal/record-object#object-lifecycle) | Records are never deleted when a person leaves a source; the departure is reported here and the record is still returned. |
| [`Exclusion`](https://offendersearch.app/docs/criminal/record-object#object-exclusion) | The `exclusion` block of an exclusion-list record (`recordType: "healthcare_exclusion"`): what the publishing list says about the person. Three list families are carried: the HHS Office of Inspector General's List of Excluded Individuals/Entities (`medicare_medicaid`, republished monthly), the SAM.gov exclusions placed by federal agencies (`federal_procurement`, published daily) and the state Medicaid exclusion and sanction lists (`state_medicaid`, mostly monthly). `convictionBased` decides whether the record is returned on every search or only with `include: ["exclusions"]`. |
| [`Physical`](https://offendersearch.app/docs/criminal/record-object#object-physical) | Physical description as the source publishes it. Every key is present; `null` when the source shows none. Values are the source's own text — `"5'10"`" and `"510"` both occur — never converted. |
| [`Bond`](https://offendersearch.app/docs/criminal/record-object#object-bond) | Bond or bail set on a booking. |
| [`Booking`](https://offendersearch.app/docs/criminal/record-object#object-booking) | The booking facts a jail roster publishes. Every key is present; `null` / `[]` when not published, which is typical outside jail records. |
| [`Identifiers`](https://offendersearch.app/docs/criminal/record-object#object-identifiers) | Official numbers the source publishes for this person or case. Every key is present; `null` when not published. |
| [`Warrant`](https://offendersearch.app/docs/criminal/record-object#object-warrant) | Warrant and wanted-person facts. Every key is present; `null` when not published, which is typical outside warrant records. |
| [`Supervision`](https://offendersearch.app/docs/criminal/record-object#object-supervision) | Parole or probation supervision. Every key is present; `null` when not published. |
| [`ReleaseDates`](https://offendersearch.app/docs/criminal/record-object#object-releasedates) | Projected and limit release dates a prison publishes (the actual release is `actualReleaseDate`). ISO when the text parses cleanly, otherwise the source's text; `null` when not published. |
| [`RegistryFacts`](https://offendersearch.app/docs/criminal/record-object#object-registryfacts) | Facts a non-sex-offender registry publishes (violent-offender, arson, animal-abuse, habitual-DUI and similar). Every key is present; `null` when not published. |
| [`NextHearing`](https://offendersearch.app/docs/criminal/record-object#object-nexthearing) | The next scheduled court event. Every key is present; `null` when not published. |
| [`OtherDetail`](https://offendersearch.app/docs/criminal/record-object#object-otherdetail) | One more fact the source published that has no named field above, under the source's own label. |
| [`CaseEvent`](https://offendersearch.app/docs/criminal/record-object#object-caseevent) | One entry of the docket history. |
| [`Normalized`](https://offendersearch.app/docs/criminal/normalized-fields#object-normalized) | A record's `normalized` classes. Every value is our classification of a published fact; the fact itself stays in the record's own fields beside it. A value that cannot be decided from what was published is `unknown` or `null`, never a guess. |
| [`NormalizedPlace`](https://offendersearch.app/docs/criminal/normalized-fields#object-normalizedplace) | A place, normalized. Every text key is `null` when not known. |
| [`NormalizedCase`](https://offendersearch.app/docs/criminal/normalized-fields#object-normalizedcase) | One case, normalized, in the order of the record's `cases[]`. |
| [`NormalizedCharge`](https://offendersearch.app/docs/criminal/normalized-fields#object-normalizedcharge) | One charge, normalized. The published statute, description, severity and disposition stay on the case's own `charges[]`. |
| [`Coverage`](https://offendersearch.app/docs/criminal/coverage#object-coverage) | Coverage totals for the whole product, by jurisdiction. |
| [`SourceInfo`](https://offendersearch.app/docs/criminal/coverage#object-sourceinfo) | One source in the coverage catalog. |
| [`JurisdictionInfo`](https://offendersearch.app/docs/criminal/jurisdictions#object-jurisdictioninfo) | One covered jurisdiction: its public code and its place, nothing else. |
| [`ResolveResult`](https://offendersearch.app/docs/criminal/resolve#object-resolveresult) | What one website resolved to. The response names the state, the county and the public code, never the website's host. |
| [`ResolveListResponse`](https://offendersearch.app/docs/criminal/resolve#object-resolvelistresponse) | The answer when `websites` (a list) is sent. |
| [`JobRecord`](https://offendersearch.app/docs/criminal/async#object-jobrecord) | An async live job as [GET /v1/criminal/searches/{searchId}](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid) returns it: `pending` with `result: null` until every source has an outcome, then `complete` with the full search envelope in `result`. |
| [`HistoryEntry`](https://offendersearch.app/docs/criminal/verification-reports#object-historyentry) | One prior criminal search. Pass `searchId` to [POST /v1/criminal/report](https://offendersearch.app/docs/criminal/verification-reports#reference-post-v1-criminal-report) to render it. |
| [`BatchRow`](https://offendersearch.app/docs/criminal/batch#object-batchrow) | One row's result. A row that ran is the search envelope without `searchId`, `meterEvents`, `usage` and `resolution`, plus `index`. A row that failed validation carries only `index`, `status: "error"`, `error`, zeroed `counts` and `records: []`. |
| [`ClearedEntry`](https://offendersearch.app/docs/criminal/clearances#object-clearedentry) | One record a `not_this_person` clearance removed from `records[]` for the `subjectId` sent. `cleared[]` carries one of these per suppressed record, so an interface can say how many previously cleared people are hidden and link each to its clearance. |
| [`RecordClearance`](https://offendersearch.app/docs/criminal/clearances#object-recordclearance) | The clearance that names a record's person, attached as `clearance` when the request carried `subjectId` and the record was kept: in `clearanceMode: "annotate"`, on a `this_person` verdict, or because a cleared person has re-surfaced (`staleReason` set). A record no clearance names carries no `clearance` key. |

## The Legal object — the FCRA notice

The notice carried on every criminal search response and every batch row.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `notice` | `string` |  | The not-a-consumer-report notice, verbatim. This data may not be used for any FCRA purpose: employment, tenant screening, credit, insurance or any other FCRA-covered decision. Example `"Not a consumer report. This information may not be used for any purpose under the Fair Credit Reporting Act (15 U.S.C. § 1681 et seq.)."`. |

---

# The Criminal Record object

> One normalized criminal record across every jurisdiction: identity, the birth-evidence object, cases, custody, lifecycle, provenance, and match metadata.

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

## One normalized schema

Every match in a search response is a normalized **Criminal Record** — identity, the birth-evidence `dob` object, `cases[]`, custody fields, a `lifecycle` block, the jurisdiction it comes from in `sources[]`, and match metadata. The same schema is returned by `/v1/criminal/search` and by `GET /v1/criminal/records/{recordId}`. `recordType` names the kind where it is known (`jail_booking`, `doc_inmate`, `court_case`, `warrant`, `wanted_fugitive`, `registry`, `healthcare_exclusion`, …) and is `criminal_record` otherwise — treat it as an open string. A person in several jurisdictions comes back as one record per jurisdiction.

## Identity — externalId and personRef

`externalId` is the source’s *own* key for **one** booking, case or inmate record — a booking number, a Department of Corrections number, an offender id or a case number, verbatim. It is what the re-fetch endpoint takes (`externalId` in the path, `sources[].code` as `?source=`), so keep it to cite or re-fetch a specific record. `""` when the source publishes no such key.

`personRef` groups the records **one source** holds for the **same person**. A new booking of someone already on file, or a case the source re-issued under a new number, carries the `personRef` of that person’s earlier records from that source, while each keeps its own `externalId`. It is the value to store when the question is “have I seen this person from this source before?” — a re-screen, a diff between two runs, a monitor.

**Two keys, two questions.** `externalId` names a record; `personRef` names the person behind a source’s records. Neither is a global primary key: both are unique only within their source, so store either with its `sources[].code`. There is still no cross-source person id; the identity comparison across jurisdictions is `name` + `dob` + `state`.

`personRef` is also what a clearance names: tell us once that a person a source returned is not the subject you are screening, and every later synchronous search for that subject honours it, at no extra charge (not a polled async result, a batch row or a report). See [Clearances](https://offendersearch.app/docs/criminal/clearances.md).

## Example record

```json
{
  "recordType": "jail_booking",
  "externalId": "TX-BEXAR:booking:889201",
  "personRef": "prs_8b1d4e6f0a2c3c9e7f1a",
  "name": { "first": "ALEX", "middle": "Q", "last": "HAMILTON", "suffix": "" },
  "aliases": ["HAMILTON, A Q"],
  "dob": { "date": "1989-04-23", "birthYear": 1989, "birthMonth": 4,
           "age": 36, "ageAsOf": "2026-03-01", "dobPrecision": "full" },
  "birthYear": 1989, "dobPrecision": "full",
  "sex": "M", "race": "White", "state": "TX", "county": "Bexar",
  "address": null,
  "currentStatus": "released", "currentlyIncarcerated": false, "everIncarcerated": true,
  "dateIncarcerated": "2023-05-31", "releaseDate": "2024-02-10", "actualReleaseDate": null,
  "custodyFacility": "Bexar County Jail",
  "sentence": null, "parole": null, "custody": null,
  "priorFelony": null, "veteranStatus": "", "mugshotUrl": null,
  "matchConfidence": 0.99, "matchBasis": "lastName:exact firstName:exact dob:match",
  "matchDetail": "name and full date of birth agree",
  "matchState": "dob_match", "unverified": false, "dobVerification": "record",
  "cases": [],
  "sources": [ { "code": "TX-BEXAR", "level": "county",
                 "recordUrl": "https://…/booking/889201",
                 "lastCheckedAt": "2026-08-24T05:12:00+00:00", "live": false } ],
  "lifecycle": { "firstSeen": "2023-06-15T00:00:00+00:00",
                 "lastSeen": "2026-08-24T05:12:00+00:00", "presentInLastSweep": true,
                 "droppedOut": false, "droppedOutDetected": null,
                 "dropInterpretation": "unknown" },
  "liveChecked": false
}
```

## Exclusion-list records

Three exclusion-list families ride inside the criminal search as one record kind, `recordType: "healthcare_exclusion"`: the HHS Office of Inspector General’s List of Excluded Individuals/Entities (people excluded from Medicare, Medicaid and the other federal health programs; republished monthly), the SAM.gov exclusions placed by federal agencies (debarments and suspensions from federal awards; published daily) and the state Medicaid exclusion and sanction lists (mostly monthly). The two national lists carry the public code `US-EXCLUSION`; a state list carries `<ST>-EXCLUSION`; in the source catalog their record type reads `Exclusion list`.

**What decides whether a record is returned is its basis.** Every exclusion record carries an `exclusion` block (program, authority, authorityDescription, statute, convictionBased, excludedOn, reinstatedOn, minimumPeriodYears, excludingAgency, npi, profession — the Exclusion object below; `null` on every other kind). When `convictionBased` is `true` — HHS-OIG 1128(a)(1)–(4), (c)(3)(G) and (b)(1)–(3), or a SAM or state entry whose stated reason names a conviction — the record is returned on every search. When the basis is administrative (a license action, a debarment from federal awards, a loan default) it is returned only with `include: ["exclusions"]`, and `counts.exclusionsIncluded` says whether that tier was requested.

- `currentStatus` is `excluded` while the person is on the list.
- `cases[]` holds one case per exclusion: `court` is the excluding agency, `level` is `federal` or `state`, `filedDate` is the exclusion date, and the one charge carries the authority as `description` and `statute` with `disposition: "convicted"` on a conviction-based record and `"administrative exclusion"` otherwise.
- Most HHS-OIG entries publish a full date of birth, so `dob_match` is reachable; SAM.gov entries and most state lists publish none, and those records come back `unverified: true` and rank below records with birth evidence.
- A person who leaves the list is kept and flagged: `lifecycle.droppedOut: true` with `dropInterpretation: "reinstated"` (reinstated, or the exclusion term ended).
- The same record is furnished through an FCRA order according to the search’s `exclusions` option, a criminal monitor alerts when a conviction-based exclusion is added, changed or removed, and a verification report prints the block as its own *Exclusion* line.

```json
{
  "recordType": "healthcare_exclusion",
  "externalId": "leie:c:3f8a9c1e7b2d4056",
  "name": { "first": "ALEX", "middle": "Q", "last": "HAMILTON", "suffix": "" },
  "dob": { "date": "1989-04-23", "birthYear": 1989, "birthMonth": 4,
           "age": null, "ageAsOf": null, "dobPrecision": "full" },
  "state": "OH", "county": null,
  "currentStatus": "excluded", "currentlyIncarcerated": false, "everIncarcerated": false,
  "matchConfidence": 0.99, "matchBasis": "lastName:exact firstName:exact dob:match",
  "matchState": "dob_match", "unverified": false,
  "exclusion": { "program": "medicare_medicaid", "authority": "1128(a)(1)",
                 "authorityDescription": "Conviction of program-related crimes",
                 "statute": "42 USC 1320a-7(a)(1)", "convictionBased": true,
                 "excludedOn": "2021-03-19", "reinstatedOn": null, "minimumPeriodYears": 5,
                 "excludingAgency": "HHS-OIG", "npi": "1234567890", "profession": "NURSING PROFESSION" },
  "cases": [ { "caseNumber": "", "court": "HHS-OIG", "county": "", "state": "OH", "level": "federal",
               "filedDate": "2021-03-19", "status": "excluded",
               "charges": [ { "statute": "42 USC 1320a-7(a)(1)",
                              "description": "Conviction of program-related crimes",
                              "severity": null, "disposition": "convicted",
                              "dispositionDate": "2021-03-19", "offenseDate": null,
                              "plea": "", "counts": null } ] } ],
  "sources": [ { "code": "US-EXCLUSION", "level": "federal", "live": false } ],
  "lifecycle": { "presentInLastSweep": true, "droppedOut": false, "dropInterpretation": "unknown" },
  "liveChecked": false
}
```

## GET /v1/criminal/records/{recordId}

Re-fetch a single record by the source's own stable identifier — its `externalId`. This is a point lookup: no name predicate.

> **The path value is the record's `externalId`**, URL-encoded. Send its `sources[].code` as `?source=` and the lookup is a direct key read; without it the record is found by `externalId` alone. Send the code anyway: an `externalId` is unique only **within its source** — store the `sources[].code` + `externalId` pair for a durable reference.

> A `404` means "this identifier is not current" (a booking that dropped from the roster, a source that re-issued its ids), not "this person has no record". Re-search by name and state, then store the new `externalId`.

## Behaviours to code around

- **`sex` and `race` are free text, not normalized** — `"Male"`, `"M"`, `"F"` co-occur. Normalize client-side; `null` means "not published".
- **On a live check a `firstName:mismatch` record is still returned** (lower confidence); on a cached search a `firstName` you send must prefix-match the record's. Threshold on `matchConfidence`/`matchState`.
- **A `droppedOut: true` record is still returned**, because disappearance is data. Read `lifecycle` before treating a record as "current".
- **`cases[].chargesPending: true` means the charges have not been retrieved yet** — the court lists the case, and `charges: []` on that case is "not retrieved yet", never "none on file". The key is present only when true.

## Criminal record API response fields — the CriminalRecord object

One record from one jurisdiction, as search, batch and the record lookup return it. A person found in several jurisdictions (booked in one county, a court case in another, an inmate record statewide) comes back as one record per jurisdiction; relate them by name, `dob` and `state`. Every key is present on every record. Empties mean different things: `""` is a text field the source does not publish, `null` is a date, number, object or demographic that is not known, and `[]` is a list with nothing on file.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `recordType` | `string` |  | The kind of record where it is known — `jail_booking`, `doc_inmate`, `court_case`, `warrant`, `wanted_fugitive`, `registry`, `healthcare_exclusion` and others — and `"criminal_record"` otherwise. An open string: give your parser a default branch. `sources[].code` and the populated fields say the same thing. `healthcare_exclusion` is a person on a government health-program or federal award [exclusion list](https://offendersearch.app/docs/criminal/record-object#exclusions), the one kind that carries a non-null `exclusion` block. Example `"criminal_record"`. |
| `externalId` | `string` |  | The source's own key for this one booking, case or inmate record: a booking number, corrections number, offender id or case number, as published. `""` when the source publishes none. Unique only within its source, so store it with `sources[].code`. It is the id [GET /v1/criminal/records/{recordId}](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid) looks up. To recognise the same person across that source's records, read `personRef`. Example `"TX-BEXAR:booking:889201"`. |
| `personRef` | `string` |  | Groups the records one source holds for the same person: a new booking, or a case the source re-issued under a new number, shares the `personRef` of the person's earlier records from that source. Scoped to the source named in `sources[].code`; there is no cross-source person id. Example `"prs_8b1d4e6f0a2c3c9e7f1a"`. |
| `name` | [Name](https://offendersearch.app/docs/criminal/record-object#object-name) |  | The person's name in four parts. |
| `aliases` | `array of string` |  | Other names on file, as published (`"SURNAME, GIVEN"` and `"Given Surname"` both occur). Do not parse them by position. `[]` when none are on file. Example `["HAMILTON, A Q"]`. |
| `dob` | [Dob](https://offendersearch.app/docs/criminal/record-object#object-dob) |  | All birth evidence on file. Read it with `dobPrecision`. |
| `birthYear` | `integer` or null |  | Year of birth, the same value as `dob.birthYear`. `null` when not known. Example `1989`. |
| `dobPrecision` | `string` — one of `"full"`, `"month_year"`, `"year"`, `"age"`, `"none"` |  | The kind of birth evidence on file, the same value as `dob.dobPrecision`: `full`, `month_year`, `year`, `age` or `none`. |
| `sex` | `string` or null |  | As published, not normalized (`"M"`, `"Male"` and `"F"` all occur). `null` when the source publishes none. Example `"M"`. |
| `race` | `string` or null |  | As published, not normalized. `null` when the source publishes none. Example `"White"`. |
| `state` | `string` or null |  | The state whose jurisdiction holds this record. Not necessarily where the person lives. Example `"TX"`. |
| `county` | `string` or null |  | The county whose jurisdiction holds this record. `null` on statewide and federal records. Example `"Bexar"`. |
| `currentStatus` | `string` |  | **Open text — the source's status, passed through.** Usually one of `in_custody`, `released`, `on_parole`, `on_probation`, `active_warrant`, `wanted`, `registered`, `disposed`, `absconder`, `deceased`, `excluded`, `unknown`, but sources also write their own words (`booked`, `scheduled`, `arrested`, `incarcerated`, `filed`, `indicted`, `pending`, …). Branch and filter on the closed twin `normalized.custodyStatus`. `unknown` is a real answer: the source published no status. `excluded` is the status of every exclusion-list record while the person is on the list; once the person has left the list (`lifecycle.droppedOut: true`, `dropInterpretation: "reinstated"`) or the record's `exclusion.reinstatedOn` date has passed, it reads `unknown`. |
| `currentlyIncarcerated` | `boolean` |  | `true` when the record places the person in custody. `false` is not "released": it is also the value for someone never booked, a court-only record and a warrant. Read with `everIncarcerated` and the dates. |
| `everIncarcerated` | `boolean` |  | `true` when the person has been recorded in custody. |
| `dateIncarcerated` | `string` or null |  | Booking or admission date, ISO `YYYY-MM-DD`. `null` when not published, which is typical on court and warrant records. Example `"2023-05-31"`. |
| `releaseDate` | `string` or null |  | Projected or scheduled release date, ISO. `null` when not published. Example `"2024-02-10"`. |
| `custodyFacility` | `string` or null |  | The facility holding the person, where published. `null` otherwise. Example `"Bexar County Adult Detention Center"`. |
| `mugshotUrl` | `string` or null |  | Photo URL as published, at most one per record. Fetch and cache it server-side rather than placing the URL in a browser `<img>`. `null` when no photo is published. |
| `matchConfidence` | `number` |  | How much identity evidence this record holds for your query, from `0.0` to `0.99`: surname plus first name plus birth evidence. Never `1.0`. A high value on a name-only match is a candidate, not a confirmation; for consequential decisions require `matchState: "dob_match"`. `0.0` on [GET /v1/criminal/records/{recordId}](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid), which scores nothing. Example `0.95`. |
| `matchBasis` | `string` |  | Space-separated `field:how` tokens: `lastName:exact\|prefix`, `firstName:exact\|prefix\|absent\|mismatch\|notQueried`, and one `dob:` token (`match`, `year`, `monthYear`, `age`, `ageFromDob`, `absent`, `sourceConfirmed`, `sourceNotConfirmed`). `""` when nothing was scored. Example `"lastName:exact firstName:exact dob:match"`. |
| `matchDetail` | `string` |  | The same reading as one plain sentence. `""` when nothing was scored. Example `"name and full date of birth agree"`. |
| `matchState` | `string` or null — one of `"dob_match"`, `"year_match"`, `"age_match"`, `"no_dob_age_year"` |  | How the record matched the `dob`, `birthYear` or `age` you sent, strongest first: `dob_match` (a full date equal to yours, or on a live search confirmed by the source), `year_match` (birth years agree, months too where both publish one), `age_match` (an age consistent with your date, or a record consistent with your age), `no_dob_age_year` (nothing to compare; returned on the name, and the value on every record when you sent no birth evidence). `null` only on [GET /v1/criminal/records/{recordId}](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid), which scores nothing. Records whose evidence contradicts yours are dropped, so `dob_mismatch` never appears. |
| `unverified` | `boolean` |  | `true` exactly when `matchState` is `no_dob_age_year`: the record had nothing to confirm or deny your date and is present on the name alone. |
| `dobVerification` | `string` or null — one of `"record"`, `"source_confirmed"`, `"source_not_confirmed"`, `"unverified"` |  | How your date was checked. `record`: against birth evidence on the record. `source_confirmed`: the record publishes no date and a live check confirmed the source holds it under your `dob` (`dob.date` stays `null`). `source_not_confirmed`: a live check asked with your date and the source did not return this record; it stays, ranked lower. `unverified`: nothing to check against, including when you sent no birth evidence. `null` on a record fetched by id. Example `"record"`. |
| `cases` | array of [Case](https://offendersearch.app/docs/criminal/record-object#object-case) |  | Court cases with their charges. `[]` on a custody or roster entry with no case attached. |
| `sources` | array of [SourceRef](https://offendersearch.app/docs/criminal/record-object#object-sourceref) |  | The jurisdiction this record comes from. |
| `lifecycle` | [Lifecycle](https://offendersearch.app/docs/criminal/record-object#object-lifecycle) |  | When the person was first and last seen at the source, and whether they have dropped off it. |
| `liveChecked` | `boolean` |  | `true` when this record was re-verified live in this request. `false` on a cached search. |
| `exclusion` | [Exclusion](https://offendersearch.app/docs/criminal/record-object#object-exclusion) or null |  | The exclusion list's own facts — program, authority, whether the basis is a criminal conviction, the dates — present only when `recordType` is `healthcare_exclusion`. `null` on every other record. See [exclusion-list records](https://offendersearch.app/docs/criminal/record-object#exclusions). |
| `address` | [Address](https://offendersearch.app/docs/criminal/record-object#object-address) or null |  | The last-known or registered residence, where the source publishes one. `null` otherwise. |
| `actualReleaseDate` | `string` or null |  | Date of a release that has happened, ISO, where published. `releaseDate` is the projected one. Example `"2024-02-10"`. |
| `sentence` | [Sentence](https://offendersearch.app/docs/criminal/record-object#object-sentence) or null |  | The record-level sentence on a custody record, where published. `null` otherwise. Court sentences are on `cases[].sentence`. |
| `parole` | [Parole](https://offendersearch.app/docs/criminal/record-object#object-parole) or null |  | Parole posture, where published. `null` when the source publishes none or the person is not parole-eligible. |
| `custody` | [Custody](https://offendersearch.app/docs/criminal/record-object#object-custody) or null |  | Custody classification, where published. `null` otherwise. |
| `priorFelony` | `boolean` or null |  | The source's prior-felony flag. `null` when the source does not publish one, which is different from `false`. |
| `veteranStatus` | `string` |  | Veteran status as published. `""` when not published. |
| `clearance` | [RecordClearance](https://offendersearch.app/docs/criminal/clearances#object-recordclearance) |  | The [clearance](https://offendersearch.app/docs/criminal/clearances) that names this person, present only when the request carried `subjectId` and this record was kept despite — or because of — a verdict: `annotate` mode, a `this_person` verdict, or a cleared person who has re-surfaced (`staleReason` set). Absent on every other record; a search without `subjectId` never carries it. |
| `recordCategory` | `string` — one of `"court"`, `"jail"`, `"prison"`, `"warrant"`, `"registry"`, `"exclusion"`, `"other"` |  | The kind of record as one closed token: `court`, `jail`, `prison`, `warrant`, `registry`, `exclusion` or `other`. `recordType` keeps the source-specific label; filter on this one. Example `"jail"`. |
| `ethnicity` | `string` or null |  | Ethnicity, as published. Example `"Not Hispanic"`. |
| `placeOfBirth` | `string` or null |  | Place of birth, as published. Example `"Mexico"`. |
| `citizenship` | `string` or null |  | Citizenship, as published. Example `"UNITED STATES OF AMERICA"`. |
| `legalStatus` | `string` or null |  | Legal or correctional status, as published (beyond the normalized `currentStatus`). Example `"Sentenced"`. |
| `custodyLevel` | `string` or null |  | Custody or security level, as published. Example `"Medium"`. |
| `physical` | [Physical](https://offendersearch.app/docs/criminal/record-object#object-physical) |  | Physical description. Always an object. |
| `booking` | [Booking](https://offendersearch.app/docs/criminal/record-object#object-booking) |  | Booking facts. Always an object. |
| `identifiers` | [Identifiers](https://offendersearch.app/docs/criminal/record-object#object-identifiers) |  | Official numbers. Always an object. |
| `warrant` | [Warrant](https://offendersearch.app/docs/criminal/record-object#object-warrant) |  | Warrant facts. Always an object. |
| `supervision` | [Supervision](https://offendersearch.app/docs/criminal/record-object#object-supervision) |  | Supervision facts. Always an object. |
| `releaseDates` | [ReleaseDates](https://offendersearch.app/docs/criminal/record-object#object-releasedates) |  | Projected and limit release dates. Always an object. |
| `registry` | [RegistryFacts](https://offendersearch.app/docs/criminal/record-object#object-registryfacts) |  | Registry facts. Always an object. |
| `vehicles` | `array of string` |  | Vehicles and plates, as published. `[]` when none. Example `["ABC1234 (TX)"]`. |
| `probableCause` | `string` or null |  | Probable-cause or case narrative text, as published. |
| `publishedAge` | `integer` or null |  | The age the source printed, when it is one plain number. Separate from `dob.age`, which is tied to the birth evidence. Example `34`. |
| `notes` | `string` or null |  | Notes or comments the source published. |
| `location` | `string` or null |  | A location the source names (where held, arrested or living), as published. |
| `occupation` | `string` or null |  | Occupation or employer, as published. |
| `custodyStatus` | `string` or null |  | The source's own custody wording (beside the normalized `currentStatus`). Example `"In custody"`. |
| `relatedCaseNumbers` | `array of string` |  | Other case, booking, docket or warrant numbers the source lists for this person. `[]` when none. Example `["26-CR-1182"]`. |
| `otherDetails` | array of [OtherDetail](https://offendersearch.app/docs/criminal/record-object#object-otherdetail) |  | Every other fact the source published that has no named field, as label/value pairs in the source's order. `[]` when none. Labels vary by source: read them, do not branch on them. |
| `normalized` | [Normalized](https://offendersearch.app/docs/criminal/normalized-fields#object-normalized) or null |  | Clean classes for this record (category, severity, outcome, NCIC person codes, places, dates), computed from the fields beside it. The source's own words stay in their fields, unchanged. On every record in every response; `null` when the record could not be classified. See [Normalized fields](https://offendersearch.app/docs/criminal/normalized-fields). |
| `matchedLocations` | `array of integer` |  | Present only when the request sent `locations`. The 0-based indexes of the locations this record matched. Example `[0,2]`. |
| `matchesFilters` | `array of object` |  | Present only when the request sent a charge filter (`severity`, `outcome`, `convictedOnly`, `chargeCategories`, `statute` or `chargeText`). The charges that met them: with `chargeMatch: "any"`, each charge that met every charge filter; with `"all"`, each charge that met at least one. Every charge is still returned. Example `[{"case":0,"charge":1}]`. |
| `matchesFilters[].case` | `integer` |  | 0-based index into `normalized.cases[]`. |
| `matchesFilters[].charge` | `integer` |  | 0-based index into that case's `charges[]`. |

## The criminal Name object — name fields on a criminal record

A name in four parts, each `""` when not published. There is no `full` key; join the parts for display. Casing is as published; matching ignores case.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `first` | `string` |  | Given name. `""` cannot satisfy a `firstName` on a cached search; on a live check the record is kept, flagged `firstName:absent`. Example `"ALEX"`. |
| `middle` | `string` |  | Middle name or initial, as published. Example `"Q"`. |
| `last` | `string` |  | Surname. Example `"HAMILTON"`. |
| `suffix` | `string` |  | `JR`, `III` and the like, as published. Example `""`. |

## The Dob object — birth evidence fields

All birth evidence on a record. `dobPrecision` says which parts are real; `date` is a full date or `null`, never a made-up day. A source's placeholder date (`1/1` of a year with nothing else to support it, and the like) never becomes a full `date`; a sentinel (`1900-01-01`, `0001-01-01`, any `9999` date, a year before 1900 or after this year) is no birth evidence, so `birthYear` is `null` too.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `string` or null |  | Full date `YYYY-MM-DD`, set exactly when `dobPrecision` is `full`; otherwise `null`. Safe to pass to a date parser. Example `"1989-04-23"`. |
| `birthYear` | `integer` or null |  | Year of birth when known, published or taken from a full `date`. Example `1989`. |
| `birthMonth` | `integer` or null |  | Month `1`–`12` when known: from a full `date`, or published with the year when there is no day. Example `4`. |
| `age` | `integer` or null |  | The age the source publishes, read with `ageAsOf`. `null` when none is published. Example `36`. |
| `ageAsOf` | `string` or null |  | The date the age applies to. Age matching counts from it. Example `"2026-03-01"`. |
| `dobPrecision` | `string` — one of `"full"`, `"month_year"`, `"year"`, `"age"`, `"none"` |  | `full` (date set), `month_year` (year and month), `year` (year only), `age` (age only, common on jail rosters), `none` (no birth evidence). |

## The criminal Address object — residence address fields

A residence, in the same slots as the Sex Offender API's address. Text fields are `""` when not published.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | `string` |  | Address kind. `residence` unless the source labels it otherwise. Example `"residence"`. |
| `line1` | `string` |  | Street line. |
| `city` | `string` |  | City. |
| `county` | `string` |  | County. |
| `state` | `string` |  | Two-letter state. |
| `zipcode` | `string` |  | ZIP code. |
| `lat` | `number` or null |  | Always `null` on criminal records. |
| `lng` | `number` or null |  | Always `null` on criminal records. |

## The Case object — court case fields

A court case and its charges. `filedDate` is ISO or `null`; text fields are `""` when not published.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `caseNumber` | `string` |  | The court's case number, as published. Example `"CR-2023-004182"`. |
| `court` | `string` |  | The court's name. Example `"144th District Court, Bexar County"`. |
| `county` | `string` |  | The county where the court sits. Not a residence. Example `"Bexar"`. |
| `state` | `string` |  | The court's state. Example `"TX"`. |
| `level` | `string` — one of `"county"`, `"state"`, `"federal"` | default `"county"` | `county`, `state` or `federal` court. |
| `filedDate` | `string` or null |  | Date the case was filed, ISO. Example `"2023-06-14"`. |
| `status` | `string` |  | Case status, as published. Example `"disposed"`. |
| `charges` | array of [Charge](https://offendersearch.app/docs/criminal/record-object#object-charge) |  | The charges on the case. When `chargesPending` is `true`, `[]` means not retrieved yet. |
| `sentence` | [Sentence](https://offendersearch.app/docs/criminal/record-object#object-sentence) |  | The sentence on the case. The key is absent when the source publishes none. |
| `chargesPending` | `boolean` |  | Present, and `true`, only when the court lists the case and its charges have not been retrieved; `charges` is then `[]`. Example `true`. |
| `caseType` | `string` or null |  | Case type or category, as published (`"FELONY"`, `"Criminal"`). On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). Example `"FELONY"`. |
| `judge` | `string` or null |  | Judge or judicial officer. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). Example `"Hon. A. Example"`. |
| `attorney` | `string` or null |  | Defense attorney. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). Example `"B. Example"`. |
| `prosecutor` | `string` or null |  | Prosecutor. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |
| `division` | `string` or null |  | Court division or department. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |
| `balanceDue` | `string` or null |  | Fines and costs still owed, as published. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |
| `plaintiff` | `string` or null |  | Plaintiff or complainant, as published (often the state). On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). Example `"State of Washington"`. |
| `offenseDate` | `string` or null |  | Offense date the source gives for the case. ISO when it parses cleanly. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |
| `convictedDate` | `string` or null |  | Conviction date (or year), as published. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |
| `sentencedDate` | `string` or null |  | Sentencing or judgment date, as published. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |
| `chargeCount` | `string` or null |  | Number of charges, as published. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |
| `chargeSummary` | `string` or null |  | The charge as text, for sources that publish it only as text (calendars, dockets). On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). Example `"THEFT 3RD DEGREE"`. |
| `lastActivityDate` | `string` or null |  | Latest docket, filing or hearing date, as published. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |
| `nextHearing` | [NextHearing](https://offendersearch.app/docs/criminal/record-object#object-nexthearing) |  | Next scheduled court event. Always an object. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |
| `events` | array of [CaseEvent](https://offendersearch.app/docs/criminal/record-object#object-caseevent) |  | Docket history. `[]` when none. On a record with more than one case these are `null` / `[]` (the source does not say which case they belong to). |

## The Charge object — criminal charge fields

One charge on a case. `severity: null` means the source did not classify it, not that it is minor.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `statute` | `string` |  | Statute cited, as published. `""` when the source gives a description without a citation. Example `"TPC 30.02"`. |
| `description` | `string` |  | The charge as published. Example `"BURGLARY OF HABITATION"`. |
| `severity` | `string` or null — one of `"felony"`, `"misdemeanor"`, `"infraction"` |  | `felony`, `misdemeanor` or `infraction`; `null` when the source does not classify it. Do not infer it from `description`. |
| `disposition` | `string` |  | Disposition as published. Example `"GUILTY"`. |
| `dispositionDate` | `string` or null |  | Disposition date, ISO. Example `"2024-02-09"`. |
| `offenseDate` | `string` or null |  | Date of the offense, ISO. It can be years before `dispositionDate`; do not use one for the other. Example `"2023-05-30"`. |
| `plea` | `string` |  | Plea as published. Example `"GUILTY"`. |
| `counts` | `integer` or null |  | Number of counts. `null` means not published, not zero or one. Example `1`. |

## The Sentence object — criminal sentence fields

A sentence. Text fields are `""` when not published; dates are ISO or `null`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `length` | `string` |  | Sentence length as published (`"10 Years"`, `"LIFE"`). Example `"10 Years"`. |
| `minDate` | `string` or null |  | Earliest release or parole-eligibility date, where published. Example `"2026-01-01"`. |
| `maxDate` | `string` or null |  | Maximum expiration date, where published. Example `"2031-01-01"`. |
| `probation` | `string` |  | Probation terms as published. |
| `fine` | `string` or null |  | The fine as published. `null` when none is published. |
| `dischargeDate` | `string` or null |  | Date the sentence was discharged, where published. |
| `years` | `integer` or null |  | Years part of the length, where published as a number. `null` otherwise. Example `10`. |
| `months` | `integer` or null |  | Months part of the length, where published as a number. `null` otherwise. Example `0`. |
| `goodTimeLaw` | `string` |  | The good-time law that applies, in the source's words. |
| `truthInSentencing` | `string` |  | The truth-in-sentencing designation, in the source's words. |

## The Parole object — parole eligibility fields

Parole posture as published. Dates are ISO or `null`; text fields `""` when not published.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `eligibilityDate` | `string` or null |  | Date the person becomes eligible for parole. |
| `nextReviewDate` | `string` or null |  | Date of the next parole review. |
| `reviewStatus` | `string` |  | Parole review status. |
| `lastDecision` | `string` |  | The most recent parole decision. |

## The Custody object — custody classification fields

Custody classification, alongside `currentStatus` and `custodyFacility`. Text fields `""` when not published.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `custodyClass` | `string` |  | Custody classification. |
| `description` | `string` |  | Custody description. |
| `facilityType` | `string` |  | Facility type. |

## The criminal SourceRef object — sources[] fields

The jurisdiction a record comes from. `code` names a state and a place, nothing else.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `code` | `string` |  | Public jurisdiction code: `<ST>-<COUNTY>` for a county (`TX-BEXAR`) or `<ST>-<KIND>` for a statewide or federal system (`TX-DOC`, `OK-COURT`, `US-WARRANT`). Send it back in `live.jurisdictions`. Example `"TX-BEXAR"`. |
| `level` | `string` — one of `"county"`, `"state"`, `"federal"` | default `"county"` | `county`, `state` or `federal`. |
| `recordUrl` | `string` |  | The source's own page for this record. `""` when the source has none. Example `"https://example-roster.gov/booking/889201"`. |
| `lastCheckedAt` | `string` or null |  | When this person was last seen at this jurisdiction, ISO-8601; `null` when not on file. On a live-verified entry, this request's time. Example `"2026-08-24T05:12:00+00:00"`. |
| `live` | `boolean` |  | `true` when this entry was re-verified live in this request. |

## The Lifecycle object — criminal record lifecycle fields

Records are never deleted when a person leaves a source; the departure is reported here and the record is still returned.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `firstSeen` | `string` or null |  | When OffenderSearch first recorded this person at the source. Example `"2023-06-15T00:00:00+00:00"`. |
| `lastSeen` | `string` or null |  | The latest time the person was on the source. Example `"2026-08-24T05:12:00+00:00"`. |
| `presentInLastSweep` | `boolean` |  | `true` when the person was on the source at its latest check. |
| `droppedOut` | `boolean` |  | `true` when the person has left the source since first seen. The record is still returned. |
| `droppedOutDetected` | `string` or null |  | When the departure was detected. `null` while the person is present. |
| `dropInterpretation` | `string` — one of `"released"`, `"source_removed"`, `"reinstated"`, `"unknown"` |  | What a departure most likely means for this kind of record: `released` (a jail booking), `source_removed` (a court, prison-system or registry record), `reinstated` (an exclusion-list record that left the list: the person was reinstated or the exclusion term ended), `unknown` (still present, or no reading). |
| `sourceLastUpdated` | `string` or null |  | When the source itself last changed this record, where the source says. ISO when it parses cleanly, otherwise the source's text. `null` when not published. Example `"2026-10-01"`. |

## The Exclusion object — exclusion-list record fields

The `exclusion` block of an exclusion-list record (`recordType: "healthcare_exclusion"`): what the publishing list says about the person. Three list families are carried: the HHS Office of Inspector General's List of Excluded Individuals/Entities (`medicare_medicaid`, republished monthly), the SAM.gov exclusions placed by federal agencies (`federal_procurement`, published daily) and the state Medicaid exclusion and sanction lists (`state_medicaid`, mostly monthly). `convictionBased` decides whether the record is returned on every search or only with `include: ["exclusions"]`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `program` | `string` — one of `"medicare_medicaid"`, `"federal_procurement"`, `"state_medicaid"` |  | Which list family placed the exclusion: `medicare_medicaid` (the HHS-OIG list, public code `US-EXCLUSION`), `federal_procurement` (a SAM.gov exclusion from a federal agency, `US-EXCLUSION`) or `state_medicaid` (a state Medicaid exclusion or sanction list, `<ST>-EXCLUSION`). Example `"medicare_medicaid"`. |
| `authority` | `string` |  | The authority for the exclusion as the list labels it — an HHS-OIG statutory code such as `1128(a)(1)`, a SAM exclusion type such as `Prohibition/Restriction`, or a state action such as `Termination`. Example `"1128(a)(1)"`. |
| `authorityDescription` | `string` |  | The authority in words, from the list's own vocabulary. Example `"Conviction of program-related crimes"`. |
| `statute` | `string` |  | The statute cited for the authority. `""` when the list cites none. Example `"42 USC 1320a-7(a)(1)"`. |
| `convictionBased` | `boolean` |  | `true` when the authority is a criminal conviction — HHS-OIG 1128(a)(1)-(4), (c)(3)(G) and (b)(1)-(3), or a SAM or state entry whose stated reason names a conviction. These records are returned on every search. `false` — a license action, an administrative debarment, a loan default or another non-conviction basis — is returned only with `include: ["exclusions"]`. |
| `excludedOn` | `string` or null |  | ISO date the exclusion took effect. `null` when the list publishes none. Example `"2021-03-19"`. |
| `reinstatedOn` | `string` or null |  | ISO date of reinstatement where the list publishes one on the record. A person who has left the list is also reported through `lifecycle` (`droppedOut: true`, `dropInterpretation: "reinstated"`). |
| `minimumPeriodYears` | `integer` or null |  | The statutory minimum exclusion period in years where the authority sets one — 5 for HHS-OIG 1128(a), 10 for a second mandatory exclusion, 3 for 1128(b)(1)-(3). `null` when there is none or the exclusion is indefinite. Example `5`. |
| `excludingAgency` | `string` |  | The agency that placed the exclusion: `HHS-OIG`, a SAM agency code, or a state agency. `""` when not published. Example `"HHS-OIG"`. |
| `npi` | `string` or null |  | The National Provider Identifier on the list entry, where published. Example `"1234567890"`. |
| `profession` | `string` or null |  | The profession or provider type the list records, as published. Example `"NURSING PROFESSION"`. |

## The Physical object — physical description fields

Physical description as the source publishes it. Every key is present; `null` when the source shows none. Values are the source's own text — `"5'10"`" and `"510"` both occur — never converted.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `height` | `string` or null |  | Height, as published. Example `"5'10\""`. |
| `weight` | `string` or null |  | Weight, as published. Example `"180 lbs"`. |
| `hairColor` | `string` or null |  | Hair color, as published. Example `"Brown"`. |
| `eyeColor` | `string` or null |  | Eye color, as published. Example `"Blue"`. |
| `build` | `string` or null |  | Build, as published. Example `"Medium"`. |
| `skinTone` | `string` or null |  | Skin tone or complexion, as published. Example `"Light"`. |
| `marks` | `string` or null |  | Scars, marks and tattoos, as published. Example `"Tattoo, left forearm"`. |
| `heightInches` | `integer` or null |  | `height` in total inches, when it is unambiguous (`5'10"`, `5-10`, `510`). `null` otherwise. Example `70`. |
| `weightPounds` | `integer` or null |  | `weight` in pounds, when it is unambiguous (`180`, `180 lbs`). `null` otherwise. Example `180`. |

## The Booking object — booking fields

The booking facts a jail roster publishes. Every key is present; `null` / `[]` when not published, which is typical outside jail records.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `bookingNumber` | `string` or null |  | The jail's booking number. Example `"2026-004812"`. |
| `bookingDate` | `string` or null |  | Booking date — ISO `YYYY-MM-DD` when it parses cleanly, otherwise the source's text. Example `"2026-10-03"`. |
| `arrestDate` | `string` or null |  | Arrest date — ISO when it parses cleanly, otherwise the source's text. Example `"2026-10-02"`. |
| `arrestingAgency` | `string` or null |  | The arresting agency. Example `"Example County Sheriff's Office"`. |
| `heldForAgency` | `string` or null |  | The agency the person is held for, when different. Example `"Example Police Department"`. |
| `arrestingOfficer` | `string` or null |  | The arresting officer, as published. |
| `housing` | `string` or null |  | Housing unit, pod, dorm or cell. Example `"2C-11"`. |
| `daysInJail` | `string` or null |  | Days in custody on this booking, as published. Example `"12"`. |
| `priorBookings` | `string` or null |  | Number of earlier bookings, as published. Example `"3"`. |
| `arrestTime` | `string` or null |  | Arrest time, as published. Example `"11:39 PM"`. |
| `bookingTime` | `string` or null |  | Booking or intake time, as published. Example `"1:05 AM"`. |
| `bookingType` | `string` or null |  | Booking or entry type, as published. Example `"New arrest"`. |
| `releaseReason` | `string` or null |  | How the person was released (bonded out, time served, transferred), as published. Example `"Bonded out"`. |
| `bond` | [Bond](https://offendersearch.app/docs/criminal/record-object#object-bond) |  | Bond or bail. Always an object; its keys are `null` when no bond is published. |
| `holds` | `array of string` |  | Holds and detainers, as published. `[]` when none. Example `["US Marshals"]`. |

## The Bond object — bond fields

Bond or bail set on a booking.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `amount` | `number` or null |  | The amount as a number, when the source publishes ONE plain amount (or a list of amounts that all parse, totalled). `null` otherwise — read `text`. Example `1500`. |
| `text` | `string` or null |  | The bond exactly as published (`"$1,500.00"`, `"NO BOND"`, `"$500; $250"`). Example `"$1,500.00"`. |
| `type` | `string` or null |  | Bond type or remarks, as published. Example `"Cash"`. |
| `company` | `string` or null |  | Bonding company, as published. |

## The Identifiers object — identifiers fields

Official numbers the source publishes for this person or case. Every key is present; `null` when not published.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `inmateNumber` | `string` or null |  | Prison or inmate number (DOC number, DIN, offender number). Example `"A123456"`. |
| `stateId` | `string` or null |  | State identification number (SID / SBI). Example `"12345678"`. |
| `fbiNumber` | `string` or null |  | FBI number. Example `"123456AB7"`. |
| `trackingNumber` | `string` or null |  | Offense tracking number (OTN / ITN). Example `"T1234567"`. |
| `warrantNumber` | `string` or null |  | Warrant number. Example `"W26-1182"`. |
| `ncicNumber` | `string` or null |  | NCIC number. Example `"W123456789"`. |
| `citationNumber` | `string` or null |  | Citation number. Example `"C26-55012"`. |
| `policeReportNumber` | `string` or null |  | Police or incident report number. Example `"26-017733"`. |
| `registrationNumber` | `string` or null |  | Registry registration number. Example `"R-20991"`. |
| `localPersonNumber` | `string` or null |  | The local agency's own person or arrest number (jacket, SO, SPN, MNI, ATN). Example `"J-118204"`. |

## The Warrant object — warrant fields

Warrant and wanted-person facts. Every key is present; `null` when not published, which is typical outside warrant records.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `issuedDate` | `string` or null |  | Date issued or wanted since — ISO when it parses cleanly, otherwise the source's text. Example `"2025-12-04"`. |
| `issuedBy` | `string` or null |  | The agency that issued it or wants the person. Example `"Example County Sheriff"`. |
| `type` | `string` or null |  | Warrant type, as published. Example `"Parole violation"`. |
| `reward` | `string` or null |  | Reward, as published. Example `"$3,000"`. |
| `caution` | `string` or null |  | Caution text, as published. Example `"ARMED AND DANGEROUS"`. |
| `escapedFrom` | `string` or null |  | The facility escaped from. |
| `escapedDate` | `string` or null |  | Escape date. |
| `lastSeen` | `string` or null |  | Last-seen location or date, as published. |
| `outstanding` | `string` or null |  | Outstanding or active warrant note, as published. |
| `captured` | `boolean` or null |  | `true` when the source marks the person captured or located, `false` when it says not, `null` when it says nothing. Example `false`. |

## The Supervision object — supervision fields

Parole or probation supervision. Every key is present; `null` when not published.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `officer` | `string` or null |  | Supervising officer or agent. Example `"J. Example"`. |
| `office` | `string` or null |  | Supervising office. Example `"Region 3 Parole Office"`. |
| `status` | `string` or null |  | Supervision status, as published. Example `"Active"`. |
| `type` | `string` or null |  | Supervision type, as published. Example `"Parole"`. |

## The ReleaseDates object — release dates fields

Projected and limit release dates a prison publishes (the actual release is `actualReleaseDate`). ISO when the text parses cleanly, otherwise the source's text; `null` when not published.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `projected` | `string` or null |  | Projected or tentative release date. Example `"2031-07-14"`. |
| `earliest` | `string` or null |  | Earliest or minimum release / parole-eligibility date. Example `"2028-01-10"`. |
| `maximum` | `string` or null |  | Maximum release date. Example `"2033-07-14"`. |
| `supervisionEnds` | `string` or null |  | End of supervision. |
| `parole` | `string` or null |  | Parole or parole-eligibility date, as published. |

## The RegistryFacts object — registry facts fields

Facts a non-sex-offender registry publishes (violent-offender, arson, animal-abuse, habitual-DUI and similar). Every key is present; `null` when not published.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` or null |  | Which registry, in words. Example `"Violent offender"`. |
| `registeredOn` | `string` or null |  | First registration date. Example `"2019-04-02"`. |
| `lastRegistered` | `string` or null |  | Most recent registration date. Example `"2026-04-02"`. |
| `expires` | `string` or null |  | Registration end date. Example `"2029-04-02"`. |
| `compliance` | `string` or null |  | Compliance status, as published. Example `"Compliant"`. |
| `offenderType` | `string` or null |  | Offender type or tier, as published. Example `"Violent offender"`. |
| `victimAge` | `string` or null |  | Victim age, as published. |
| `lifetime` | `boolean` or null |  | `true` when the registry marks the registration lifetime, `false` when it says not, `null` when it says nothing. |

## The NextHearing object — next hearing fields

The next scheduled court event. Every key is present; `null` when not published.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `string` or null |  | Date — ISO when it parses cleanly, otherwise the source's text. Example `"2026-12-07"`. |
| `time` | `string` or null |  | Time, as published. Example `"9:00 AM"`. |
| `type` | `string` or null |  | Hearing type, as published. Example `"Pre-trial conference"`. |
| `courtroom` | `string` or null |  | Courtroom, as published. Example `"3"`. |

## The CaseEvent object — case event fields

One entry of the docket history.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `string` or null |  | Date — ISO when it parses cleanly, otherwise the source's text. Example `"2022-05-11"`. |
| `type` | `string` or null |  | Event type (`"PRE-TRIAL CONFERENCE"`, `"filing"`, `"judgment"`). Example `"PRE-TRIAL CONFERENCE"`. |
| `description` | `string` or null |  | What happened, as published. Example `"Criminal Complaint"`. |
| `outcome` | `string` or null |  | Outcome or disposition, as published. Example `"Continued"`. |

## The OtherDetail object — other detail fields

One more fact the source published that has no named field above, under the source's own label.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` |  | The source's own name for the fact, in words (`"Arrest type"`, `"County of commitment"`). Example `"Arrest type"`. |
| `value` | `string` |  | The value as published. Lists and small tables are joined with `; `. Example `"On-view"`. |

## Criminal record lookup API request and response fields

`GET /v1/criminal/records/{recordId}` · auth: `X-API-Key`

Fetch one current record by its `externalId`, sending its `sources[].code` as `source` so the lookup is a direct key read. The record comes back unscored: `matchConfidence` is `0.0` and the match fields are empty, because there is no query to score against. Not billed.

### Request

**Path parameters**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `recordId` | `string` | required | The record's `externalId`, URL-encoded. It is unique only within its source, so send `sources[].code` as `source`. Example `"TX-BEXAR:booking:889201"`. |

**Query parameters**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `source` | `string` | optional | The record's `sources[].code`. With it the lookup is a direct key read. Without it the lookup is bounded to five seconds and answers 404 if the row is not found in time. Example `"TX-BEXAR"`. |

### Response

**200** (application/json) — The record, as a bare object (no envelope).

The body is [the `CriminalRecord` object](https://offendersearch.app/docs/criminal/record-object#object-criminalrecord). One record from one jurisdiction, as search, batch and the record lookup return it. A person found in several jurisdictions (booked in one county, a court case in another, an inmate record statewide) comes back as one record per jurisdiction; relate them by name, `dob` and `state`. Every key is present on every record. Empties mean different things: `""` is a text field the source does not publish, `null` is a date, number, object or demographic that is not known, and `[]` is a list with nothing on file.

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | Missing or invalid `X-API-Key`. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 402 | — | Free trial used up, or no active subscription. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 404 | `not_found` | No current record carries this id, for example a booking that has left the roster. |
| 503 | `unavailable` | Criminal Search could not be reached. Retry with backoff. |
| 504 | `upstream_timeout` | Criminal Search took longer than 65 seconds to answer. Retry, or narrow the query. |

---

# Normalized fields reference

> Every normalized class on a criminal record: outcomes, severity and its basis, categories, NCIC codes, custody events and case stages, beside the raw text.

HTML: https://offendersearch.app/docs/criminal/normalized-fields · Markdown: https://offendersearch.app/docs/criminal/normalized-fields.md

## Normalized criminal record fields beside the source's words

Every record carries a `normalized` object with one closed set of classes for category, severity, outcome, custody, person codes, places and dates. They are what [search filters](https://offendersearch.app/docs/criminal/filtering.md) test.

**The source's own text is always served beside it.** A normalized value is our classification; the published fact stays in the record's own fields, unchanged. A value that cannot be decided from what was published is `unknown` or `null`, never a guess.

- `normalized` is on every record, in search, batch and the record lookup. It is `null` when a record could not be classified.
- `rulesVersion` names the version of the rules that produced the values, and `quality` is `quarantined` when a source's normalized values are withheld (its own fields are unaffected).
- `include: ["normalizedDetail"]` on a search adds `rule` to every charge: which rule decided its outcome.
- `normalized.cases[]` and each case's `charges[]` follow the order of the record's own `cases[]` and `charges[]`.

```json
{
  "recordType": "court_case",
  "sex": "Male", "race": "White",
  "cases": [ { "caseNumber": "201944871", "status": "Disposed", "caseType": "Felony",
               "charges": [ { "statute": "PC 22.02", "description": "AGG ASSAULT W/DEADLY WEAPON",
                              "severity": "felony", "disposition": "GUILTY", "plea": "GUILTY" } ] } ],
  "normalized": {
    "rulesVersion": 1, "quality": "ok",
    "recordCategory": "court", "custodyStatus": "disposed",
    "sex": "M", "race": "W", "hair": null, "eyes": null,
    "residence": null,
    "jurisdiction": { "line1": null, "city": null, "county": "Harris", "countyFips": "48201",
                      "state": "TX", "zip5": null, "lat": null, "lng": null, "geoPrecision": "none" },
    "topSeverity": "felony", "hasConviction": true, "convictionCount": 1,
    "chargeCategories": ["assault"], "outcomes": ["convicted"],
    "latestOffenseDate": "2019-05-01", "latestDispositionDate": "2019-11-15",
    "latestFiledDate": "2019-05-03",
    "hasPhoto": false, "vacatedOrSealed": false,
    "cases": [ { "stage": "closed", "caseClass": "felony",
                 "charges": [ { "category": "assault", "notACharge": false,
                                "severity": "felony", "severityBasis": "published", "degree": null,
                                "outcome": "convicted", "convicted": true, "custodyEvent": null } ] } ]
  }
}
```

| Normalized value | The source's words, on the same record |
| --- | --- |
| `normalized.sex / race` | `sex` and `race` on the record, as published. |
| `normalized.hair / eyes` | `physical` on the record, as published. |
| `normalized.custodyStatus` | `custodyStatus` (the source's wording) and `currentStatus` on the record. |
| `normalized.residence` | `address` on the record. |
| `charges[].severity / degree` | `cases[].charges[].severity` and `statute` on the record. |
| `charges[].outcome / convicted` | `cases[].charges[].disposition` and `plea` on the record. |
| `cases[].stage / caseClass` | `cases[].status` and `caseType` on the record. |

## Criminal charge outcome classes

`outcome` on each charge, and `outcomes` on the record (every class present). `convicted` is `true` only for `convicted`.

| outcome | Meaning | convicted |
| --- | --- | --- |
| `convicted` | The charge ended in a conviction: a guilty plea, a guilty finding or a sentence, as the source records it. | `true` |
| `pending` | The charge is open and not yet decided. A hearing or motion about vacating a conviction is also `pending`. | `null` |
| `dismissed` | The court dismissed the charge. | `false` |
| `nolle_prosequi` | The prosecutor dropped the charge. | `false` |
| `acquitted` | The person was found not guilty. | `false` |
| `deferred_or_diverted` | Deferred adjudication, diversion or a similar program in which no conviction is entered. | `false` |
| `vacated` | The conviction was vacated, expunged, sealed, set aside or pardoned. | `false` |
| `warrant` | The charge is tied to an outstanding warrant. | `null` |
| `transferred` | The charge moved to another court or jurisdiction. | `null` |
| `closed_unspecified` | The charge is closed, and the source does not say how. | `null` |
| `administrative` | An administrative entry, not a decision on the charge. | `null` |
| `unknown` | The outcome cannot be decided from what the source published. | `null` |

## Criminal charge severity and severityBasis

`severity` on each charge, and `topSeverity` (the highest) on the record. `degree` carries the source's class or degree text, such as `F3` or `Class A`, when stated.

| severity | Meaning |
| --- | --- |
| `felony` | A felony. |
| `misdemeanor` | A misdemeanor. |
| `infraction` | An infraction or violation below a misdemeanor. |
| `unknown` | The severity cannot be decided from what was published. Never read it as minor. |

`severityBasis` says what decided it, and is `null` when the severity is `unknown`. Filter with `"severityBasis": ["published"]` (together with `severity`) to trust only the source's own label.

| severityBasis | Decided by |
| --- | --- |
| `published` | The source states the severity itself. |
| `statute_code` | A class code in the statute or charge (for example `F3` or `M1`). |
| `statute_table` | An official table of the state's statutes. |
| `case_type` | The type of case the charge was filed under. |
| `charge_text` | Words in the published charge description. |

## Criminal record and charge categories

`recordCategory` is the kind of record.

| recordCategory | Meaning |
| --- | --- |
| `court` | A court case. |
| `jail` | A jail booking. |
| `prison` | A state or federal prison record. |
| `warrant` | A warrant or a wanted-person notice. |
| `registry` | An offender registry entry. |
| `exclusion` | A government health-program or federal award exclusion. |
| `other` | Any other kind of record. |

`category` on each charge says what it is about, and `chargeCategories` on the record lists every one present. `notACharge: true` marks text in a charge slot that is not an offense, such as a custody, supervision or caption entry.

| category | Meaning |
| --- | --- |
| `homicide` | Homicide and manslaughter. |
| `sex_offense` | Sex offenses. |
| `robbery` | Robbery. |
| `domestic_violence` | Domestic violence, including protective-order violations. |
| `assault` | Assault, battery and threats. |
| `weapons` | Weapons offenses. |
| `burglary` | Burglary and breaking and entering. |
| `theft` | Theft, larceny and stolen property. |
| `fraud` | Fraud, forgery and identity theft. |
| `drug_distribution` | Drug manufacture, delivery and trafficking. |
| `drug_possession` | Drug possession. |
| `dui` | DUI and DWI. |
| `traffic` | Traffic offenses other than DUI. |
| `public_order` | Public order, trespass and property damage. |
| `supervision` | Probation and parole violations and failure to appear. |
| `other` | Other or unclear. |

## Criminal custody status and custody events

`custodyStatus` is the person's status on the record.

| custodyStatus | Meaning |
| --- | --- |
| `in_custody` | In jail or prison now. |
| `released` | Released. |
| `on_parole` | On parole. |
| `on_probation` | On probation. |
| `active_warrant` | An active warrant is out. |
| `wanted` | Wanted. |
| `registered` | On an offender registry. |
| `disposed` | The matter is disposed. |
| `absconder` | Absconded from supervision. |
| `deceased` | Deceased. |
| `excluded` | On a health-program or federal award exclusion list. |
| `unknown` | Not known. |

`custodyEvent` is a jail event listed against one charge. It is not a court outcome; the court outcome is `outcome`.

| custodyEvent | Meaning |
| --- | --- |
| `booked` | The person was booked on the charge. |
| `released` | The person was released on the charge. |
| `bonded` | Bond was posted on the charge. |
| `held` | The person is held on the charge. |
| `transferred` | The person was transferred on the charge. |
| `null` | No jail event is listed against the charge. |

## Criminal case stages and case classes

| stage | Meaning |
| --- | --- |
| `open` | The case is open. |
| `closed` | The case is closed. |
| `warrant` | The case has an outstanding warrant. |
| `unknown` | The stage cannot be decided. |

| caseClass | Meaning |
| --- | --- |
| `felony` | A felony case. |
| `misdemeanor` | A misdemeanor case. |
| `traffic` | A traffic case. |
| `infraction` | An infraction case. |
| `civil` | A civil case. |
| `unknown` | The class cannot be decided. |

## NCIC person codes on criminal records

`sex`, `race`, `hair` and `eyes` use the codes police systems use (NCIC). Each is `null` when the source publishes nothing, and the published words stay in the record's own `sex`, `race` and `physical`.

| sex | Meaning |
| --- | --- |
| `M` | Male |
| `F` | Female |
| `X` | Another marker the source publishes |
| `U` | Unknown |

| race | Meaning |
| --- | --- |
| `W` | White |
| `B` | Black |
| `I` | American Indian or Alaska Native |
| `A` | Asian or Pacific Islander |
| `H` | Hispanic, only where the source publishes it as a race |
| `U` | Unknown |

| hair | Meaning |
| --- | --- |
| `BLK` | Black |
| `BRO` | Brown |
| `BLN` | Blond |
| `RED` | Red |
| `GRY` | Gray |
| `WHI` | White |
| `SDY` | Sandy |
| `BLD` | Bald |
| `XXX` | Unknown |

| eyes | Meaning |
| --- | --- |
| `BLK` | Black |
| `BLU` | Blue |
| `BRO` | Brown |
| `GRN` | Green |
| `GRY` | Gray |
| `HAZ` | Hazel |
| `MAR` | Maroon |
| `PNK` | Pink |
| `MUL` | Multicolored |
| `XXX` | Unknown |

## Vacated and sealed criminal records: vacatedOrSealed

`vacatedOrSealed` is `true` when the source marks the record or a charge vacated, expunged or sealed. Such a record is marked and always returned; a vacated charge has the outcome `vacated` and `convicted: false`. A search leaves it out only when it sends `excludeVacatedOrSealed: true`.

The record also carries `hasConviction`, `convictionCount`, `hasPhoto` and its latest offense, filing and disposition dates, which the date filters and `lookbackYears` test.

## The Normalized object — normalized criminal record fields

A record's `normalized` classes. Every value is our classification of a published fact; the fact itself stays in the record's own fields beside it. A value that cannot be decided from what was published is `unknown` or `null`, never a guess.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `rulesVersion` | `integer` |  | Version of the classification rules that produced these values. Example `1`. |
| `quality` | `string` — one of `"ok"`, `"quarantined"` |  | `quarantined` when this source's normalized values are withheld; the record's own fields are unaffected. |
| `recordCategory` | `string` — one of `"court"`, `"jail"`, `"prison"`, `"warrant"`, `"registry"`, `"exclusion"`, `"other"` |  | The kind of record. |
| `custodyStatus` | `string` — one of `"in_custody"`, `"released"`, `"on_parole"`, `"on_probation"`, `"active_warrant"`, `"wanted"`, `"registered"`, `"disposed"`, `"absconder"`, `"deceased"`, `"excluded"`, `"unknown"` |  | The person's custody status on this record. |
| `sex` | `string` or null — one of `"M"`, `"F"`, `"X"`, `"U"` |  | NCIC sex code. `null` when the source publishes none; its own text stays in `sex`. |
| `race` | `string` or null — one of `"W"`, `"B"`, `"I"`, `"A"`, `"H"`, `"U"` |  | NCIC race code. `H` only where the source publishes Hispanic as a race. Its own text stays in `race`. |
| `hair` | `string` or null — one of `"BLK"`, `"BRO"`, `"BLN"`, `"RED"`, `"GRY"`, `"WHI"`, `"SDY"`, `"BLD"`, `"XXX"` |  | NCIC hair-color code. |
| `eyes` | `string` or null — one of `"BLK"`, `"BLU"`, `"BRO"`, `"GRN"`, `"GRY"`, `"HAZ"`, `"MAR"`, `"PNK"`, `"MUL"`, `"XXX"` |  | NCIC eye-color code. |
| `residence` | [NormalizedPlace](https://offendersearch.app/docs/criminal/normalized-fields#object-normalizedplace) or null |  | Where the person lives, from the published address. `null` when none is published. |
| `jurisdiction` | [NormalizedPlace](https://offendersearch.app/docs/criminal/normalized-fields#object-normalizedplace) |  | Where the record is: the state and county of the court, jail or agency. |
| `topSeverity` | `string` — one of `"felony"`, `"misdemeanor"`, `"infraction"`, `"unknown"` |  | The highest severity of any charge on the record. |
| `hasConviction` | `boolean` |  | `true` when any charge's outcome is `convicted`. |
| `convictionCount` | `integer` |  | Charges whose outcome is `convicted`. |
| `chargeCategories` | `array of string` — one of `"supervision"`, `"homicide"`, `"sex_offense"`, `"robbery"`, `"domestic_violence"`, `"dui"`, `"assault"`, `"weapons"`, `"burglary"`, `"fraud"`, `"theft"`, `"drug_distribution"`, `"drug_possession"`, `"traffic"`, `"public_order"`, `"other"` |  | Every charge category on the record. |
| `outcomes` | `array of string` — one of `"convicted"`, `"pending"`, `"dismissed"`, `"nolle_prosequi"`, `"acquitted"`, `"deferred_or_diverted"`, `"vacated"`, `"warrant"`, `"transferred"`, `"closed_unspecified"`, `"administrative"`, `"unknown"` |  | Every outcome class on the record. |
| `latestOffenseDate` | `string (date)` or null |  | The latest offense date on the record. |
| `latestDispositionDate` | `string (date)` or null |  | The latest disposition date on the record. |
| `latestFiledDate` | `string (date)` or null |  | The latest filing date on the record. |
| `hasPhoto` | `boolean` |  | `true` when the record has a published photograph. |
| `vacatedOrSealed` | `boolean` |  | `true` when the source marks the record or a charge vacated, expunged or sealed. Such records are returned, marked, unless the search sends `excludeVacatedOrSealed: true`. |
| `cases` | array of [NormalizedCase](https://offendersearch.app/docs/criminal/normalized-fields#object-normalizedcase) |  | One entry per case, in the order of the record's `cases[]`. |

## The NormalizedPlace object — residence and jurisdiction

A place, normalized. Every text key is `null` when not known.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `line1` | `string` or null |  | Street line. Example `"16966 NORTHCHASE DR APT 313"`. |
| `city` | `string` or null |  | City. Example `"HOUSTON"`. |
| `county` | `string` or null |  | County. Example `"Harris"`. |
| `countyFips` | `string` or null |  | Five-digit county FIPS code. Example `"48201"`. |
| `state` | `string` or null |  | Two-letter state code. Example `"TX"`. |
| `zip5` | `string` or null |  | Five-digit ZIP. Example `"77060"`. |
| `lat` | `number` or null |  | Latitude. |
| `lng` | `number` or null |  | Longitude. |
| `geoPrecision` | `string` — one of `"rooftop"`, `"street"`, `"zip"`, `"none"` |  | How precise `lat`/`lng` are; `none` when there are no coordinates. |

## The NormalizedCase object — case stage and class

One case, normalized, in the order of the record's `cases[]`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `stage` | `string` — one of `"open"`, `"closed"`, `"warrant"`, `"unknown"` |  | Where the case stands. |
| `caseClass` | `string` — one of `"felony"`, `"misdemeanor"`, `"traffic"`, `"infraction"`, `"civil"`, `"unknown"` |  | The class of case. |
| `charges` | array of [NormalizedCharge](https://offendersearch.app/docs/criminal/normalized-fields#object-normalizedcharge) |  | One entry per charge, in the order of the case's `charges[]`. |

## The NormalizedCharge object — charge severity and outcome

One charge, normalized. The published statute, description, severity and disposition stay on the case's own `charges[]`.

Returned or accepted by [`POST /v1/criminal/search`](https://offendersearch.app/docs/criminal/search#reference-post-v1-criminal-search), [`GET /v1/criminal/searches/{searchId}`](https://offendersearch.app/docs/criminal/async#reference-get-v1-criminal-searches-searchid), [`POST /v1/criminal/batch`](https://offendersearch.app/docs/criminal/batch#reference-post-v1-criminal-batch), [`GET /v1/criminal/records/{recordId}`](https://offendersearch.app/docs/criminal/record-object#reference-get-v1-criminal-records-recordid).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `category` | `string` — one of `"supervision"`, `"homicide"`, `"sex_offense"`, `"robbery"`, `"domestic_violence"`, `"dui"`, `"assault"`, `"weapons"`, `"burglary"`, `"fraud"`, `"theft"`, `"drug_distribution"`, `"drug_possession"`, `"traffic"`, `"public_order"`, `"other"` |  | What the charge is about. `other` when no category fits. |
| `notACharge` | `boolean` |  | `true` when the text in the charge slot is not an offense (a custody, supervision or caption entry). |
| `severity` | `string` — one of `"felony"`, `"misdemeanor"`, `"infraction"`, `"unknown"` |  | Felony, misdemeanor or infraction; `unknown` when it cannot be decided. |
| `severityBasis` | `string` or null — one of `"published"`, `"statute_code"`, `"statute_table"`, `"case_type"`, `"charge_text"` |  | What decided `severity`. `null` when `severity` is `unknown`. |
| `degree` | `string` or null |  | The source's class or degree text (`F3`, `Class A`), when stated. Example `"Class A"`. |
| `outcome` | `string` — one of `"convicted"`, `"pending"`, `"dismissed"`, `"nolle_prosequi"`, `"acquitted"`, `"deferred_or_diverted"`, `"vacated"`, `"warrant"`, `"transferred"`, `"closed_unspecified"`, `"administrative"`, `"unknown"` |  | The outcome class. |
| `convicted` | `boolean` or null |  | `true` for `convicted`; `false` for `acquitted`, `dismissed`, `nolle_prosequi`, `deferred_or_diverted` and `vacated`; `null` otherwise. |
| `custodyEvent` | `string` or null — one of `"booked"`, `"released"`, `"bonded"`, `"held"`, `"transferred"` |  | A jail event listed against the charge (not a court outcome). `null` when none. |
| `rule` | `string` |  | Only with `include: ["normalizedDetail"]`. Which rule decided `outcome`, for audits. |

---

# Verification reports

> Generate a branded, timestamped PDF attesting a criminal search was performed, with a neutral public citation on every record and the FCRA notice in full.

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

## A defensible artifact for your audit file

A verification report is a branded, timestamped PDF that attests a criminal-records search was **performed** — the criminal analogue of the Sex-Offender API's report. It shows the query, the run timestamp and a report id (`rpt_…`), the matching records **grouped by DOB/age match strength**, each record with its identity and its **public source citation** (the neutral jurisdiction code plus a last-checked stamp), a records-searched count, and the FCRA "not a consumer report" notice, prominently. A zero-match search renders a clean "no records found" attestation.

> **The report names states, counties and record kinds — never sources.** Every citation is a neutral public code (`OK-COURT`, `TX-BEXAR`) with a freshness stamp. No portal, platform or vendor name appears anywhere in the PDF.

## POST /v1/criminal/report

**Authentication:** `X-API-Key`, available on every account exactly like `/search` — no entitlement required. Provide **exactly one** of two inputs, `searchId` or `query`; every field is in the request and response tables below.

The response is **the PDF bytes** (`application/pdf`). The report id (`rpt_` + 12 hex) is printed on the document and returned in the `X-Report-Id` response header; `Content-Disposition` names the attachment `offendersearch-criminal-<reportId>.pdf`.

```bash
curl -sS -X POST "https://api.offendersearch.app/v1/criminal/report" \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
  -d '{"searchId":"crs_7f2a1c9e0b4d6a8f1e23","viewerName":"Case Worker","purpose":"Records verification"}' \
  -o verification.pdf
```

> **The report is a snapshot of one search, not a live query.** A `searchId` report renders the stored result — the page that search returned, up to its `perPage` — so the completeness the search reported is the completeness the document reflects; an inline `query` report lists every matching record. Billing: `criminal_report` is a small fixed per-PDF charge; a `searchId` report bills only that, an inline-`query` report bills the search and the report (plus each completed live source when a `live` block is sent).

## Criminal verification report API request and response fields

`POST /v1/criminal/report` · auth: `X-API-Key`

Render a PDF that records a search was run: the criteria, the sources searched and the records found (for a stored `searchId`, the page that search returned), with the not-a-consumer-report notice. A report on a stored `searchId` bills only the report; an inline `query` bills the search too.

### Request

**Body** (application/json)

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `searchId` | `string` | optional | A `searchId` from a search on your account. The stored result is rendered without re-running the search, and only the report is billed. Example `"crs_7f2a1c9e0b4d6a8f1e23"`. |
| `query` | [Query](https://offendersearch.app/docs/criminal/search#object-query) | optional | Run this search now and render it. The search bills a `criminal_call` and the report bills on top. The query fields can also be sent flat at the top level. |
| `live` | [LiveRequest](https://offendersearch.app/docs/criminal/async#object-liverequest) | optional | With `query`: re-verify these jurisdictions before rendering. The checks run inside the request whatever their number; completed checks bill as on a search. |
| `viewerName` | `string` | optional | Printed at the top of the report as the person who ran it. A record of who viewed it, not an access control. |
| `requesterName` | `string` | optional | Alias for `viewerName`, read only when `viewerName` is absent. |
| `viewerEmail` | `string` | optional | Printed at the top of the report. |
| `purpose` | `string` | optional | Printed on the report as the stated purpose of the search. |
| `reference` | `string` | optional | Printed on the report: your own file or case number. |

### Response

**200** (application/pdf) — The PDF bytes. The report id (`rpt_` + 12 hex) is printed on the document and returned in the `X-Report-Id` header; `Content-Disposition` names the attachment `offendersearch-criminal-<reportId>.pdf`.

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | Missing or invalid `X-API-Key`. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 402 | — | Free trial used up, or no active subscription. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 400 | `no_report_input` | Neither `searchId` nor a `query` was sent. |
| 404 | `not_found` | No search with this `searchId` on your account, or the search has no stored result yet (a job still pending). |
| 422 | `guard_unbounded_query` | An inline `query` failed validation (also `prefix_too_short`, `invalid_dob`). |
| 503 | `unavailable` | Criminal Search could not be reached. Retry with backoff. |
| 504 | `upstream_timeout` | Criminal Search took longer than 65 seconds to answer. Retry, or narrow the query. |

## Criminal search history API response fields

`GET /v1/criminal/history` · auth: `X-API-Key`

Your account's 50 most recent criminal searches, newest first, with the `searchId` a report needs. Not billed.

### Request

No parameters.

### Response

**200** (application/json) — The searches.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `searches` | array of [HistoryEntry](https://offendersearch.app/docs/criminal/verification-reports#object-historyentry) |  | The 50 most recent searches, newest first. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | Missing or invalid `X-API-Key`. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 402 | — | Free trial used up, or no active subscription. Body: [GatewayError](https://offendersearch.app/docs/criminal/errors#object-gatewayerror). |
| 503 | `unavailable` | Criminal Search could not be reached. Retry with backoff. |
| 504 | `upstream_timeout` | Criminal Search took longer than 65 seconds to answer. Retry, or narrow the query. |

## The HistoryEntry object — one prior search

One prior criminal search. Pass `searchId` to [POST /v1/criminal/report](https://offendersearch.app/docs/criminal/verification-reports#reference-post-v1-criminal-report) to render it.

Returned or accepted by [`GET /v1/criminal/history`](https://offendersearch.app/docs/criminal/verification-reports#reference-get-v1-criminal-history).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `searchId` | `string` |  | The search's id. Example `"crs_7f2a1c9e0b4d6a8f1e23"`. |
| `tier` | `string` — one of `"core"`, `"live"` |  | `core` (cached) or `live`. |
| `status` | `string` — one of `"complete"`, `"pending"` |  | `complete` or `pending`. |
| `query` | `object` |  | Who you searched — your own search terms, blanks left out, so a history row reads "Jane Smith · DOB 1990-01-02 · GA". Never the street address. |
| `query.first` | `string` |  | First name searched. |
| `query.last` | `string` |  | Last name searched. |
| `query.dob` | `string` |  | Full date of birth, YYYY-MM-DD. |
| `query.birthYear` | `integer` |  | Birth year searched. |
| `query.age` | `integer` |  | Age searched. |
| `query.state` | `string` |  | State searched. |
| `query.county` | `string` |  | County searched. |
| `query.city` | `string` |  | City searched. |
| `query.zipcode` | `string` |  | ZIP code searched. |
| `recordsCount` | `integer` |  | Records the search matched. |
| `liveCompleted` | `integer` |  | Live sources that completed. `0` on a cached search. |
| `createdAt` | `string (date-time)` |  | When the search ran. |

---

# Migrating from another provider

> Move a legacy criminal-records integration onto one normalized envelope: the parameter map, the additive recordType note, and the labelled fields you gain.

HTML: https://offendersearch.app/docs/criminal/migration · Markdown: https://offendersearch.app/docs/criminal/migration.md

## One normalized envelope

The Criminal Search API returns one normalized record schema across every jurisdiction, so you stop stitching differing per-state formats together yourself. Moving a legacy criminal-records integration means changing the base URL and key, then adopting the labelled fields the envelope adds.

```plain
- const BASE = "https://api.previous-provider.example";
+ const BASE = "https://api.offendersearch.app";
- headers: { "Authorization": "Bearer " + LEGACY_KEY }
+ headers: { "X-API-Key": process.env.OFFENDERSEARCH_KEY }
```

## Parameter map

| Concern | Criminal Search API |
| --- | --- |
| Search predicate | `query.lastName` **required**; `firstName`, `state`, `county`, `dob`/`birthYear`/`age` narrow it. |
| Pagination | `page` / `perPage` at the **top level** (not inside `query`); at most 200 records per page. |
| Name widening | `nameStrategy: "prefix"` (default) or `"exact"`. **No fuzzy surname matching, ever.** |
| Live re-check | a `live` block — `{ "jurisdictions": [...] }`, `{ "scope": "matched" }` or `{ "websites": [...] }` — billed per completed source to a $2.00 ceiling. |
| Location filter | `query.state`/`query.county` (jurisdiction) + `query.city`/`query.zipcode`/`query.address` (residence, where published). No lat/lng radius. |
| Errors | `{ "error": { "code", "message" } }` from Criminal Search — branch on `code`; a `401` or `402` comes from the gateway as `{ "detail": "…" }`. |

## What you gain

- **Labelled match strength.** `matchState`, `matchConfidence` and `matchBasis` on every record, so you set your own auto-accept threshold rather than inherit one.
- **A completeness signal.** On a live search, `counts.sourcesIncomplete` separates *no match* from *not determined*.
- **Neutral jurisdiction codes.** Every place is a `<ST>-<COUNTY>` or `<ST>-<RECORD-KIND>` code — a state and a place, and nothing else.
- **Stored history and CSV batch.** Prior searches are recorded, and up to 1000 cached lookups run in one call.

## The additive-recordType note

Criminal records are a distinct product on the same account and key as the Sex Offender API, returned with their own `recordType` values (`criminal_record`, or the kind — `jail_booking`, `doc_inmate`, `court_case`, `warrant`, `registry` and others). New data lands as a new `recordType`; existing keys and their meanings do not change under you. A caller who knows one API knows the other.

> Results from the standard endpoints are not a consumer report and may not be used for any FCRA-covered purpose — the notice is present in the `legal` block of every search response. FCRA-regulated use runs as FCRA orders under FCRA Partner Access — see the FCRA API.
