# Search criminal records by name and date of birth with an API

> A criminal records API by name, walked through on the sandbox: the required surname, the three answers one name gives with and without a date of birth, how to read a disposition, the record kinds you will see, scoping by state and county, and when a live search answers 202.

- **HTML:** https://offendersearch.app/docs/learn/search-criminal-records-by-name-and-dob
- **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

## Search criminal records by name and date of birth: the request

The [criminal records API](/criminal-records-api) is one endpoint, `POST /v1/criminal/search`, with the search terms inside a `query` object and the paging controls beside it. Two rules decide whether the request is accepted. `query.lastName` is **required**: a body without a surname is refused with `422` and the coded error `guard_unbounded_query`, because a search with no surname is unbounded. And `page` and `perPage` belong at the **top level** of the body, not inside `query`; `perPage` runs from 1 to 200 and defaults to 50, and a value outside that range is `422 invalid_page`.

**curl**
```bash
# Criminal records by name and date of birth, on the sandbox (free)
# lastName is required; page and perPage sit at the TOP level, not inside query
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"}, "page": 1, "perPage": 50}'
```

**Python**
```python
# pip install requests
import os, requests

KEY = os.environ["OFFENDERSEARCH_TEST_KEY"]      # os_test_… from the dashboard
HOST = "https://sandbox.offendersearch.app"

def criminal_search(first, last, dob=None, state=None, page=1, per_page=50):
    query = {"firstName": first, "lastName": last}   # lastName is required (422 guard_unbounded_query without it)
    if dob:
        query["dob"] = dob
    if state:
        query["state"] = state
    r = requests.post(
        f"{HOST}/v1/criminal/search",
        headers={"X-API-Key": KEY},
        json={"query": query, "page": page, "perPage": per_page},
        timeout=130,
    )
    if r.status_code == 422:
        raise ValueError(r.json()["error"]["code"])   # e.g. guard_unbounded_query, invalid_page
    r.raise_for_status()
    return r.json()

body = criminal_search("Felony", "Record", dob="1975-06-15")
print(body["status"], body["counts"]["records"], body["legal"]["notice"])
for rec in body["records"]:
    print(rec["recordType"], rec["matchState"], "unverified" if rec["unverified"] else "verified")
    for case in rec["cases"]:
        for ch in case["charges"]:
            print("  ", ch["severity"], ch["description"], "->", ch["disposition"], ch["dispositionDate"])
```

**Node**
```javascript
// Node 18+: fetch is built in
const KEY = process.env.OFFENDERSEARCH_TEST_KEY;   // os_test_… from the dashboard
const HOST = "https://sandbox.offendersearch.app";

async function criminalSearch({ firstName, lastName, dob, state, page = 1, perPage = 50 }) {
  const query = { firstName, lastName };            // lastName is required (422 guard_unbounded_query without it)
  if (dob) query.dob = dob;
  if (state) query.state = state;
  const res = await fetch(`${HOST}/v1/criminal/search`, {
    method: "POST",
    headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ query, page, perPage }),
  });
  if (res.status === 422) throw new Error((await res.json()).error.code);   // e.g. guard_unbounded_query
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  return res.json();
}

const body = await criminalSearch({ firstName: "Felony", lastName: "Record", dob: "1975-06-15" });
console.log(body.status, body.counts.records, body.legal.notice);
for (const rec of body.records) {
  console.log(rec.recordType, rec.matchState, rec.unverified ? "unverified" : "verified");
  for (const c of rec.cases) for (const ch of c.charges)
    console.log("  ", ch.severity, ch.description, "->", ch.disposition, ch.dispositionDate);
}
```

Everything here runs on the [sandbox](/sandbox) with a free `os_test_` key (60 calls a minute, 2,000 a day) against fixed sample people: `Felony Record`, `Misdemeanor Record`, `Arrest Only`, `Active Warrant`, `In Custody`, `Possible Match`, `Clear Record`. Any other name returns a complete answer with zero records. Python and Node.js SDKs ship with the first registry release; see [SDKs](/sdk). Until then the raw HTTP calls above are the integration.

Criminal errors are a nested object, `{ "error": { "code", "message" } }`, unlike the registry endpoint's flat `detail`. Branch on `code`; the message is for humans. The other query fields are `birthYear`, `birthMonth`, `age`, `state`, `county`, `city`, `zipcode`, `address` and `jurisdiction`; the [criminal search reference](/docs/criminal/search) documents each one.

## Three outcomes for one name: what date of birth does to a criminal records search

The sandbox person `Felony Record` was born on 1975-06-15 and has one court case. Send the same name three ways and you get three different answers, which is the whole argument for always sending a date of birth when you have one:

*Felony Record, three ways*
```plain
# 1. The right date of birth → the record is identified
{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"}}
#    records[0].matchState  = "dob_match"      unverified = false
#    matchBasis             = "lastName:exact firstName:exact dob:match"

# 2. No date of birth → the same case comes back, but nothing verified it
{"query": {"firstName": "Felony", "lastName": "Record"}}
#    records[0].matchState  = "no_dob_age_year"   unverified = true

# 3. A contradicting date of birth → not this person, so not returned
{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1990-01-01"}}
#    status = "complete"   counts.records = 0   records = []
```

1. **With the right date**, the record is identified: `matchState: "dob_match"`, `unverified: false`, and `matchBasis` reads `"lastName:exact firstName:exact dob:match"`. This is the only outcome your code may treat as "this is the person".
2. **Without a date**, the same case comes back, but as a lead: `matchState: "no_dob_age_year"` and `unverified: true`. The name matched; nothing confirmed it. A reviewer, or a second identifier, has to.
3. **With a contradicting date** (`1990-01-01`), the record is not returned at all: `status: "complete"`, `counts.records: 0`, `records: []`. The platform drops a record whose date of birth disagrees with yours rather than returning it with a warning, because a record that is demonstrably someone else is noise.

A zero-record answer for a name with a date of birth is not "no records for that name"; it is "no records for that person". The [no-match and multiple-match results](/docs/learn/no-match-and-multiple-match-results) tutorial covers how to present each outcome.

## Why date of birth matters in a criminal records API by name

Common names are the problem. A surname and first name shared by thousands of people will match records in many counties, and nothing in the name tells you which, if any, belong to your subject. The date of birth is the identifier that courts, jails and corrections departments publish most consistently, so it is the one the platform verifies against. `matchConfidence` scores the **name**; `matchState` reports the **identity** check.

Not every source publishes a full date, and the record tells you what it had. `dob.dobPrecision` is `full` when the source publishes a complete date (`dob.date` is set), and narrower values when it publishes a year only (`dob.birthYear`) or an age as of a date (`dob.age`, `dob.ageAsOf`). The verification adapts to what is there:

| matchState | The source published | Meaning |
| --- | --- | --- |
| `dob_match` | a full date | It agrees with yours. The only automatic identification. |
| `year_match` | a birth year only | The year agrees. A strong lead; `unverified: true`. |
| `age_match` | an age only | The age is consistent within a year. A plausibility check; `unverified: true`. |
| `no_dob_age_year` | nothing comparable | Name only. Also the value on every record when you sent no `dob`, `age` or `birthYear`. A lead for a reviewer; `unverified: true`. |
| `null` | anything | Only on `GET /v1/criminal/records/{recordId}`, which scores nothing. |

If you only know the year, send `birthYear` instead of `dob` and expect `year_match` at best. The sandbox name `Possible Match` returns three records in three states with no date on any of them, which is what a name-only answer looks like in practice. The [criminal date of birth](/docs/criminal/date-of-birth) reference and the [matching and confidence guide](/docs/guides/criminal/matching-and-confidence) explain the rules in full.

## Reading charges and dispositions in a criminal records search result

A criminal record is not a verdict. It is a container for one or more cases, each with one or more charges, and the decision about each charge lives on the charge: `cases[].charges[].disposition`. Here is the `Felony Record` court case as the sandbox returns it, trimmed to the keys that matter for this read:

*records[0] — one case, two charges, two dispositions*
```json
{
  "recordType": "court_case",
  "externalId": "SBX-CR-2015-000123",
  "personRef": "prs_6e1aaefd188032a7e6eb",
  "name": { "first": "Felony", "middle": "Sample", "last": "Record", "suffix": "" },
  "dob": { "date": "1975-06-15", "birthYear": 1975, "birthMonth": 6, "age": null, "ageAsOf": null, "dobPrecision": "full" },
  "state": "OH", "county": "Sample",
  "currentStatus": "on_probation",
  "matchConfidence": 0.99,
  "matchState": "dob_match",
  "unverified": false,
  "matchBasis": "lastName:exact firstName:exact dob:match",
  "cases": [
    {
      "caseNumber": "SBX-CR-2015-000123",
      "court": "Sample County Court of Common Pleas",
      "county": "Sample", "state": "OH", "level": "county",
      "filedDate": "2015-03-02", "status": "Closed", "caseType": "FELONY",
      "charges": [
        { "statute": "SAMPLE 2911.12", "description": "Burglary", "severity": "felony",
          "disposition": "Guilty", "dispositionDate": "2015-09-14", "offenseDate": "2015-02-20", "plea": "Guilty", "counts": 1 },
        { "statute": "SAMPLE 2913.02", "description": "Theft", "severity": "misdemeanor",
          "disposition": "Dismissed", "dispositionDate": "2015-09-14", "offenseDate": "2015-02-20", "plea": "", "counts": 1 }
      ],
      "events": [
        { "date": "2015-03-02", "type": "ARRAIGNMENT", "description": null, "outcome": "Plea of not guilty entered" },
        { "date": "2015-09-14", "type": "PLEA HEARING", "description": null, "outcome": "Guilty plea to count 1; count 2 dismissed" }
      ],
      "sentence": { "length": "3 years", "minDate": "2015-09-14", "maxDate": "2018-09-14",
                    "probation": "2 years community control", "fine": 500.0, "dischargeDate": null, "years": 3, "months": null }
    }
  ],
  "sources": [ { "code": "OH-SAMPLE", "level": "county", "recordUrl": "https://offendersearch.app/docs/sandbox",
                 "lastCheckedAt": "2026-10-01T00:00:00+00:00", "live": false } ],
  "recordCategory": "court"
  /* … every other key is always present ("" / null / [] when the source publishes nothing) */
}
```

Two charges were filed on the same day in the same case. The felony burglary was disposed `Guilty`; the misdemeanor theft was `Dismissed`. "Has a felony case" is true and "convicted of theft" is false, and only the charge level can tell you both. So the read is: for each case, for each charge, take `severity`, `description`, `disposition`, `dispositionDate` and `plea`, and decide from those. `events[]` is the docket that explains how the dispositions came about; `sentence` is the outcome for the convicted charge.

> **Disposition values are the source's own words.** The sandbox says `Guilty` and `Dismissed`; a real source may say "Convicted", "Nolle prosequi", "Deferred" or a code. Map the values you encounter explicitly, and treat an empty disposition as "not decided", not as "not guilty". The [dispositions guide](/docs/guides/criminal/dispositions) lists common forms.

## The record types a criminal records search returns

`recordType` tells you what kind of document the record came from, and the kind decides which keys are meaningful. The sandbox has one person per kind:

| recordType | Sandbox name and DOB | What it is | Read |
| --- | --- | --- | --- |
| `court_case` | Felony Record, 1975-06-15 | A filed case with charges and dispositions | `cases[].charges[].disposition`, `sentence` |
| `jail_booking` | Arrest Only, 1992-04-20 | An arrest-stage record: a charge at booking, **no disposition**. A booking is not a conviction. | `booking`, the booking charge; never treat as a conviction |
| `doc_inmate` | In Custody, 1979-12-31 | A corrections record with custody status | `currentlyIncarcerated`, `custodyFacility`, `releaseDate`, `custody` |
| `warrant` | Active Warrant, 1985-09-09 | An outstanding warrant with its status | `currentStatus` (`active_warrant`), `warrant` |
| `healthcare_exclusion` | Excluded Provider, 1968-03-22 | A conviction-based exclusion list entry | `exclusion`; administrative entries only with `include: ["exclusions"]` |

Other values exist (`wanted_fugitive`, `registry`, and `criminal_record` for anything else), and `recordCategory` groups them. Every record carries every key, with `""`, `null` or `[]` where the kind or the source has nothing, so a `court_case` has an empty `booking` and a `jail_booking` has an empty `cases[]`. The [record types guide](/docs/guides/criminal/record-types) and the [criminal record object](/docs/criminal/record-object) reference document each kind.

## Scoping a criminal records search by state and county

By default the search covers the whole dataset for that name. To narrow it, add `state` and optionally `county` or `jurisdiction` to `query`; `city`, `zipcode` and `address` work the same way. For a search across several places, send `locations` (up to 10) at the top level instead, and do not combine it with the flat keys: sending both is `422 ambiguous_location`.

Scoping changes what is **searched**, so a record outside the scope is not returned; it does not tell you the person has no record elsewhere. When the decision is about a person rather than a place, search unscoped first and use `state` and `county` on each returned record to see where the records are. The [search by place](/docs/criminal/search-by-place) reference covers the location object and radius searches.

## Cached vs live: re-verifying a criminal records search at the source

A request with no `live` block is answered from the continuously updated dataset: fast, and `counts.sourcesQueried` is `0` because no source was contacted at request time. `counts.sourcesOnFile` and `counts.sourcesPending` describe the dataset behind the answer. When the decision needs the source's answer as of now, add a `live` block naming the jurisdictions to re-verify, such as `["TX-BEXAR", "TX-DOC"]`, or `"scope": "matched"` to re-verify wherever the cached answer found something.

*A live search that goes async*
```bash
# Re-verify at named sources. ≤ 2 sync-eligible sources → 200 inline; more → 202 and a poll URL.
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"},
       "live": {"jurisdictions": ["TX-BEXAR", "TX-DOC", "TX-HARRIS"]}}'

# 202
{
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "status": "pending",
  "mode": "async",
  "liveSourcesRequested": 3,
  "poll": "/v1/criminal/searches/crs_7f2a1c9e0b4d6a8f1e23",
  "reason": "3 live sources (> 2 or not sync-eligible) — running asynchronously"
}

# Poll until status is no longer "pending"; the body is then the normal search response
curl https://sandbox.offendersearch.app/v1/criminal/searches/crs_7f2a1c9e0b4d6a8f1e23 \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
```

A live search with up to 2 sync-eligible sources answers `200` inline, with `sourceStatus[]` filled in and `sources[].live: true` on re-verified records. More than 2 sources answers `202` with `status: "pending"`, `mode: "async"`, `liveSourcesRequested` and a `poll` path; call `GET /v1/criminal/searches/{searchId}` until the status is no longer pending and read the body as a normal response. Persist the `searchId` and poll it; a repeated `POST` is a second search, because the search endpoints have no idempotency header. Live re-verification is $0.02 per completed source. The [cached vs live guide](/docs/guides/criminal/cached-vs-live) and the [criminal async](/docs/criminal/async) reference have the lifecycle.

## Search criminal records by name and date of birth: the procedure

1. **Mint a test key and set the sandbox host.** Create an `os_test_` key in the dashboard, export it as `OFFENDERSEARCH_TEST_KEY`, and call `https://sandbox.offendersearch.app`. Nothing on the sandbox is billed.
2. **Send lastName, firstName and dob inside query.** `POST /v1/criminal/search` with `query.lastName` (required), `query.firstName` and `query.dob` as YYYY-MM-DD. Put `page` and `perPage` at the top level of the body. Sandbox: "Felony Record" with 1975-06-15.
3. **Read status and counts, then matchState.** Confirm `status` is "complete". On each record, `dob_match` with `unverified` false is an identified person; `no_dob_age_year` with `unverified` true is a lead; a contradicting date is not returned.
4. **Read each charge, not the case.** Walk `cases[].charges[]` and read `severity`, `description`, `disposition` and `dispositionDate`. A Guilty burglary and a Dismissed theft can sit in the same case.
5. **Scope and page.** Narrow with `query.state`, `query.county` or `query.jurisdiction`. Raise `perPage` (1 to 200) or walk `page` while `page` is below `totalPages`.
6. **Re-verify live when the decision needs it.** Add a `live` block naming jurisdictions. Up to 2 sync-eligible sources answer `200` inline; more answer `202` with a poll URL under `/v1/criminal/searches/{searchId}`.

Every response carries `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.)."* Store it beside the result. A hiring, tenant, credit or insurance decision runs as an FCRA order on the [FCRA Partner API](/fcra), which records consent and handles adverse action; whether your use is a permissible purpose is a question for your counsel. Production is a host and key swap (`https://api.offendersearch.app`, an `os_live_` key) at $0.15 a call, $0.11 past 2,000 calls in a month, 25 free calls, no card; the [background check API tutorial](/docs/learn/background-check-api-tutorial) walks through it.

## Criminal records search by name and date of birth FAQ

### Can I search criminal records by name only?

Yes, as long as `lastName` is present; `firstName` is optional. Every record then comes back `unverified: true` with `matchState: "no_dob_age_year"`, because nothing identified the person. Add `dob`, or at least `birthYear` or `age`, as soon as you have it.

### Why is my request a 422 guard_unbounded_query?

`query.lastName` is missing. A surname is the minimum predicate; a search with no surname cannot be bounded. Check that the name is inside `query` and not at the top level of the body.

### How do I page through a long answer?

`perPage` (1 to 200, default 50) and `page` (from 1) sit at the top level of the body. The response carries `page`, `perPage` and `totalPages`; loop while `page` is below `totalPages`. The [criminal pagination](/docs/criminal/pagination) reference covers `candidatesCapped`.

### Is a jail booking a criminal record?

It is an arrest-stage record (`recordType: "jail_booking"`): a charge at booking and no disposition. It is not a conviction and must not be read as one. Only a `court_case` charge with a disposition says how a charge ended.

### Does a zero-record answer mean the person has no criminal record?

It means no record in the searched scope matched that name and survived the date-of-birth check, with `status: "complete"`. A scoped search says nothing about other places, and a cached answer is as of the dataset's last update; a live search re-verifies at the source.

## What this criminal records search tutorial does not cover

- Charge, identifier and date filters (`filters`, `sort`) and the `facets` and `normalizedDetail` includes: see [filtering](/docs/criminal/filtering) and [normalized fields](/docs/criminal/normalized-fields).
- Batch searches of up to 1,000 people with `POST /v1/criminal/batch`: see [criminal batch](/docs/criminal/batch).
- Sex offender registry searches, which are a separate endpoint and record shape: [check if someone is a registered sex offender](/docs/learn/check-registered-sex-offender-api).
- Building an inmate lookup on `doc_inmate` records: [build an inmate search tool](/docs/learn/build-an-inmate-search-tool).

## Related

- [Background check API tutorial](https://offendersearch.app/docs/learn/background-check-api-tutorial)
- [Check if someone is a registered sex offender with the API](https://offendersearch.app/docs/learn/check-registered-sex-offender-api)
- [Court records API tutorial](https://offendersearch.app/docs/learn/court-records-api-tutorial)
- [Build an inmate search tool](https://offendersearch.app/docs/learn/build-an-inmate-search-tool)
- [No-match and multiple-match results](https://offendersearch.app/docs/learn/no-match-and-multiple-match-results)
- [Criminal records API](https://offendersearch.app/criminal-records-api)
- [Criminal search reference](https://offendersearch.app/docs/criminal/search)
- [Criminal record object](https://offendersearch.app/docs/criminal/record-object)
- [Arrests, charges and convictions](https://offendersearch.app/docs/guides/criminal/arrests-charges-convictions)
- [Criminal sandbox scenarios](https://offendersearch.app/docs/criminal/sandbox)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
