Criminal Records API · v1.0.0

Performing your first criminal search

One request, one surname, one envelope. This guide takes you from a test key to a correctly read answer, including the two refusals every integration must handle.

What you'll build

A function that sends one person's name and date of birth to POST /v1/criminal/search, handles a refusal, and turns the envelope into three facts a reviewer can act on: whether the search finished (status), how many people matched (counts.records), and how strongly each matched (matchState, matchConfidence). It is the building block for screening, admissions and visitor workflows; the industry pages in Solutions all start from it.

A cached search reads the stored copy of the federal, state and county sources the platform holds and answers in under a second. It never consults a source live unless you add a live block, which Cached vs. live verification covers. Nothing here is a consumer report: every envelope says so in legal.notice.

Before you start

  • A test key from Dashboard → Keys. Test keys begin os_test_ and work only against https://sandbox.offendersearch.app; live keys begin os_live_ and work only against https://api.offendersearch.app. Send the key in the X-API-Key header.
  • The sandbox people. Felony Record, born 1975-06-15, is one court case with a felony conviction. Clear Record returns zero records. Possible Match returns three undated people. The full list is on Sandbox & test keys; the names match on first and last name together, so send both.
  • Nothing you do on the sandbox is billed or recorded to your usage. Responses are the production shape plus "sandbox": true.
The surname is required and nothing else is. A body without lastName is refused with 422 guard_unbounded_query before anything runs. firstName, dob, state and the rest narrow within the surname; they cannot replace it. page and perPage sit at the top level of the body, next to query, not inside it.

Step by step

  1. Send the search. Wrap the person in query. The date of birth is optional but changes everything about how the answer reads, so send it when you have it.
  2. Branch on the HTTP status before reading the body. 200 is an answer. 422 is a refusal whose body is {"error": {"code", "message"}} — nothing was searched and nothing was billed. 401 and 402 come from the gateway as {"detail": "..."}.
  3. Read status, then counts, then records. status is complete on every cached search. counts.records is the total before paging; counts.recordsReturned is this page. Each record carries its own match verdict.
  4. Keep the searchId. searchId (crs_ + 20 hex) is how you render a report later (POST /v1/criminal/report) and how the search appears in GET /v1/criminal/history.
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": "Record", "firstName": "Felony", "dob": "1975-06-15" },
    "page": 1,
    "perPage": 50
  }'

The answer, trimmed to the keys this guide reads (the full record has about sixty top-level keys — see the Record object):

200 — a complete cached answer
{
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 0, "sourcesComplete": 0,
              "sourcesIncomplete": 0, "candidatesCapped": false, "exclusionsIncluded": false },
  "warnings": [],
  "page": 1, "perPage": 50, "totalPages": 1,
  "sourceStatus": [],
  "cappedOmittedSources": [],
  "omittedFields": ["sourceData"],
  "records": [
    {
      "recordType": "court_case",
      "recordCategory": "court",
      "externalId": "SBX-CR-2015-000123",
      "personRef": "prs_4c1d9e7a2b8f0e6d3a51",
      "name": { "first": "Felony", "middle": "Sample", "last": "Record", "suffix": "" },
      "dob": { "date": "1975-06-15", "birthYear": 1975, "birthMonth": 6, "age": 51,
               "ageAsOf": "2026-10-01", "dobPrecision": "full" },
      "state": "OH", "county": "Sample",
      "currentStatus": "on_probation",
      "matchConfidence": 0.99,
      "matchState": "dob_match",
      "matchBasis": "lastName:exact firstName:exact dob:match",
      "matchDetail": "name and full date of birth agree",
      "unverified": false,
      "dobVerification": "record",
      "cases": [ { "caseNumber": "SBX-CR-2015-000123", "court": "Sample County Court of Common Pleas",
                   "filedDate": "2015-03-02", "status": "Closed", "charges": [ "…" ] } ],
      "sources": [ { "code": "OH-SAMPLE", "level": "county", "recordUrl": "https://offendersearch.app/docs/sandbox",
                     "lastCheckedAt": "2026-10-01T00:00:00+00:00", "live": false } ]
    }
  ],
  "proof": { "status": "none" },
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "meterEvents": [ { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null } ],
  "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.)." },
  "sandbox": true
}

Now send the same request without lastName and with a surname that is too common. The first is refused at once; the second is refused only when the stored records matching it cannot all be read within the time budget (the sandbox is too small to trigger it, so the second body below is from production):

422 — the two refusals you must handle
// query: { "firstName": "Felony" }
{ "error": { "code": "guard_unbounded_query",
             "message": "a lastName predicate is required — an unbounded query returns a lottery, not an answer" } }

// query: { "lastName": "Smith" }  (production, no firstName or state)
{ "error": { "code": "query_too_broad",
             "message": "This query is too broad to be answered completely: more than 150,000 candidate records matched it in the stored criminal records and they could not all be searched within 25 seconds. …" },
  "detail": "This query is too broad to be answered completely: …" }

query_too_broad is a refusal, not a partial answer: the API never returns a short list and calls it complete. Add firstName or state and resend — a county, city or ZIP on its own does not narrow the candidate walk.

Reading the result

FieldValuesWhat it means for a first search
statuscomplete · partialAlways complete on a cached search. partial appears only when a live block ran and a source fell short.
counts.recordsintegerPeople matched in total, before paging. 0 with status: "complete" is a clear answer for this surname and the fields you sent.
counts.recordsReturnedintegerRecords on this page. Walk page = 1 … totalPages when it is smaller than counts.records.
records[].matchStatedob_match · year_match · age_match · no_dob_age_yearHow the birth date agreed. no_dob_age_year is the value on every record when you sent no dob; it means "the name matched and nothing could confirm or deny it".
records[].matchConfidence0.0–0.99A ceiling on the evidence held, never a probability. 0.99 here is surname + first name + full date of birth; the same person with no date sent scores 0.80.
records[].unverifiedbooleantrue iff matchState is no_dob_age_year. Route these to a reviewer rather than treating them as a hit.
records[].recordCategorycourt · jail · prison · warrant · registry · exclusion · otherThe closed kind of record. Branch on this, not on recordType.
searchIdcrs_ + 20 hexStore it with the case file; a report can be rendered from it later.
meterEvents[]criminal_call (+ criminal_live_source)What this search metered. One criminal_call per search, cached or live.

Edge cases and pitfalls

  • Zero records on the sandbox for a real-looking name. The sandbox answers only its fixed names, and only when first and last name both match. Any other name is a complete answer with zero records — not a bug.
  • A date of birth that contradicts the record drops it. Felony Record with dob: "1990-01-01" returns zero records: a dob_mismatch never appears in records[]. Check the date you sent before concluding the person is clear.
  • `perPage` is validated, not clamped. perPage: 300 is 422 invalid_page; the range is 1–200. page: 0 is refused the same way.
  • Each page is a new search. Paging re-runs the query and meters another criminal_call; fetch only the pages you will read. See Pagination.
  • `""`, `null` and `[]` are three different answers. name.middle: "" means the source published no middle name; dob.date: null means no full date is known; cases: [] means no case is on file. Do not collapse them. Data model.
  • `currentStatus` is the source's own text. Display it; branch on normalized.custodyStatus, which is closed. Understanding record types.
  • A 401 on the live host with a test key is expected: the detail sentence names the sandbox URL. Keys do not cross hosts.

Production checklist

  • Switch the host to https://api.offendersearch.app and the key to an os_live_ key; keep X-API-Key as the header.
  • Handle 422 by error.code, 401/402 by detail, and never read detail on a criminal 422.
  • Treat unverified: true records as candidates for review, not as findings.
  • Store searchId and legal.notice alongside the decision you make.
  • Narrow a query_too_broad with firstName or state; do not retry it unchanged.

Product overview