Criminal Records API · v1.0.0

Coverage and data sources

A clear answer is only as good as the sources behind it. The catalog is public, the freshness is per source, and every search says which sources stood behind it.

What you'll build

Two things. A coverage check you can run before you buy and keep running afterwards — which federal, state and county sources the platform holds, which are live today and which are known but not on file yet, and when each was last refreshed. And a per-search reading of the coverage block so your outcome logic can say "clear across N sources, M not on file yet" instead of a bare "clear".

Before you start

  • GET /v1/criminal/sources needs no key and works on both hosts; it is the public coverage statement and is cached for up to five minutes. On the sandbox it returns the production catalog unchanged.
  • A test key (os_test_…) for the search half. Send query.state so the coverage block is scoped, and include: ["coverage"] to list every source in scope.
  • The rendered catalog and the human list: Coverage today and All data sources.
Freshness never filters. A source contributes its stored records to a cached search whatever its health.ageSeconds says. Read the catalog to know a source's currency, not to predict which sources will answer. To get a source's answer as of now, run a live check (Cached vs. live verification).

Step by step

  1. Read the catalog. GET /v1/criminal/sources. catalog totals the rows; each sources[] row has a public id (the jurisdiction code), a name, covers, scope, recordType in words, status and a health block.
  2. Separate live from pending. status: "live" is on file today. status: "pending" is known and not yet available — it contributes nothing to any search until it turns live.
  3. Read freshness per source. health.lastSuccessAt and ageSeconds say when the stored copy was last refreshed; freshFor restates it as the tiers it falls inside (daily ≈ ≤36 h, weekly ≈ ≤8 days). [] means never refreshed.
  4. Read coverage on a search. Send state (and county). The envelope's coverage block and counts.sourcesOnFile/sourcesPending say what stood behind this answer; coverage.pendingSources[] names what did not.
# the public catalog — no key
curl -s https://sandbox.offendersearch.app/v1/criminal/sources | head -c 1200

# a scoped search with the full in-scope source list
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": "Clear", "state": "TX", "county": "Bexar" }, "include": ["coverage"] }'
GET /v1/criminal/sources, trimmed to two rows
{
  "catalog": { "total": 1410, "live": 727, "comingSoon": 683, "states": 50 },
  "coverage": { "jurisdictions_total": 1410, "jurisdictions_live_connector": 727, "jurisdictions_pending_connector": 683, "states_covered": 50 },
  "sources": [
    { "id": "TX-BEXAR", "name": "Bexar County, TX", "covers": ["TX"], "scope": "county",
      "recordType": "County jail roster", "status": "live",
      "health": { "lastSuccessAt": "2026-10-24T05:12:00+00:00", "ageSeconds": 30074, "freshFor": ["daily", "weekly"] } },
    { "id": "TX-TRAVIS", "name": "Travis County, TX", "covers": ["TX"], "scope": "county",
      "recordType": "Court records", "status": "pending",
      "health": { "lastSuccessAt": null, "ageSeconds": null, "freshFor": [] } }
  ],
  "count": 1410
}
The coverage block on a scoped search (include: ["coverage"]), trimmed
{
  "status": "complete",
  "counts": { "records": 0, "recordsReturned": 0, "sourcesQueried": 0, "sourcesComplete": 0, "sourcesIncomplete": 0,
              "candidatesCapped": false, "exclusionsIncluded": false, "sourcesOnFile": 4, "sourcesPending": 1 },
  "coverage": {
    "scope": { "state": "TX", "county": "Bexar", "countyRecognized": true },
    "sourcesOnFile": 4,
    "sourcesPending": 1,
    "pendingSources": [ { "code": "TX-COURT", "name": "Texas — statewide court records", "recordType": "Court records", "scope": "state" } ],
    "sources": [
      { "code": "TX-BEXAR",     "name": "Bexar County, TX",                 "recordType": "County jail roster", "scope": "county",  "status": "live" },
      { "code": "TX-DOC",       "name": "Texas — statewide prison system",  "recordType": "Prison system",      "scope": "state",   "status": "live" },
      { "code": "US-WARRANT",   "name": "Federal — Warrant system",         "recordType": "Warrant system",     "scope": "federal", "status": "live" },
      { "code": "US-EXCLUSION", "name": "Federal — Exclusion list",         "recordType": "Exclusion list",     "scope": "federal", "status": "live" },
      { "code": "TX-COURT",     "name": "Texas — statewide court records",  "recordType": "Court records",      "scope": "state",   "status": "pending" }
    ]
  },
  "records": []
}

Read the two together: zero records across four sources on file — the county's jail roster, the state prison system and two federal lists — with the statewide court source for Texas not on file yet. That is a materially different sentence from "no criminal records found", and it is the one to put in front of a reviewer. (The codes and counts above are illustrative; read yours from the response.)

Reading the result

FieldValuesRead as
sources[].statuslive · pendingOn file today, or known and not yet available. Only live rows ever contribute records.
sources[].health.lastSuccessAt / ageSecondsISO timestamp / integer, or nullWhen the stored copy was last refreshed. null = never. Computed when the catalog is served (cached up to five minutes).
sources[].health.freshFor[] · ["weekly"] · ["daily", "weekly"]The tiers the snapshot falls inside; a restatement of ageSeconds, not a filter.
sources[].recordTypeCounty jail roster · Court records · Prison system · Statewide registry · Warrant system · Exclusion list · RecordsWhat kind of records the source holds, in words. The record-level twin is recordCategory.
catalog{total, live, comingSoon, states}Totals over the same rows as sources[], so the two always agree.
coverage.scope{state, county, countyRecognized}state is null unless a two-letter state was sent. countyRecognized: false = no source of that county's own is in the catalog.
coverage.sourcesOnFile / sourcesPendingintegers (also on counts)Sources behind this answer, and sources in scope not on file yet. sourcesPending > 0 → read pendingSources before calling an empty result a clear.
coverage.pendingSources[]present when state was sentThe pending sources, named by code.
coverage.sources[]present with include: ["coverage"] and a stateEvery source in scope with its status.
sourceStatus[][] on a cached searchPer-source outcomes exist only for live checks; coverage is the cached-search equivalent.

Edge cases and pitfalls

  • "Not on file yet" is not "searched and found nothing". A pending source in scope means nothing from it could have been returned. Carry sourcesPending and the names into your outcome text.
  • `coverage` is absent, not empty, when the catalog cannot be read. Treat a missing key as "unknown coverage" and surface it; do not default it to zero pending.
  • No `state` means an unscoped `coverage.scope.state: null` and no pendingSources. If you want the coverage sentence, send the state.
  • Freshness is per source, not per record. A record's own currency is sources[].lastCheckedAt and its lifecycle; the catalog's health describes the source's last refresh.
  • The catalog grows. Counts change as coverage grows; never hard-code a total or a code list. The public endpoint is cheap and cacheable (Cache-Control: public, max-age=300).
  • `503 catalog_unavailable` means the catalog file could not be read; the gateway serves its last cached copy when it can. Retry with backoff; do not fail the search path on it.
  • `GET /v1/criminal/jurisdictions` is the keyed twin with recordKind and live flags but no health; use it for codes, sources for freshness.

Production checklist

  • Cache GET /v1/criminal/sources for five minutes and alert when a source you depend on leaves freshFor or turns pending.
  • Send state on every search; store coverage.sourcesOnFile, sourcesPending and pendingSources[].code with the result.
  • Word every clear outcome as "no records across N sources on file" and name the pending ones.
  • Use a live check for a point-in-time answer from a specific source; never infer it from health.
  • Keep the Not-a-consumer-report notice (legal.notice) with any coverage statement you show.

Product overview