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.
Updated November 3, 2026
What the court records API returns
There is no separate court endpoint. Court cases are one record kind inside the 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 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, 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. Until then the raw HTTP calls below are the integration. A test key is free from the dashboard, nothing is billed on the 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 -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"}}'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.
{
"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",| 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: 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 lists the words courts use and which mean a conviction.
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.
{
"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:
"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 has every key, and the normalized fields reference 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.
# 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 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.
# 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.pdfPOST /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
- Mint a test key and point at the sandbox. Create an
os_test_key in the dashboard, export it asOFFENDERSEARCH_API_KEY, and send every call tohttps://sandbox.offendersearch.app. Nothing is billed there. - Search by last name, first name and date of birth.
POST /v1/criminal/searchwithquery.lastName(required),query.firstNameand a fullquery.dob. Try Felony Record, 1975-06-15, then Misdemeanor Record, 1988-11-02. - Keep the court layer. Keep records whose
recordTypeiscourt_case, or sendfilters.recordCategory: ["court"]so the answer carries only court records. - Walk every charge and read its disposition. For each
cases[]entry, for eachcharges[]entry, readdisposition,dispositionDate,pleaandcounts. Treat an emptydispositionas pending or unpublished, never as an outcome. - Filter and sort when the docket is long. Send
filters(severity,outcome,filedDate,lookbackYears), setsorttofiledDateordispositionDate, and readnormalized.cases[].charges[].outcomefor a machine-readable class. - Scope to a county and re-verify it live when it matters. Put
stateandcountyinquery; checkGET /v1/criminal/sourcesfor the court sources that exist; addlive.jurisdictionsto re-check one source at the moment of the search. - Page, then render a PDF. Walk
page1 tototalPageswithperPageup to 200. Pass thesearchIdtoPOST /v1/criminal/reportfor 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 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.
- Matching and false positives on common names: the search criminal records by name and DOB tutorial.
- 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.