Developer docs · v1.0.0

Build an inmate search tool: an inmate search API tutorial

One endpoint, two record kinds and a handful of custody fields are the whole tool. This page walks the request, the fields to show, sorting and paging, coverage by jurisdiction, live re-verification and the UI rules that keep a booking from reading as a conviction.

Updated November 3, 2026

What an inmate search tool returns: doc_inmate and jail_booking records

An inmate search tool answers one question: is this person in custody, where, and since when. The inmate search API is POST /v1/criminal/search on the criminal records API; the same endpoint returns court cases and warrants, and an inmate tool reads two of its record kinds. recordType is an open string from the source; recordCategory is the closed token to branch on.

recordTyperecordCategoryWhat it isCustody fields that carry data
doc_inmateprisonA state or federal department of corrections record: a sentenced person in a prison system.currentStatus, currentlyIncarcerated, custodyFacility, dateIncarcerated, releaseDate, releaseDates, custody, parole, sentence
jail_bookingjailA county jail roster entry: an arrest-stage record with a charge at booking and, usually, no disposition yet.currentStatus, currentlyIncarcerated, custodyFacility, dateIncarcerated, actualReleaseDate, booking

Every key on a criminal record is always present, with "", null, [] or {} when the source publishes nothing, so a tool reads the same shape for every record and never guards for a missing key. The criminal record object lists all of them; the record types guide explains how a court case, a booking, a prison record and a warrant differ.

Everything below runs on the sandbox at https://sandbox.offendersearch.app with an os_test_ key from the dashboard. A test key is free, nothing is billed, and the limits are 60 calls a minute and 2,000 a day per key. Production is https://api.offendersearch.app with an os_live_ key and the same shapes; pricing is $0.15 a call, $0.11 past 2,000 calls in a month, 25 free calls, no card. Python and Node.js SDKs ship with the first registry release; see SDKs. Until then the raw HTTP calls below are the integration.

The inmate search request: a last name and a state

The query needs lastName; without it the search is 422 with code guard_unbounded_query. A state scopes the search to that state's sources and is what makes include: ["coverage"] answer with the sources in scope. firstName narrows the candidates; dob as YYYY-MM-DD is the only route to a confirmed identity (matchState: "dob_match"); birthYear, birthMonth and age are weaker alternatives; county, city, zipcode, address and jurisdiction narrow the place. page and perPage (1–200) sit at the top level, not inside query.

# An inmate search: a last name and a state. The sandbox answers fixed scenarios; nothing is billed.
export OFFENDERSEARCH_API_KEY=os_test_...   # minted in the dashboard

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": "In", "lastName": "Custody", "dob": "1979-12-31", "state": "AZ"},
       "filters": {"recordCategory": ["prison", "jail"]},
       "sort": "relevance",
       "include": ["coverage"],
       "page": 1, "perPage": 50}'

The sandbox name "In Custody" with dob 1979-12-31 returns one doc_inmate record in AZ with currentlyIncarcerated: true, a custodyFacility and a release date. "Arrest Only" with dob 1992-04-20 returns one jail_booking in FL with a charge at booking and no disposition. "Clear Record" and any name that is not a scenario, such as Jordan Rivera, 1988-04-12, return a complete answer with zero records. "Possible Match" returns three records in three states with no date of birth on any, which is the name-only case the UI section covers.

The envelope is status, counts { records, recordsReturned, sourcesQueried, sourcesComplete, sourcesIncomplete, candidatesCapped, sourcesOnFile, sourcesPending }, page, perPage, totalPages, sourceStatus[], legal { notice }, searchId, records[]. A cached search has sourcesQueried: 0, because nothing was asked live.

Custody fields an inmate search result carries

The custody picture lives in a few record-level fields plus four objects:

FieldTypeMeaning
currentStatusstringThe source's status, passed through: usually in_custody, released, on_parole, on_probation, active_warrant, …; unknown means the source published none. Branch on normalized.custodyStatus for a closed set.
currentlyIncarceratedbooleanIn custody as of the last check. false is not "released": it is also the value for someone never booked. Read everIncarcerated beside it.
custodyFacilitystring | nullThe facility, where published.
dateIncarceratedstring | nullBooking or admission date.
releaseDatestring | nullThe projected or scheduled release.
actualReleaseDatestring | nullA release that has happened. Distinct from releaseDate.
releaseDatesobjectprojected, earliest, maximum, supervisionEnds, parole: the dates a prison publishes, ISO or null.
custodyobject | nullcustodyClass, description, facilityType.
paroleobject | nulleligibilityDate, nextReviewDate, reviewStatus, lastDecision.
bookingobjectbookingNumber, bookingDate, arrestDate, arrestingAgency, heldForAgency, arrestingOfficer, housing, daysInJail, priorBookings, arrestTime, bookingTime, bookingType, releaseReason, bond, holds[]. Carries data on jail records; null keys elsewhere.

The "In Custody" record, trimmed to the custody keys:

doc_inmate: "In Custody", dob 1979-12-31
{
  "recordType": "doc_inmate",
  "recordCategory": "prison",
  "externalId": "SBX-DOC-310045",
  "personRef": "prs_…",
  "name": { "first": "In", "middle": "Sample", "last": "Custody", "suffix": "" },
  "aliases": ["I. S. Custody"],
  "dob": { "date": "1979-12-31", "birthYear": 1979, "birthMonth": 12, "age": null, "ageAsOf": null, "dobPrecision": "full" },
  "state": "AZ",
  "county": "",
  "currentStatus": "in_custody",
  "currentlyIncarcerated": true,
  "everIncarcerated": true,
  "dateIncarcerated": "2019-04-02",
  "releaseDate": "2031-04-01",
  "actualReleaseDate": null,
  "custodyFacility": "Sample State Correctional Facility",
  "custody": { "custodyClass": "Medium", "description": "General population", "facilityType": "State prison" },
  "parole": { "eligibilityDate": "2029-10-01", "nextReviewDate": null, "reviewStatus": "Not yet eligible", "lastDecision": "" },
  "sentence": { "length": "12 years", "minDate": "2019-04-02", "maxDate": "2031-04-01", "years": 12 /* … */ },
  "priorFelony": true,
  "matchConfidence": 0.99,
  "matchState": "dob_match",
  "unverified": false,

And the "Arrest Only" booking. Note currentStatus: "released", actualReleaseDate set, releaseDate null, a case at status: "Booked" with court: "", and a charge whose disposition is "":

jail_booking: "Arrest Only", dob 1992-04-20
{
  "recordType": "jail_booking",
  "recordCategory": "jail",
  "externalId": "SBX-BK-2026-004512",
  "personRef": "prs_…",
  "name": { "first": "Arrest", "middle": "", "last": "Only", "suffix": "" },
  "state": "FL",
  "county": "Sample",
  "currentStatus": "released",
  "currentlyIncarcerated": false,
  "everIncarcerated": true,
  "dateIncarcerated": "2026-08-03",
  "releaseDate": null,
  "actualReleaseDate": "2026-08-04",
  "custodyFacility": "Sample County Jail",
  "matchState": "dob_match",
  "cases": [
    {
      "caseNumber": "SBX-BK-2026-004512", "court": "", "state": "FL", "level": "county",
      "filedDate": "2026-08-03", "status": "Booked",
      "charges": [
        { "statute": "SAMPLE 316.193", "description": "Driving under the influence", "severity": "misdemeanor",
          "disposition": "", "offenseDate": "2026-08-03", "plea": "", "counts": 1 }
      ]
    }
  ],
  "booking": {
    "bookingNumber": "SBX-B-2026-0101", "bookingDate": "2026-09-30", "arrestDate": "2026-09-30",
    "arrestingAgency": "Sample County Sheriff's Office", "heldForAgency": "Sampleville Police Department",
    "housing": "Pod B-12", "holds": ["Sample County probation hold"]
    /* … arrestingOfficer, daysInJail, priorBookings, arrestTime, bookingTime, bookingType, releaseReason, bond */
  },
  "sources": [
    { "code": "FL-SAMPLE", "level": "county", "recordUrl": "https://offendersearch.app/docs/sandbox",
      "lastCheckedAt": "2026-10-01T00:00:00+00:00", "live": false }
  ]
}

Two more fields matter to an inmate tool: identifiers.inmateNumber (the DOC or inmate number) with identifiers.stateId, and mugshotUrl where the source publishes a photo.

Sort, filter and page inmate search results

An inmate tool usually wants custody records only, newest first, a page at a time. Four request keys do that.

  • `filters`. Filters are ANDed; values inside one list are ORed. recordCategory: ["prison", "jail"] keeps doc_inmate and jail_booking records; custodyStatus: ["in_custody"] keeps people in custody now; lookbackYears, offenseDate, filedDate, severity, chargeText, sources and hasPhoto are among the rest. See filtering.
  • `sort`. relevance (default), offenseDate, filedDate or dispositionDate; a date sort puts the record's latest date of that kind first and records without one last. It applies when filters or locations are sent.
  • `page` and `perPage`. perPage is 1–200 at the top level; totalPages tells you when to stop; an out-of-range page is 422 with code invalid_page.
  • `include`. ["coverage"] adds coverage.sources[] when the query names a state; ["facets"] adds counts over every matched record, including facets.custodyStatus, to a search that sends filters; ["sourceData"] empties omittedFields.

A person can appear in both a jail roster and a prison system; the two records have different externalId values and may share a personRef. Group by personRef for one card per person and keep the records separate underneath, because each has its own sources[].recordUrl.

Inmate search coverage by jurisdiction: GET /v1/criminal/sources

A tool should be able to say which jails and prison systems stood behind an answer, and which it could not reach. GET /v1/criminal/sources needs no key and lists every source the product knows about with its code (TX-BEXAR, TX-DOC), name and status (live or pending), with catalog totals over the same rows. The code is the same string a record's sources[].code carries and a live block's jurisdictions[] takes.

The catalog, and the per-search view
# Coverage by jurisdiction. No key needed.
curl https://api.offendersearch.app/v1/criminal/sources

# { "catalog": { "total": …, "live": …, "comingSoon": … }, "sources": [ { "code": "TX-BEXAR", "name": "…", "status": "live" | "pending", … } ] }
# A code here is the same code a record's sources[].code carries and a live block's jurisdictions[] takes.

# The same answer scoped to one search: include ["coverage"] with a state in the query
# "coverage": { "scope": { "state": "AZ", "county": null }, "sourcesOnFile": 5, "sourcesPending": 1,
#               "pendingSources": [ … ], "sources": [ { "code": "AZ-SAMPLE", "status": "…" }, … ] }

Per search, include: ["coverage"] with a state in the query adds a coverage block: scope { state, county, countyRecognized }, sourcesOnFile, sourcesPending, pendingSources[] and sources[] with each source's status. countyRecognized: false on a county search means only statewide and federal sources stood behind the answer. Show that to the user: "searched 5 sources for AZ; 1 pending" is an honest line. The coverage reference explains the statuses.

Live re-verification in an inmate search tool

A cached answer is the continuously updated dataset as of sources[].lastCheckedAt. Custody changes by the hour, so a tool that shows "in custody" should offer to re-verify with the jurisdiction. Add a live block to the same request: jurisdictions: ["AZ-SAMPLE"] names the sources; scope: "matched" re-verifies the sources of the records already matched. With two or fewer sync-eligible sources the answer is 200 inline; more than two is 202 with searchId (crs_…), status: "pending", mode: "async", liveSourcesRequested, a poll path and a reason, and GET /v1/criminal/searches/{searchId} returns the envelope when it is done. The price is $0.02 per completed source.

Live re-verification
# Re-verify a record with the jurisdiction. Up to two sync-eligible sources answer inline (200);
# more than two answer 202 with a poll URL. $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": "In", "lastName": "Custody", "dob": "1979-12-31", "state": "AZ"},
       "live": {"jurisdictions": ["AZ-SAMPLE"]}}'
# 200 → the envelope; counts.sourcesQueried / sourcesComplete / sourcesIncomplete count the live sources,
#        sourceStatus[] lists each, and a re-verified record carries sources[].live: true

# More than two sources, or "scope": "matched" across a wide result:
#   202 { "searchId": "crs_…", "status": "pending", "mode": "async", "liveSourcesRequested": 4,
#         "poll": "/v1/criminal/searches/crs_…", "reason": "…" }
curl https://sandbox.offendersearch.app/v1/criminal/searches/crs_… -H "X-API-Key: $OFFENDERSEARCH_API_KEY"

A re-verified record carries sources[].live: true and liveChecked: true; sourceStatus[] lists each live source. On a live search with a full dob, dobVerification can read source_confirmed or source_not_confirmed. Persist the searchId from a 202 before polling; there is no idempotency header, so a resubmitted search is a second billed one. The cached vs live guide has the timing and retry rules.

Build the inmate search UI: a booking is not a conviction

Four rules keep an inmate search tool honest.

  1. Label the kind. A doc_inmate record is a prison record; a jail_booking is a jail booking. Use recordCategory for the label and never collapse the two into "criminal record".
  2. A booking is not a conviction. A jail booking is arrest-stage: its charge is the charge at booking, cases[].charges[].disposition is "" until a court decides, and cases[].status reads Booked. Read disposition to decide whether a record is a conviction, and say "booked on" rather than "convicted of" on a booking card. The arrests, charges and convictions guide is the reference.
  3. Show `sources[].recordUrl`. Every record links to the jail's or prison system's own page. Put the link on the card with sources[].lastCheckedAt, so the user can see the current roster themselves.
  4. Never show a name-only match as a confirmed identity. matchState: "dob_match" is the only confirmation. no_dob_age_year (unverified: true), year_match and age_match are possible matches; matchConfidence is a ceiling on the evidence and a 0.95 on a name-only record is still a name match. Render those as "possible match" with the published dob, physical and mugshotUrl beside them, and keep them out of any view titled "confirmed".

Every response carries legal.notice: "Not a consumer report…". An inmate search tool is a lookup, not an eligibility decision; a tool whose answer decides hiring, tenancy, credit or insurance is an FCRA order through FCRA Partner Access, and that is a question for counsel.

To hear about a new booking or a custody change without re-searching, create a person monitor with product: "criminal", type: "person", the person and a webhookUrl. It answers 201 with channels.webhookSecret shown once and sends a signed monitor.alert on new, changed and removed events; $3 a month per person monitor, $10 per location monitor. Verify X-Offendersearch-Signature on the raw body before parsing.

Monitor for a new booking
# Watch one person for a new booking or custody change. $3 a month per person monitor.
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"product": "criminal", "type": "person",
       "label": "In Custody — custody watch",
       "person": {"firstName": "In", "lastName": "Custody", "dob": "1979-12-31"},
       "minConfidence": "dob_match",
       "channels": {"webhookUrl": "https://hooks.example.com/hooks/offendersearch"}}'
# 201 … "channels": { "webhookSecret": "whsec_…" }   ← shown once
# Alerts: { "type": "monitor.alert", "event": "new" | "changed" | "removed", "alertId": "hit_…",
#           "detail": { "name", "jurisdiction", "locality", "matchLabel", "matchConfidence", "personRef", "recordId" } }

Build an inmate search tool in seven steps

  1. Mint a test key. Create an os_test_ key in the dashboard and call https://sandbox.offendersearch.app. A test key is free and nothing is billed.
  2. Send a last name and a state. POST /v1/criminal/search with query.lastName (required), query.state, and firstName and dob when you have them; page and perPage (1–200) go at the top level.
  3. Keep custody records with filters. Send filters.recordCategory ["prison", "jail"], a sort, and include ["coverage"] to see which sources stood behind the answer.
  4. Display the custody fields. Show currentStatus, currentlyIncarcerated, custodyFacility, dateIncarcerated, releaseDate and actualReleaseDate, plus custody, parole, releaseDates and booking where published.
  5. Label the kind and link the source. Label each record as a prison record or a jail booking, show sources[].recordUrl, and treat a charge with an empty disposition as arrest-stage, not a conviction.
  6. Never show a name-only match as an identity. Only matchState dob_match confirms identity; render no_dob_age_year, year_match and age_match records as possible matches.
  7. Re-verify live and monitor. Add a live block to re-verify with the jurisdiction (two sources inline, more via 202 and poll) and create a criminal person monitor with a webhookUrl.

The inmate search page is the product view for the people who use your tool.

Inmate search API questions

Can I search by inmate number instead of a name?

The search query is name-based and lastName is required. A record carries identifiers.inmateNumber, so once you have found a person you can match on the number in your own store, and GET /v1/criminal/records/{recordId} re-fetches a record by its id.

Does currentlyIncarcerated: false mean the person was released?

No. It is also the value for someone never booked. Read everIncarcerated and currentStatus beside it, and actualReleaseDate for a release that has happened.

Is a jail booking a criminal record?

It is an arrest-stage record. The charge is the charge at booking and disposition is empty until a court decides. Label it as a booking and never as a conviction.

How do I know which jails and prisons were searched?

GET /v1/criminal/sources lists the catalog with each source's status; include: ["coverage"] on a search with a state adds coverage.sources[] for that search, with sourcesPending and pendingSources[] for what is not available yet.

How fresh is a cached answer, and how do I get a current one?

Each record's sources[].lastCheckedAt says when the source was last checked; the dataset is continuously updated. For a current answer, send a live block: two sources inline, more via 202 and GET /v1/criminal/searches/{searchId}, at $0.02 per completed source.

What this page does not cover

  • Court cases, dispositions and sentences in depth: the cases[] side of the same record.
  • Warrants and wanted records (recordType: "warrant", "wanted_fugitive").
  • Bulk lookups across many names with POST /v1/criminal/batch (cached rows only).
  • Healthcare exclusion records (healthcare_exclusion), returned beside criminal records.