Criminal Records API · v1.0.0

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.

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; for charges and dispositions read Understanding charges and Understanding 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 and 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 -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" } }'
records[0], trimmed — a prison record
{
  "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",

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

FieldValuesRead as
recordCategorycourt · jail · prison · warrant · registry · exclusion · otherThe kind of record. The only "kind" field to branch on.
recordTypeopen: doc_inmate, court_case, jail_booking, jail, arrest, warrant, healthcare_exclusion, …The source's label. Show it; never compare exact strings.
currentStatusopen text, usually one of in_custody, released, on_parole, on_probation, active_warrant, wanted, registered, disposed, absconder, deceased, excluded, unknownDisplay only.
normalized.custodyStatusthe twelve values above, closedWhere the person stands. unknown is a real answer: the source said nothing.
currentlyIncarcerated / everIncarceratedbooleansPlain facts. false is not "released"; read currentStatus/actualReleaseDate for that.
releaseDate vs actualReleaseDateISO date or nullProjected 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, countsThe 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, notAChargeThe closed reading of the same charge, same indexes. normalized: null means the record could not be classified; treat outcome as unknown.
lifecyclefirstSeen, lastSeen, presentInLastSweep, droppedOut, droppedOutDetected, dropInterpretation, sourceLastUpdateddroppedOut: true records are still returned. dropInterpretation guesses why: released (jail), source_removed (court/registry), reinstated (exclusion), unknown.
personRefprs_ + 20 hexStable 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.
  • 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.

Product overview