Developer docs · v1.0.0

Search criminal records by name and date of birth with an API

A criminal records API by name, walked through on the sandbox: the required surname, the three answers one name gives with and without a date of birth, how to read a disposition, the record kinds you will see, scoping by state and county, and when a live search answers 202.

Updated November 3, 2026

Search criminal records by name and date of birth: the request

The criminal records API is one endpoint, POST /v1/criminal/search, with the search terms inside a query object and the paging controls beside it. Two rules decide whether the request is accepted. query.lastName is required: a body without a surname is refused with 422 and the coded error guard_unbounded_query, because a search with no surname is unbounded. And page and perPage belong at the top level of the body, not inside query; perPage runs from 1 to 200 and defaults to 50, and a value outside that range is 422 invalid_page.

# Criminal records by name and date of birth, on the sandbox (free)
# lastName is required; page and perPage sit at the TOP level, not inside query
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"}, "page": 1, "perPage": 50}'

Everything here runs on the sandbox with a free os_test_ key (60 calls a minute, 2,000 a day) against fixed sample people: Felony Record, Misdemeanor Record, Arrest Only, Active Warrant, In Custody, Possible Match, Clear Record. Any other name returns a complete answer with zero records. Python and Node.js SDKs ship with the first registry release; see SDKs. Until then the raw HTTP calls above are the integration.

Criminal errors are a nested object, { "error": { "code", "message" } }, unlike the registry endpoint's flat detail. Branch on code; the message is for humans. The other query fields are birthYear, birthMonth, age, state, county, city, zipcode, address and jurisdiction; the criminal search reference documents each one.

Three outcomes for one name: what date of birth does to a criminal records search

The sandbox person Felony Record was born on 1975-06-15 and has one court case. Send the same name three ways and you get three different answers, which is the whole argument for always sending a date of birth when you have one:

Felony Record, three ways
# 1. The right date of birth → the record is identified
{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"}}
#    records[0].matchState  = "dob_match"      unverified = false
#    matchBasis             = "lastName:exact firstName:exact dob:match"

# 2. No date of birth → the same case comes back, but nothing verified it
{"query": {"firstName": "Felony", "lastName": "Record"}}
#    records[0].matchState  = "no_dob_age_year"   unverified = true

# 3. A contradicting date of birth → not this person, so not returned
{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1990-01-01"}}
#    status = "complete"   counts.records = 0   records = []
  1. With the right date, the record is identified: matchState: "dob_match", unverified: false, and matchBasis reads "lastName:exact firstName:exact dob:match". This is the only outcome your code may treat as "this is the person".
  2. Without a date, the same case comes back, but as a lead: matchState: "no_dob_age_year" and unverified: true. The name matched; nothing confirmed it. A reviewer, or a second identifier, has to.
  3. With a contradicting date (1990-01-01), the record is not returned at all: status: "complete", counts.records: 0, records: []. The platform drops a record whose date of birth disagrees with yours rather than returning it with a warning, because a record that is demonstrably someone else is noise.

A zero-record answer for a name with a date of birth is not "no records for that name"; it is "no records for that person". The no-match and multiple-match results tutorial covers how to present each outcome.

Why date of birth matters in a criminal records API by name

Common names are the problem. A surname and first name shared by thousands of people will match records in many counties, and nothing in the name tells you which, if any, belong to your subject. The date of birth is the identifier that courts, jails and corrections departments publish most consistently, so it is the one the platform verifies against. matchConfidence scores the name; matchState reports the identity check.

Not every source publishes a full date, and the record tells you what it had. dob.dobPrecision is full when the source publishes a complete date (dob.date is set), and narrower values when it publishes a year only (dob.birthYear) or an age as of a date (dob.age, dob.ageAsOf). The verification adapts to what is there:

matchStateThe source publishedMeaning
dob_matcha full dateIt agrees with yours. The only automatic identification.
year_matcha birth year onlyThe year agrees. A strong lead; unverified: true.
age_matchan age onlyThe age is consistent within a year. A plausibility check; unverified: true.
no_dob_age_yearnothing comparableName only. Also the value on every record when you sent no dob, age or birthYear. A lead for a reviewer; unverified: true.
nullanythingOnly on GET /v1/criminal/records/{recordId}, which scores nothing.

If you only know the year, send birthYear instead of dob and expect year_match at best. The sandbox name Possible Match returns three records in three states with no date on any of them, which is what a name-only answer looks like in practice. The criminal date of birth reference and the matching and confidence guide explain the rules in full.

Reading charges and dispositions in a criminal records search result

A criminal record is not a verdict. It is a container for one or more cases, each with one or more charges, and the decision about each charge lives on the charge: cases[].charges[].disposition. Here is the Felony Record court case as the sandbox returns it, trimmed to the keys that matter for this read:

records[0] — one case, two charges, two dispositions
{
  "recordType": "court_case",
  "externalId": "SBX-CR-2015-000123",
  "personRef": "prs_6e1aaefd188032a7e6eb",
  "name": { "first": "Felony", "middle": "Sample", "last": "Record", "suffix": "" },
  "dob": { "date": "1975-06-15", "birthYear": 1975, "birthMonth": 6, "age": null, "ageAsOf": null, "dobPrecision": "full" },
  "state": "OH", "county": "Sample",
  "currentStatus": "on_probation",
  "matchConfidence": 0.99,
  "matchState": "dob_match",
  "unverified": false,
  "matchBasis": "lastName:exact firstName:exact dob:match",
  "cases": [
    {
      "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",
          "disposition": "Dismissed", "dispositionDate": "2015-09-14", "offenseDate": "2015-02-20", "plea": "", "counts": 1 }
      ],
      "events": [
        { "date": "2015-03-02", "type": "ARRAIGNMENT", "description": null, "outcome": "Plea of not guilty entered" },
        { "date": "2015-09-14", "type": "PLEA HEARING", "description": null, "outcome": "Guilty plea to count 1; count 2 dismissed" }
      ],
      "sentence": { "length": "3 years", "minDate": "2015-09-14", "maxDate": "2018-09-14",
                    "probation": "2 years community control", "fine": 500.0, "dischargeDate": null, "years": 3, "months": null }
    }
  ],
  "sources": [ { "code": "OH-SAMPLE", "level": "county", "recordUrl": "https://offendersearch.app/docs/sandbox",
                 "lastCheckedAt": "2026-10-01T00:00:00+00:00", "live": false } ],
  "recordCategory": "court"
  /* … every other key is always present ("" / null / [] when the source publishes nothing) */
}

Two charges were filed on the same day in the same case. The felony burglary was disposed Guilty; the misdemeanor theft was Dismissed. "Has a felony case" is true and "convicted of theft" is false, and only the charge level can tell you both. So the read is: for each case, for each charge, take severity, description, disposition, dispositionDate and plea, and decide from those. events[] is the docket that explains how the dispositions came about; sentence is the outcome for the convicted charge.

Disposition values are the source's own words. The sandbox says Guilty and Dismissed; a real source may say "Convicted", "Nolle prosequi", "Deferred" or a code. Map the values you encounter explicitly, and treat an empty disposition as "not decided", not as "not guilty". The dispositions guide lists common forms.

The record types a criminal records search returns

recordType tells you what kind of document the record came from, and the kind decides which keys are meaningful. The sandbox has one person per kind:

recordTypeSandbox name and DOBWhat it isRead
court_caseFelony Record, 1975-06-15A filed case with charges and dispositionscases[].charges[].disposition, sentence
jail_bookingArrest Only, 1992-04-20An arrest-stage record: a charge at booking, no disposition. A booking is not a conviction.booking, the booking charge; never treat as a conviction
doc_inmateIn Custody, 1979-12-31A corrections record with custody statuscurrentlyIncarcerated, custodyFacility, releaseDate, custody
warrantActive Warrant, 1985-09-09An outstanding warrant with its statuscurrentStatus (active_warrant), warrant
healthcare_exclusionExcluded Provider, 1968-03-22A conviction-based exclusion list entryexclusion; administrative entries only with include: ["exclusions"]

Other values exist (wanted_fugitive, registry, and criminal_record for anything else), and recordCategory groups them. Every record carries every key, with "", null or [] where the kind or the source has nothing, so a court_case has an empty booking and a jail_booking has an empty cases[]. The record types guide and the criminal record object reference document each kind.

Scoping a criminal records search by state and county

By default the search covers the whole dataset for that name. To narrow it, add state and optionally county or jurisdiction to query; city, zipcode and address work the same way. For a search across several places, send locations (up to 10) at the top level instead, and do not combine it with the flat keys: sending both is 422 ambiguous_location.

Scoping changes what is searched, so a record outside the scope is not returned; it does not tell you the person has no record elsewhere. When the decision is about a person rather than a place, search unscoped first and use state and county on each returned record to see where the records are. The search by place reference covers the location object and radius searches.

Cached vs live: re-verifying a criminal records search at the source

A request with no live block is answered from the continuously updated dataset: fast, and counts.sourcesQueried is 0 because no source was contacted at request time. counts.sourcesOnFile and counts.sourcesPending describe the dataset behind the answer. When the decision needs the source's answer as of now, add a live block naming the jurisdictions to re-verify, such as ["TX-BEXAR", "TX-DOC"], or "scope": "matched" to re-verify wherever the cached answer found something.

A live search that goes async
# Re-verify at named sources. ≤ 2 sync-eligible sources → 200 inline; more → 202 and a poll URL.
curl -X POST https://sandbox.offendersearch.app/v1/criminal/search \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Felony", "lastName": "Record", "dob": "1975-06-15"},
       "live": {"jurisdictions": ["TX-BEXAR", "TX-DOC", "TX-HARRIS"]}}'

# 202
{
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "status": "pending",
  "mode": "async",
  "liveSourcesRequested": 3,
  "poll": "/v1/criminal/searches/crs_7f2a1c9e0b4d6a8f1e23",
  "reason": "3 live sources (> 2 or not sync-eligible) — running asynchronously"
}

# Poll until status is no longer "pending"; the body is then the normal search response
curl https://sandbox.offendersearch.app/v1/criminal/searches/crs_7f2a1c9e0b4d6a8f1e23 \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"

A live search with up to 2 sync-eligible sources answers 200 inline, with sourceStatus[] filled in and sources[].live: true on re-verified records. More than 2 sources answers 202 with status: "pending", mode: "async", liveSourcesRequested and a poll path; call GET /v1/criminal/searches/{searchId} until the status is no longer pending and read the body as a normal response. Persist the searchId and poll it; a repeated POST is a second search, because the search endpoints have no idempotency header. Live re-verification is $0.02 per completed source. The cached vs live guide and the criminal async reference have the lifecycle.

Search criminal records by name and date of birth: the procedure

  1. Mint a test key and set the sandbox host. Create an os_test_ key in the dashboard, export it as OFFENDERSEARCH_TEST_KEY, and call https://sandbox.offendersearch.app. Nothing on the sandbox is billed.
  2. Send lastName, firstName and dob inside query. POST /v1/criminal/search with query.lastName (required), query.firstName and query.dob as YYYY-MM-DD. Put page and perPage at the top level of the body. Sandbox: "Felony Record" with 1975-06-15.
  3. Read status and counts, then matchState. Confirm status is "complete". On each record, dob_match with unverified false is an identified person; no_dob_age_year with unverified true is a lead; a contradicting date is not returned.
  4. Read each charge, not the case. Walk cases[].charges[] and read severity, description, disposition and dispositionDate. A Guilty burglary and a Dismissed theft can sit in the same case.
  5. Scope and page. Narrow with query.state, query.county or query.jurisdiction. Raise perPage (1 to 200) or walk page while page is below totalPages.
  6. Re-verify live when the decision needs it. Add a live block naming jurisdictions. Up to 2 sync-eligible sources answer 200 inline; more answer 202 with a poll URL under /v1/criminal/searches/{searchId}.

Every response carries legal.notice: *"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.)."* Store it beside the result. A hiring, tenant, credit or insurance decision runs as an FCRA order on the FCRA Partner API, which records consent and handles adverse action; whether your use is a permissible purpose is a question for your counsel. Production is a host and key swap (https://api.offendersearch.app, an os_live_ key) at $0.15 a call, $0.11 past 2,000 calls in a month, 25 free calls, no card; the background check API tutorial walks through it.

Criminal records search by name and date of birth FAQ

Can I search criminal records by name only?

Yes, as long as lastName is present; firstName is optional. Every record then comes back unverified: true with matchState: "no_dob_age_year", because nothing identified the person. Add dob, or at least birthYear or age, as soon as you have it.

Why is my request a 422 guard_unbounded_query?

query.lastName is missing. A surname is the minimum predicate; a search with no surname cannot be bounded. Check that the name is inside query and not at the top level of the body.

How do I page through a long answer?

perPage (1 to 200, default 50) and page (from 1) sit at the top level of the body. The response carries page, perPage and totalPages; loop while page is below totalPages. The criminal pagination reference covers candidatesCapped.

Is a jail booking a criminal record?

It is an arrest-stage record (recordType: "jail_booking"): a charge at booking and no disposition. It is not a conviction and must not be read as one. Only a court_case charge with a disposition says how a charge ended.

Does a zero-record answer mean the person has no criminal record?

It means no record in the searched scope matched that name and survived the date-of-birth check, with status: "complete". A scoped search says nothing about other places, and a cached answer is as of the dataset's last update; a live search re-verifies at the source.

What this criminal records search tutorial does not cover