# Understanding a criminal record

> A record keeps the source's own words in the top-level fields and the closed classification in `normalized`. Knowing which field is which is most of the job.

- **HTML:** https://offendersearch.app/docs/guides/criminal/understanding-records
- **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 renderer: code that takes one element of `records[]` and shows a reviewer the right facts in the right order — what kind of record it is, where the person stands today, what the cases and charges say, and how current the copy is. The same reading applies to every record the API returns, whether it came from a cached search, a live check, a batch row or `GET /v1/criminal/records/{recordId}`.

This guide walks one prison record. For what each record kind is (booking, inmate, case, warrant, registry, exclusion) read [Understanding record types](/docs/guides/criminal/record-types); for charges and dispositions read [Understanding charges](/docs/guides/criminal/charges) and [Understanding dispositions](/docs/guides/criminal/dispositions).

## Before you start

- A test key (`os_test_…`) against `https://sandbox.offendersearch.app`.
- The sandbox person `In Custody`, born `1979-12-31`: one state prison record with a sentence, a parole block, a facility and a projected release date. `Arrest Only` (`1992-04-20`) is the contrasting jail booking with no disposition.
- The field list you will read against: [the Record object](/docs/criminal/record-object) and [Normalized fields](/docs/criminal/normalized-fields).

> **Four fields all say "status" or "kind".** `recordType` is the source's label (open). `recordCategory` is the closed kind (`court|jail|prison|warrant|registry|exclusion|other`). `currentStatus` is the source's status text (open; sources write `booked`, `scheduled`, `indicted` and more alongside the twelve usual values). `normalized.custodyStatus` is the closed status. Display the open pair; branch on the closed pair.

## Step by step

1. **Fetch a record with everything populated.** Search for `In Custody` with the date of birth so the match fields are filled in too.
2. **Classify it.** Read `recordCategory` first, then `normalized.custodyStatus`. Together they say "prison record, in custody" without parsing any text.
3. **Read custody and sentence.** `currentlyIncarcerated`, `dateIncarcerated`, `releaseDate` (projected), `actualReleaseDate` (happened), `custodyFacility`, `sentence`, `parole`, `custody`, `releaseDates`.
4. **Walk cases[] and charges[].** Each case is one docket; each charge is one count as the source worded it. The closed reading of the same charge is `normalized.cases[i].charges[j]`, aligned by index.
5. **Check currency.** `sources[].lastCheckedAt` says when the copy was taken; `lifecycle` says whether the person is still present at the source.

**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": "Custody", "firstName": "In", "dob": "1979-12-31" } }'
```

**Python**
```python
import requests

r = requests.post("https://sandbox.offendersearch.app/v1/criminal/search",
                  headers={"X-API-Key": "os_test_your_key"},
                  json={"query": {"lastName": "Custody", "firstName": "In", "dob": "1979-12-31"}})
r.raise_for_status()
rec = r.json()["records"][0]

kind   = rec["recordCategory"]                                   # closed: prison
status = (rec.get("normalized") or {}).get("custodyStatus", "unknown")  # closed: in_custody
print(kind, status, "| source says:", rec["recordType"], rec["currentStatus"])

for case in rec["cases"]:
    for i, ch in enumerate(case["charges"]):
        norm = None
        if rec.get("normalized"):
            ncase = rec["normalized"]["cases"][rec["cases"].index(case)]
            norm = ncase["charges"][i]
        print(case["caseNumber"], ch["description"] or "(no text)", "|", ch["disposition"] or "(none)",
              "| outcome:", norm["outcome"] if norm else "unknown")

print("in custody:", rec["currentlyIncarcerated"], "since", rec["dateIncarcerated"],
      "projected release", rec["releaseDate"], "at", rec["custodyFacility"])
print("present at source:", rec["lifecycle"]["presentInLastSweep"], "last seen", rec["lifecycle"]["lastSeen"])
```

**JavaScript**
```javascript
const r = await fetch('https://sandbox.offendersearch.app/v1/criminal/search', {
  method: 'POST',
  headers: { 'X-API-Key': 'os_test_your_key', 'Content-Type': 'application/json' },
  body: JSON.stringify({ query: { lastName: 'Custody', firstName: 'In', dob: '1979-12-31' } }),
});
const rec = (await r.json()).records[0];

const kind = rec.recordCategory;                               // closed: prison
const status = rec.normalized?.custodyStatus ?? 'unknown';     // closed: in_custody
console.log(kind, status, '| source says:', rec.recordType, rec.currentStatus);

rec.cases.forEach((c, ci) => {
  c.charges.forEach((ch, i) => {
    const norm = rec.normalized?.cases?.[ci]?.charges?.[i];    // same indexes
    console.log(c.caseNumber, ch.description || '(no text)', '|', ch.disposition || '(none)',
      '| outcome:', norm ? norm.outcome : 'unknown');
  });
});
console.log('in custody:', rec.currentlyIncarcerated, 'since', rec.dateIncarcerated,
  'projected release', rec.releaseDate, 'at', rec.custodyFacility);
```

*records[0], trimmed — a prison record*
```json
{
  "recordType": "doc_inmate",
  "recordCategory": "prison",
  "externalId": "SBX-DOC-310045",
  "personRef": "prs_0b7e3f9c1d2a4e6b8c90",
  "name": { "first": "In", "middle": "Sample", "last": "Custody", "suffix": "" },
  "aliases": ["I. S. Custody"],
  "dob": { "date": "1979-12-31", "birthYear": 1979, "birthMonth": 12, "age": 46, "ageAsOf": "2026-10-01", "dobPrecision": "full" },
  "sex": "M", "race": "White",
  "state": "AZ", "county": "",
  "address": null,
  "currentStatus": "in_custody",
  "currentlyIncarcerated": true, "everIncarcerated": true,
  "dateIncarcerated": "2019-04-02", "releaseDate": "2031-04-01", "actualReleaseDate": null,
  "custodyFacility": "Sample State Correctional Facility",
  "sentence": { "length": "12 years", "minDate": "2019-04-02", "maxDate": "2031-04-01", "probation": null, "fine": null,
                "dischargeDate": null, "years": 12, "months": null, "goodTimeLaw": null, "truthInSentencing": "Yes" },
  "parole": { "eligibilityDate": "2029-10-01", "nextReviewDate": null, "reviewStatus": "Not yet eligible", "lastDecision": null },
  "custody": { "custodyClass": "Medium", "description": "General population", "facilityType": "State prison" },
  "priorFelony": true,
  "matchConfidence": 0.99, "matchState": "dob_match", "matchBasis": "lastName:exact firstName:exact dob:match",
  "cases": [
    { "caseNumber": "SBX-CR-2018-007788", "court": "Sample County Superior Court", "county": "Sample", "state": "AZ",
      "level": "state", "filedDate": "2018-06-01", "status": "Sentenced",
      "charges": [ { "statute": "SAMPLE 13-1904", "description": "Armed robbery", "severity": "felony",
                     "disposition": "Guilty", "dispositionDate": "2019-03-15", "offenseDate": "2018-05-20",
                     "plea": "Guilty", "counts": 1 } ],
      "sentence": { "length": "12 years", "years": 12, "minDate": "2019-04-02", "maxDate": "2031-04-01" } }
  ],
  "identifiers": { "inmateNumber": "A000123", "stateId": "OH00067890", "fbiNumber": null, "trackingNumber": null, "warrantNumber": null,
                   "ncicNumber": null, "citationNumber": null, "policeReportNumber": null, "registrationNumber": null, "localPersonNumber": null },
  "releaseDates": { "projected": "2029-04-01", "earliest": "2027-10-01", "maximum": null, "supervisionEnds": null, "parole": null },
  "normalized": {
    "rulesVersion": "2026-10-24", "quality": "ok",
    "recordCategory": "prison", "custodyStatus": "in_custody",
    "topSeverity": "felony", "hasConviction": true, "convictionCount": 1,
    "chargeCategories": ["robbery"], "outcomes": ["convicted"],
    "latestOffenseDate": "2018-05-20", "latestDispositionDate": "2019-03-15", "latestFiledDate": "2018-06-01",
    "hasPhoto": true, "vacatedOrSealed": false,
    "cases": [ { "stage": "closed", "caseClass": "felony",
                 "charges": [ { "category": "robbery", "notACharge": false, "severity": "felony", "severityBasis": "published",
                                "degree": null, "outcome": "convicted", "convicted": true, "custodyEvent": null } ] } ]
  },
  "sources": [ { "code": "AZ-SAMPLE", "level": "state", "recordUrl": "https://offendersearch.app/docs/sandbox",
                 "lastCheckedAt": "2026-10-01T00:00:00+00:00", "live": false } ],
  "lifecycle": { "firstSeen": "2025-01-10T00:00:00+00:00", "lastSeen": "2026-10-01T00:00:00+00:00",
                 "presentInLastSweep": true, "droppedOut": false, "droppedOutDetected": null,
                 "dropInterpretation": "unknown", "sourceLastUpdated": null },
  "liveChecked": false,
  "exclusion": null
}
```

Read it top down: a prison record (`recordCategory`), the person is in custody now (`normalized.custodyStatus`, `currentlyIncarcerated`), one case with one felony count, convicted (`normalized.hasConviction`), projected release in 2031 (`releaseDate`) with an earlier DOC projection in `releaseDates.projected`, and the copy was present at the source on its last check (`lifecycle.presentInLastSweep`).

## Reading the result

| Field | Values | Read as |
| --- | --- | --- |
| `recordCategory` | `court` · `jail` · `prison` · `warrant` · `registry` · `exclusion` · `other` | The kind of record. The only "kind" field to branch on. |
| `recordType` | open: `doc_inmate`, `court_case`, `jail_booking`, `jail`, `arrest`, `warrant`, `healthcare_exclusion`, … | The source's label. Show it; never compare exact strings. |
| `currentStatus` | open text, usually one of `in_custody`, `released`, `on_parole`, `on_probation`, `active_warrant`, `wanted`, `registered`, `disposed`, `absconder`, `deceased`, `excluded`, `unknown` | Display only. |
| `normalized.custodyStatus` | the twelve values above, closed | Where the person stands. `unknown` is a real answer: the source said nothing. |
| `currentlyIncarcerated` / `everIncarcerated` | booleans | Plain facts. `false` is not "released"; read `currentStatus`/`actualReleaseDate` for that. |
| `releaseDate` vs `actualReleaseDate` | ISO date or `null` | Projected or scheduled vs. happened. A `releaseDate` in the past with `actualReleaseDate: null` means the source has not recorded the release. |
| `cases[].charges[]` | `statute`, `description`, `severity`, `disposition`, `dispositionDate`, `offenseDate`, `plea`, `counts` | The source's words, untranslated. `disposition: ""` means no outcome text was published — typical on a booking. |
| `normalized.cases[i].charges[j]` | `category`, `severity`, `severityBasis`, `outcome`, `convicted`, `custodyEvent`, `notACharge` | The closed reading of the same charge, same indexes. `normalized: null` means the record could not be classified; treat outcome as unknown. |
| `lifecycle` | `firstSeen`, `lastSeen`, `presentInLastSweep`, `droppedOut`, `droppedOutDetected`, `dropInterpretation`, `sourceLastUpdated` | `droppedOut: true` records are still returned. `dropInterpretation` guesses why: `released` (jail), `source_removed` (court/registry), `reinstated` (exclusion), `unknown`. |
| `personRef` | `prs_` + 20 hex | Stable per source per person; what a clearance names. `externalId` is the source's own key, unique only within `sources[].code`. |

## Edge cases and pitfalls

- **The three empties.** `""` = the source published no text for a string slot (`name.middle`, `statute`, `disposition`, `plea`, `caseNumber`). `null` = a typed absence: dates, numbers, `sex`, `race`, `county`, and whole optional objects (`address`, `sentence`, `parole`, `custody`, `exclusion`, `normalized`). `[]` = none on file (`aliases`, `cases`, `charges`, `sources`, `vehicles`, `otherDetails`). Never collapse them into one "missing".
- **Some keys are present only when they apply.** `cases[].sentence` appears only when the case has one; `cases[].chargesPending: true` appears only when the case page was never fully retrieved (then `charges` is `[]`, meaning "not retrieved", not "no charges"); `matchedLocations` only when `locations` was sent; `clearance` only when `subjectId` named this `personRef`.
- **A booking is not a conviction.** `Arrest Only` has one charge with `disposition: ""`, `cases[].status: "Booked"` and `booking.*` populated. `normalized.hasConviction` is `false` or `null` there. [Arrests vs. charges vs. convictions](/docs/guides/criminal/arrests-charges-convictions).
- **Dates are ISO when they parse, otherwise the source's text.** The core dates (`dob.date`, `filedDate`, `dispositionDate`, `dateIncarcerated`, `releaseDate`) are `YYYY-MM-DD` or `null`. The detail groups (`booking.bookingDate`, `warrant.issuedDate`, `releaseDates.*`, `cases[].nextHearing.date`, `events[].date`, `lifecycle.sourceLastUpdated`) fall back to the published text when it does not parse. Parse defensively.
- **`dob.date` is never a placeholder.** `1900-01-01`, `0001-01-01` and `1/1` stubs collapse to `dobPrecision: "year"` or `"none"` with `date: null`. You will never see a fabricated day.
- **`sex`, `race`, `physical.hairColor` are the source's words**; the NCIC-style codes live in `normalized.sex`, `normalized.race`, `normalized.hair`, `normalized.eyes`.
- **`GET /v1/criminal/records/{recordId}` returns the bare record** with `matchState: null`, `matchConfidence: 0.0` and `dobVerification: null` — it scores nothing. The path value is the record's `externalId`; send `?source=` with its `sources[].code`.

## Production checklist

- Branch on `recordCategory` and `normalized.custodyStatus`; render `recordType` and `currentStatus` as labels.
- Read charge outcomes from `normalized.cases[i].charges[j].outcome` / `convicted`, with a fallback of "unknown" when `normalized` is `null`.
- Distinguish `""`, `null` and `[]` in your model; add `chargesPending` and `droppedOut` to what a reviewer sees.
- Link the reviewer to `sources[].recordUrl` and show `sources[].lastCheckedAt`.
- Key your own de-duplication on `personRef` within a `sources[].code`, never on the name.

## Related

- [The Record object](https://offendersearch.app/docs/criminal/record-object): every key, every depth
- [Normalized fields](https://offendersearch.app/docs/criminal/normalized-fields)
- [Understanding record types](https://offendersearch.app/docs/guides/criminal/record-types)
- [Record normalization](https://offendersearch.app/docs/guides/criminal/normalization)
- [Data model rules](https://offendersearch.app/docs/platform/data-model)
