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.
| recordType | recordCategory | What it is | Custody fields that carry data |
|---|---|---|---|
doc_inmate | prison | A state or federal department of corrections record: a sentenced person in a prison system. | currentStatus, currentlyIncarcerated, custodyFacility, dateIncarcerated, releaseDate, releaseDates, custody, parole, sentence |
jail_booking | jail | A 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:
| Field | Type | Meaning |
|---|---|---|
currentStatus | string | The 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. |
currentlyIncarcerated | boolean | In custody as of the last check. false is not "released": it is also the value for someone never booked. Read everIncarcerated beside it. |
custodyFacility | string | null | The facility, where published. |
dateIncarcerated | string | null | Booking or admission date. |
releaseDate | string | null | The projected or scheduled release. |
actualReleaseDate | string | null | A release that has happened. Distinct from releaseDate. |
releaseDates | object | projected, earliest, maximum, supervisionEnds, parole: the dates a prison publishes, ISO or null. |
custody | object | null | custodyClass, description, facilityType. |
parole | object | null | eligibilityDate, nextReviewDate, reviewStatus, lastDecision. |
booking | object | bookingNumber, 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:
{
"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 "":
{
"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"]keepsdoc_inmateandjail_bookingrecords;custodyStatus: ["in_custody"]keeps people in custody now;lookbackYears,offenseDate,filedDate,severity,chargeText,sourcesandhasPhotoare among the rest. See filtering. - `sort`.
relevance(default),offenseDate,filedDateordispositionDate; a date sort puts the record's latest date of that kind first and records without one last. It applies whenfiltersorlocationsare sent. - `page` and `perPage`.
perPageis 1–200 at the top level;totalPagestells you when to stop; an out-of-range page is422with codeinvalid_page. - `include`.
["coverage"]addscoverage.sources[]when the query names astate;["facets"]adds counts over every matched record, includingfacets.custodyStatus, to a search that sendsfilters;["sourceData"]emptiesomittedFields.
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.
# 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.
# 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.
- Label the kind. A
doc_inmaterecord is a prison record; ajail_bookingis a jail booking. UserecordCategoryfor the label and never collapse the two into "criminal record". - A booking is not a conviction. A jail booking is arrest-stage: its charge is the charge at booking,
cases[].charges[].dispositionis""until a court decides, andcases[].statusreadsBooked. Readdispositionto 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. - 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. - Never show a name-only match as a confirmed identity.
matchState: "dob_match"is the only confirmation.no_dob_age_year(unverified: true),year_matchandage_matchare possible matches;matchConfidenceis a ceiling on the evidence and a0.95on a name-only record is still a name match. Render those as "possible match" with the publisheddob,physicalandmugshotUrlbeside 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.
# 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
- 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.
- 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.
- Keep custody records with filters. Send filters.recordCategory ["prison", "jail"], a sort, and include ["coverage"] to see which sources stood behind the answer.
- Display the custody fields. Show currentStatus, currentlyIncarcerated, custodyFacility, dateIncarcerated, releaseDate and actualReleaseDate, plus custody, parole, releaseDates and booking where published.
- 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.
- 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.
- 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.