# Court records API tutorial: cases, charges and dispositions

> Court cases are one layer of the criminal records search. This tutorial makes the request, walks the case object field by field, reads a disposition, filters and sorts by date, scopes to a county, pages, and renders a PDF, all against the sandbox.

- **HTML:** https://offendersearch.app/docs/learn/court-records-api-tutorial
- **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 the court records API returns

There is no separate court endpoint. Court cases are one record kind inside the [criminal records API](/criminal-records-api): `POST /v1/criminal/search` answers with `records[]`, and each record carries a `recordType`. A court record is `recordType: "court_case"`; beside it in the same answer you may see `jail_booking` (arrest stage, no disposition), `doc_inmate` (custody), `warrant`, `wanted_fugitive`, `registry` and `healthcare_exclusion`.

The dataset is **criminal court records**, the same one the [court records search](/court-records-search) product page describes. Civil suits, family and probate matters and traffic citations are not in it. A court record carries one or more `cases[]`, and a case carries `charges[]`, each with its own `disposition`.

Every search answer also carries a `legal.notice` line: **"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 it as a statement about this endpoint. A hiring or housing decision belongs on the [FCRA Partner API](/fcra-api), where the same records arrive inside an order with consent and notices.

Python and Node.js SDKs ship with the first registry release; see [SDKs](/sdk). Until then the raw HTTP calls below are the integration. A test key is free from the dashboard, nothing is billed on the [sandbox](/sandbox), and the sandbox allows 60 calls a minute and 2,000 a day per key.

## Court records API tutorial: the first request

`query.lastName` is the one required field; a query without it is `422 guard_unbounded_query`. Send `firstName` and a full `dob` whenever you have them, because the date of birth is what turns a name match into an identification (`matchState: "dob_match"`). The sandbox subject **Felony Record**, born **1975-06-15**, returns exactly one court case. The same name without a `dob` returns the same case with `matchState: "no_dob_age_year"` and `unverified: true`; the same name with `dob: "1990-01-01"` returns nothing, because a record whose published date of birth contradicts yours is dropped rather than returned.

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

**Python**
```python
import os, requests

r = requests.post(
    "https://sandbox.offendersearch.app/v1/criminal/search",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_API_KEY"]},   # os_test_… from the dashboard
    json={"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"}},
    timeout=60,
)
r.raise_for_status()
body = r.json()

for rec in body["records"]:
    if rec["recordType"] != "court_case":
        continue                                   # bookings, DOC and warrants are other layers
    for case in rec["cases"]:
        print(case["caseNumber"], case["court"], case["filedDate"], case["status"])
        for ch in case["charges"]:
            print("  ", ch["severity"], ch["description"], "->", ch["disposition"] or "(no disposition published)")
```

**Node**
```javascript
// Node 18+ — fetch is built in
const res = await fetch("https://sandbox.offendersearch.app/v1/criminal/search", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_API_KEY,   // os_test_… from the dashboard
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ query: { firstName: "Felony", lastName: "Record", dob: "1975-06-15" } }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();

for (const rec of body.records.filter((r) => r.recordType === "court_case")) {
  for (const c of rec.cases) {
    console.log(c.caseNumber, c.court, c.filedDate, c.status);
    for (const ch of c.charges) {
      console.log("  ", ch.severity, ch.description, "->", ch.disposition || "(no disposition published)");
    }
  }
}
```

The answer is `status: "complete"`, `counts.records: 1`, one record with `recordType: "court_case"`, `matchConfidence: 0.99`, `matchState: "dob_match"` and `matchBasis: "lastName:exact firstName:exact dob:match"`. Swap in **Misdemeanor Record**, **1988-11-02**, for a second court case: one misdemeanor theft charge, `disposition: "Guilty"`, `plea: "No contest"`, and a `sentence.fine` of `250.0`. Any other name, including Jordan Rivera, 1988-04-12, answers `complete` with zero records.

## The court case object in a court records API response

This is `records[0].cases[0]` from the sandbox answer, unedited. Every key is always present; a value the source did not publish is `""`, `null` or `[]`, never missing and never invented.

*cases[0] for Felony Record, 1975-06-15*
```json
{
  "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
    }
  ],
  "judge": "Hon. Sample Judge",
  "attorney": "Sample Defender",
  "prosecutor": "Sample County Prosecutor",
  "division": "General Division",
  "nextHearing": { "date": null, "time": null, "type": null, "courtroom": null },
  "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,
    "goodTimeLaw": "",
    "truthInSentencing": ""
  }
}
```

| Field | What it holds |
| --- | --- |
| `caseNumber`, `court`, `county`, `state`, `level` | The docket number and the court that holds it; `level` is `county`, `state` or `federal`. |
| `filedDate`, `status`, `caseType` | When the case was filed, the court's own status word (`Closed`, `Open`…), and its class as published (`FELONY`, `MISDEMEANOR`). |
| `charges[]` | One entry per count: `statute`, `description`, `severity` (`felony`, `misdemeanor`, …), `disposition`, `dispositionDate`, `offenseDate`, `plea`, `counts`. |
| `events[]` | The docket timeline: `date`, `type`, `description`, `outcome`. Arraignments, hearings, pleas. |
| `sentence` | `length`, `minDate`, `maxDate`, `probation`, `fine`, `dischargeDate`, `years`, `months`. `null` when nothing was imposed or published. |
| `judge`, `attorney`, `prosecutor`, `division`, `nextHearing` | Court metadata when the source publishes it. |

Around the case sit the person-level keys you already know from the [criminal record object](/docs/criminal/record-object): `name`, `aliases[]`, `dob` (with `dobPrecision`), `state`, `county`, `currentStatus`, `sources[]` (the `code`, `level`, `recordUrl`, `lastCheckedAt` and `live` of each source), `lifecycle`, `identifiers` and `recordCategory: "court"`.

## Reading a disposition in court records

A disposition lives on the **charge**, not the case, because one case can end two ways. The sample case does exactly that: the burglary count is `disposition: "Guilty"` with `plea: "Guilty"`, and the theft count on the same docket is `disposition: "Dismissed"` with an empty `plea`. Both carry the same `dispositionDate`, and the second docket event explains why: *Guilty plea to count 1; count 2 dismissed*. A program that reads `cases[0].charges[0].disposition` and stops has read half the case.

The rule for deciding whether a record is a conviction is therefore: walk every `charges[]` entry of every `cases[]` entry, and read each `disposition`. The [dispositions guide](/docs/guides/criminal/dispositions) lists the words courts use and which mean a conviction.

> **A missing disposition stays empty.** Many dockets publish a charge before the court has ruled on it. That charge arrives with `disposition: ""` and `dispositionDate: null`. The API never fills the gap with a guess, and your code must not either: treat an empty disposition as "pending or unpublished", not as "not guilty" and not as "guilty".

If you want a machine-readable answer instead of the court's own words, read the `normalized` block described below: `outcome: "convicted"` and `convicted: true` on the burglary count, `outcome: "dismissed"` and `convicted: false` on the theft count, and `outcome: "unknown"` with `convicted: null` when the published text decides nothing.

## Sorting and filtering court records by date and outcome

Three top-level request keys narrow and order the answer. `filters` is ANDed across keys and ORed within a list; `sort` takes `relevance` (default), `offenseDate`, `filedDate` or `dispositionDate`, and a date sort puts the record's latest date of that kind first with undated records last; `include: ["normalizedDetail"]` adds a `rule` to every normalized charge so an audit can see which rule decided the outcome. `sort` is applied when the request sends `filters` or `locations`.

*Court convictions only, felonies, filed since 2010, newest disposition first*
```json
{
  "query": { "firstName": "Felony", "lastName": "Record", "dob": "1975-06-15" },
  "filters": {
    "recordCategory": ["court"],
    "severity": ["felony"],
    "outcome": ["convicted"],
    "filedDate": { "from": "2010-01-01", "to": "2026-10-31" }
  },
  "sort": "dispositionDate",
  "include": ["normalizedDetail", "facets"],
  "page": 1,
  "perPage": 50
}
```

| Filter | Tests | Values |
| --- | --- | --- |
| `recordCategory` | the record | `court`, `jail`, `prison`, `warrant`, `registry`, `exclusion`, `other` |
| `severity` | each charge | `felony`, `misdemeanor`, `infraction` |
| `outcome` | each charge | `convicted`, `pending`, `dismissed`, `nolle_prosequi`, `acquitted`, `deferred_or_diverted`, `vacated`, `warrant`, `transferred`, `closed_unspecified`, `administrative` |
| `convictedOnly` | each charge | `true` keeps records with at least one convicted charge |
| `chargeCategories`, `statute`, `chargeText` | each charge | a category list, a statute prefix such as `22.02`, or text in the description |
| `filedDate`, `dispositionDate`, `offenseDate` | the record's latest date | `{ "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" }`, at least one bound |
| `lookbackYears` | the record's latest date | 1–100 |
| `unknown` | every classified filter | `keep` (default) or `exclude` for values that could not be classified |

Every record also carries a `normalized` block, with or without filters: clean classes computed from the fields beside it while the court's own words stay untouched. With `normalizedDetail` each charge adds its `rule`:

*records[0].normalized with include: ["normalizedDetail"] (the shape; values follow the reference enums)*
```json
"normalized": {
  "rulesVersion": 1,
  "quality": "ok",
  "recordCategory": "court",
  "custodyStatus": "on_probation",
  "cases": [
    {
      "stage": "closed",
      "caseClass": "felony",
      "charges": [
        { "category": "burglary", "notACharge": false, "severity": "felony", "severityBasis": "published",
          "degree": null, "outcome": "convicted", "convicted": true, "custodyEvent": null,
          "rule": "disposition_text:guilty" },
        { "category": "theft", "notACharge": false, "severity": "misdemeanor", "severityBasis": "published",
          "degree": null, "outcome": "dismissed", "convicted": false, "custodyEvent": null,
          "rule": "disposition_text:dismissed" }
      ]
    }
  ]
}
```

A record a filter removes is counted in `counts.filteredOut`; a record kept because its value was unknown is counted in `counts.unknownKept`. The [filtering reference](/docs/criminal/filtering) has every key, and the [normalized fields reference](/docs/criminal/normalized-fields) every class and the rule names.

## Scoping court records to a county or state

Court records are kept by the court that holds the docket, so a county is the natural unit. Put `state` and `county` inside `query` to scope a cached search to one county; `state` alone scopes to the state. Send either the flat `state` / `county` keys or a `locations[]` list (up to ten places), never both, or the request is `422 ambiguous_location`.

```bash
# Cached search, scoped to one county
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Misdemeanor", "lastName": "Record", "dob": "1988-11-02",
                 "state": "TX", "county": "Sample"}}'

# Which court sources exist? No key needed. Each row: id, name, covers[], scope, recordType, status, health
curl https://api.offendersearch.app/v1/criminal/sources

# Re-verify one county source live ($0.02 per completed source)
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Misdemeanor", "lastName": "Record", "dob": "1988-11-02", "state": "TX"},
       "live": {"jurisdictions": ["TX-BEXAR"]}}'
```

`GET /v1/criminal/sources` needs no key and lists every jurisdiction: its public `id` (the code you pass to `live.jurisdictions`), a human `name`, the states it `covers`, its `scope` (`county`, `state`, `federal`), its `recordType` in words (`"Court records"`, `"County jail roster"`, `"Exclusion list"`), whether it is `live` or `pending`, and a `health` object with `lastSuccessAt` and `ageSeconds`. Filter that list on `recordType` to see which court sources exist before you promise a county to a customer.

A cached search is answered from the continuously updated dataset and sends no `live` block. To re-verify a county source at the moment of the search, add `live.jurisdictions` with its code, or `live.scope: "matched"` to re-check every jurisdiction that produced a cached hit. Live re-verification costs $0.02 per completed source. With two or fewer sync-eligible sources the answer comes back inline; with more, the response is `202` with `status: "pending"`, `mode: "async"` and a `poll` path, `GET /v1/criminal/searches/{searchId}`. The [cached vs live guide](/docs/guides/criminal/cached-vs-live) covers both paths.

## Paging court records and rendering a PDF report

`page` and `perPage` are **top-level** request keys, not query keys, and `perPage` runs from 1 to 200; any other value is `422 invalid_page`. The answer carries `page`, `perPage`, `totalPages` and `counts.records` (the total, which does not change as you page) beside `counts.recordsReturned` (this page). Walk `page = 1 … totalPages`.

```bash
# Page 2 of 200-record pages. page and perPage are TOP LEVEL, not inside query.
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"lastName": "Record", "state": "OH"}, "page": 2, "perPage": 200}'

# Render the search you already ran as a PDF (report charge only; no second search)
curl -X POST https://sandbox.offendersearch.app/v1/criminal/report \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"searchId": "srch_sbx_cr_felony", "viewerName": "Taylor Brooks",
       "purpose": "Visitor screening", "reference": "file-4471"}' \
  --output court-records.pdf
```

`POST /v1/criminal/report` renders a PDF. Send the `searchId` of a search you already ran on your account and only the report is billed; send an inline `query` instead and the search runs first. `viewerName`, `viewerEmail`, `purpose` and `reference` are printed at the top of the document as a record of who ran it; they are not an access gate.

Searches cost $0.15 a call, $0.11 past 2,000 calls in a month, with 25 free calls and no card. A repeated `POST` is a second billed search, so persist the `searchId` and re-render from it rather than resubmitting.

## Step by step: how to get court records via API

1. **Mint a test key and point at the sandbox.** Create an `os_test_` key in the dashboard, export it as `OFFENDERSEARCH_API_KEY`, and send every call to `https://sandbox.offendersearch.app`. Nothing is billed there.
2. **Search by last name, first name and date of birth.** `POST /v1/criminal/search` with `query.lastName` (required), `query.firstName` and a full `query.dob`. Try Felony Record, 1975-06-15, then Misdemeanor Record, 1988-11-02.
3. **Keep the court layer.** Keep records whose `recordType` is `court_case`, or send `filters.recordCategory: ["court"]` so the answer carries only court records.
4. **Walk every charge and read its disposition.** For each `cases[]` entry, for each `charges[]` entry, read `disposition`, `dispositionDate`, `plea` and `counts`. Treat an empty `disposition` as pending or unpublished, never as an outcome.
5. **Filter and sort when the docket is long.** Send `filters` (`severity`, `outcome`, `filedDate`, `lookbackYears`), set `sort` to `filedDate` or `dispositionDate`, and read `normalized.cases[].charges[].outcome` for a machine-readable class.
6. **Scope to a county and re-verify it live when it matters.** Put `state` and `county` in `query`; check `GET /v1/criminal/sources` for the court sources that exist; add `live.jurisdictions` to re-check one source at the moment of the search.
7. **Page, then render a PDF.** Walk `page` 1 to `totalPages` with `perPage` up to 200. Pass the `searchId` to `POST /v1/criminal/report` for a PDF of the search you already ran.

## Court records API questions developers ask

### Is there a separate court records endpoint?

No. Court cases are `recordType: "court_case"` records inside `POST /v1/criminal/search`. Send `filters.recordCategory: ["court"]` to receive only the court layer.

### How do I know whether a court record is a conviction?

Read `cases[].charges[].disposition` on every charge, or `normalized.cases[].charges[].outcome` for the class (`convicted`, `dismissed`, `pending`, …). One case can hold a guilty count and a dismissed count side by side; the sample case does.

### What does an empty disposition mean?

The court has not published one: the charge may be pending or the docket may not carry it. The API leaves `disposition` as `""` and `dispositionDate` as `null` rather than guessing. `normalized` says `outcome: "unknown"`.

### Does the court records API include civil, family or traffic cases?

No. The dataset is criminal court records, with jail, custody, warrant, registry and exclusion-list layers beside it. A civil suit, a divorce or a traffic citation is not returned.

### How do I find out which counties have court records?

`GET /v1/criminal/sources`, with no key, lists every jurisdiction with its `recordType` in words and its `status`. Rows whose `recordType` is court records are the court sources; `GET /v1/criminal/jurisdictions` lists the codes by state and county.

### Can I use court records from this endpoint for a hiring decision?

The response says `legal.notice: "Not a consumer report…"`. An employment, tenant, credit or insurance decision runs as an order on the [FCRA Partner API](/fcra-api) for approved partners; ask your counsel which applies to you.

## What this page does not cover

- Jail bookings, custody records and warrants: the [inmate search tool tutorial](/docs/learn/build-an-inmate-search-tool).
- Matching and false positives on common names: the [search criminal records by name and DOB tutorial](/docs/learn/search-criminal-records-by-name-and-dob).
- Batch searches of up to 1,000 rows with `POST /v1/criminal/batch`.
- Registry (sex offender) records, which are a different endpoint: the [sex offender API](/sex-offender-api).

## Related

- [Court records search (product)](https://offendersearch.app/court-records-search)
- [Criminal records API](https://offendersearch.app/criminal-records-api)
- [Dispositions guide](https://offendersearch.app/docs/guides/criminal/dispositions)
- [Arrests, charges and convictions](https://offendersearch.app/docs/guides/criminal/arrests-charges-convictions)
- [Criminal record object reference](https://offendersearch.app/docs/criminal/record-object)
- [Filtering reference](https://offendersearch.app/docs/criminal/filtering)
- [Normalized fields reference](https://offendersearch.app/docs/criminal/normalized-fields)
- [Search by place](https://offendersearch.app/docs/criminal/search-by-place)
- [Search criminal records by name and date of birth](https://offendersearch.app/docs/learn/search-criminal-records-by-name-and-dob)
- [Build an inmate search tool](https://offendersearch.app/docs/learn/build-an-inmate-search-tool)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
