# Performing your first criminal search

> One request, one surname, one envelope. This guide takes you from a test key to a correctly read answer, including the two refusals every integration must handle.

- **HTML:** https://offendersearch.app/docs/guides/criminal/first-search
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## What you'll build

A function that sends one person's name and date of birth to `POST /v1/criminal/search`, handles a refusal, and turns the envelope into three facts a reviewer can act on: whether the search finished (`status`), how many people matched (`counts.records`), and how strongly each matched (`matchState`, `matchConfidence`). It is the building block for screening, admissions and visitor workflows; the industry pages in [Solutions](/docs/solutions) all start from it.

A cached search reads the stored copy of the federal, state and county sources the platform holds and answers in under a second. It never consults a source live unless you add a `live` block, which [Cached vs. live verification](/docs/guides/criminal/cached-vs-live) covers. Nothing here is a consumer report: every envelope says so in `legal.notice`.

## Before you start

- A test key from [Dashboard → Keys](/dashboard/keys). Test keys begin `os_test_` and work only against `https://sandbox.offendersearch.app`; live keys begin `os_live_` and work only against `https://api.offendersearch.app`. Send the key in the `X-API-Key` header.
- The sandbox people. `Felony Record`, born `1975-06-15`, is one court case with a felony conviction. `Clear Record` returns zero records. `Possible Match` returns three undated people. The full list is on [Sandbox & test keys](/docs/criminal/sandbox); the names match on first and last name together, so send both.
- Nothing you do on the sandbox is billed or recorded to your usage. Responses are the production shape plus `"sandbox": true`.

> **The surname is required and nothing else is.** A body without `lastName` is refused with `422 guard_unbounded_query` before anything runs. `firstName`, `dob`, `state` and the rest narrow within the surname; they cannot replace it. `page` and `perPage` sit at the top level of the body, next to `query`, not inside it.

## Step by step

1. **Send the search.** Wrap the person in `query`. The date of birth is optional but changes everything about how the answer reads, so send it when you have it.
2. **Branch on the HTTP status before reading the body.** `200` is an answer. `422` is a refusal whose body is `{"error": {"code", "message"}}` — nothing was searched and nothing was billed. `401` and `402` come from the gateway as `{"detail": "..."}`.
3. **Read status, then counts, then records.** `status` is `complete` on every cached search. `counts.records` is the total before paging; `counts.recordsReturned` is this page. Each record carries its own match verdict.
4. **Keep the searchId.** `searchId` (`crs_` + 20 hex) is how you render a report later (`POST /v1/criminal/report`) and how the search appears in `GET /v1/criminal/history`.

**cURL**
```bash
curl -s https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: os_test_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "query": { "lastName": "Record", "firstName": "Felony", "dob": "1975-06-15" },
    "page": 1,
    "perPage": 50
  }'
```

**Python**
```python
import requests

API = "https://sandbox.offendersearch.app"   # live: https://api.offendersearch.app
KEY = "os_test_your_key"                      # live keys begin os_live_

def criminal_search(last, first=None, dob=None):
    body = {"query": {"lastName": last}, "page": 1, "perPage": 50}
    if first:
        body["query"]["firstName"] = first
    if dob:
        body["query"]["dob"] = dob            # YYYY-MM-DD
    r = requests.post(f"{API}/v1/criminal/search", headers={"X-API-Key": KEY}, json=body, timeout=90)
    data = r.json()
    if r.status_code in (401, 402):
        raise RuntimeError(f"{r.status_code}: {data['detail']}")      # gateway shape
    if r.status_code == 422:
        err = data["error"]
        raise ValueError(f"refused {err['code']}: {err['message']}")  # criminal shape
    r.raise_for_status()
    return data

env = criminal_search("Record", "Felony", "1975-06-15")
print(env["status"], env["counts"]["records"], env["searchId"])
for rec in env["records"]:
    print(rec["matchState"], rec["matchConfidence"], rec["recordCategory"],
          rec["name"]["first"], rec["name"]["last"], rec["state"])
```

**JavaScript**
```javascript
const API = 'https://sandbox.offendersearch.app'; // live: https://api.offendersearch.app
const KEY = 'os_test_your_key';                    // live keys begin os_live_

async function criminalSearch({ last, first, dob }) {
  const query = { lastName: last };
  if (first) query.firstName = first;
  if (dob) query.dob = dob; // YYYY-MM-DD
  const r = await fetch(API + '/v1/criminal/search', {
    method: 'POST',
    headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, page: 1, perPage: 50 }),
  });
  const data = await r.json();
  if (r.status === 401 || r.status === 402) throw new Error(r.status + ': ' + data.detail); // gateway shape
  if (r.status === 422) throw new Error('refused ' + data.error.code + ': ' + data.error.message);
  if (!r.ok) throw new Error('HTTP ' + r.status);
  return data;
}

const env = await criminalSearch({ last: 'Record', first: 'Felony', dob: '1975-06-15' });
console.log(env.status, env.counts.records, env.searchId);
for (const rec of env.records) {
  console.log(rec.matchState, rec.matchConfidence, rec.recordCategory, rec.name.first, rec.name.last, rec.state);
}
```

The answer, trimmed to the keys this guide reads (the full record has about sixty top-level keys — see [the Record object](/docs/criminal/record-object)):

*200 — a complete cached answer*
```json
{
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 0, "sourcesComplete": 0,
              "sourcesIncomplete": 0, "candidatesCapped": false, "exclusionsIncluded": false },
  "warnings": [],
  "page": 1, "perPage": 50, "totalPages": 1,
  "sourceStatus": [],
  "cappedOmittedSources": [],
  "omittedFields": ["sourceData"],
  "records": [
    {
      "recordType": "court_case",
      "recordCategory": "court",
      "externalId": "SBX-CR-2015-000123",
      "personRef": "prs_4c1d9e7a2b8f0e6d3a51",
      "name": { "first": "Felony", "middle": "Sample", "last": "Record", "suffix": "" },
      "dob": { "date": "1975-06-15", "birthYear": 1975, "birthMonth": 6, "age": 51,
               "ageAsOf": "2026-10-01", "dobPrecision": "full" },
      "state": "OH", "county": "Sample",
      "currentStatus": "on_probation",
      "matchConfidence": 0.99,
      "matchState": "dob_match",
      "matchBasis": "lastName:exact firstName:exact dob:match",
      "matchDetail": "name and full date of birth agree",
      "unverified": false,
      "dobVerification": "record",
      "cases": [ { "caseNumber": "SBX-CR-2015-000123", "court": "Sample County Court of Common Pleas",
                   "filedDate": "2015-03-02", "status": "Closed", "charges": [ "…" ] } ],
      "sources": [ { "code": "OH-SAMPLE", "level": "county", "recordUrl": "https://offendersearch.app/docs/sandbox",
                     "lastCheckedAt": "2026-10-01T00:00:00+00:00", "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.)." },
  "sandbox": true
}
```

Now send the same request without `lastName` and with a surname that is too common. The first is refused at once; the second is refused only when the stored records matching it cannot all be read within the time budget (the sandbox is too small to trigger it, so the second body below is from production):

*422 — the two refusals you must handle*
```json
// query: { "firstName": "Felony" }
{ "error": { "code": "guard_unbounded_query",
             "message": "a lastName predicate is required — an unbounded query returns a lottery, not an answer" } }

// query: { "lastName": "Smith" }  (production, no firstName or state)
{ "error": { "code": "query_too_broad",
             "message": "This query is too broad to be answered completely: more than 150,000 candidate records matched it in the stored criminal records and they could not all be searched within 25 seconds. …" },
  "detail": "This query is too broad to be answered completely: …" }
```

`query_too_broad` is a refusal, not a partial answer: the API never returns a short list and calls it complete. Add `firstName` or `state` and resend — a county, city or ZIP on its own does not narrow the candidate walk.

## Reading the result

| Field | Values | What it means for a first search |
| --- | --- | --- |
| `status` | `complete` · `partial` | Always `complete` on a cached search. `partial` appears only when a `live` block ran and a source fell short. |
| `counts.records` | integer | People matched in total, before paging. `0` with `status: "complete"` is a clear answer for this surname and the fields you sent. |
| `counts.recordsReturned` | integer | Records on this page. Walk `page = 1 … totalPages` when it is smaller than `counts.records`. |
| `records[].matchState` | `dob_match` · `year_match` · `age_match` · `no_dob_age_year` | How the birth date agreed. `no_dob_age_year` is the value on every record when you sent no `dob`; it means "the name matched and nothing could confirm or deny it". |
| `records[].matchConfidence` | `0.0`–`0.99` | A ceiling on the evidence held, never a probability. `0.99` here is surname + first name + full date of birth; the same person with no date sent scores `0.80`. |
| `records[].unverified` | boolean | `true` iff `matchState` is `no_dob_age_year`. Route these to a reviewer rather than treating them as a hit. |
| `records[].recordCategory` | `court` · `jail` · `prison` · `warrant` · `registry` · `exclusion` · `other` | The closed kind of record. Branch on this, not on `recordType`. |
| `searchId` | `crs_` + 20 hex | Store it with the case file; a report can be rendered from it later. |
| `meterEvents[]` | `criminal_call` (+ `criminal_live_source`) | What this search metered. One `criminal_call` per search, cached or live. |

## Edge cases and pitfalls

- **Zero records on the sandbox for a real-looking name.** The sandbox answers only its fixed names, and only when first and last name both match. Any other name is a complete answer with zero records — not a bug.
- **A date of birth that contradicts the record drops it.** `Felony Record` with `dob: "1990-01-01"` returns zero records: a `dob_mismatch` never appears in `records[]`. Check the date you sent before concluding the person is clear.
- **`perPage` is validated, not clamped.** `perPage: 300` is `422 invalid_page`; the range is 1–200. `page: 0` is refused the same way.
- **Each page is a new search.** Paging re-runs the query and meters another `criminal_call`; fetch only the pages you will read. See [Pagination](/docs/criminal/pagination).
- **`""`, `null` and `[]` are three different answers.** `name.middle: ""` means the source published no middle name; `dob.date: null` means no full date is known; `cases: []` means no case is on file. Do not collapse them. [Data model](/docs/platform/data-model).
- **`currentStatus` is the source's own text.** Display it; branch on `normalized.custodyStatus`, which is closed. [Understanding record types](/docs/guides/criminal/record-types).
- **A 401 on the live host with a test key** is expected: the `detail` sentence names the sandbox URL. Keys do not cross hosts.

## Production checklist

- Switch the host to `https://api.offendersearch.app` and the key to an `os_live_` key; keep `X-API-Key` as the header.
- Handle `422` by `error.code`, `401`/`402` by `detail`, and never read `detail` on a criminal `422`.
- Treat `unverified: true` records as candidates for review, not as findings.
- Store `searchId` and `legal.notice` alongside the decision you make.
- Narrow a `query_too_broad` with `firstName` or `state`; do not retry it unchanged.

## Related

- [Search](https://offendersearch.app/docs/criminal/search): every request field
- [Sandbox & test keys](https://offendersearch.app/docs/criminal/sandbox): the fixed names and what each returns
- [The Record object](https://offendersearch.app/docs/criminal/record-object)
- [Errors & status codes](https://offendersearch.app/docs/criminal/errors)
- [Getting started in five minutes](https://offendersearch.app/docs/platform/getting-started)
