Criminal Records API · v1.0.0

Asynchronous searches

Up to two live sources answer inside the request. More than two, or a source that cannot run inline, becomes a job: a `202`, a `poll` URL, and the same envelope at the end.

What you'll build

A poller: code that sends a live search, recognises a 202, follows the poll key until the job answers 200, and reads the envelope from result. The same function must handle the two other poll outcomes — still pending (202) and refused (422) — and give up gracefully without losing the searchId. Async is a latency rule, not a different product: the finished result is byte-for-byte the envelope a synchronous search returns.

Criminal has no webhook. Polling is the only way to collect an async result; if you need a push, run the poller in your own job system and emit your own event when it completes.

Before you start

  • A test key (os_test_…) on https://sandbox.offendersearch.app. The sandbox applies the same rule as production: a live.jurisdictions list of three or more codes answers 202; on the sandbox the job is complete by the time you poll, so your pending branch will be exercised only in production.
  • Felony Record, born 1975-06-15, with the sandbox codes OH-SAMPLE, TX-SAMPLE, FL-SAMPLE. On the sandbox every requested source answers complete.
  • The reference: Async & long-running.
Read poll, not pollUrl, and read result on the 200. The 202 body's key is poll (a path: /v1/criminal/searches/{searchId}). The poll's 200 body is a JobRecord, and the envelope is under result — records is not at the top level. The poll's 202 is also a JobRecord, with result: null.

Step by step

  1. Send the live search. Add live: { "jurisdictions": [...] } (or scope: "matched"). Two sync-eligible sources or fewer → 200 inline. Otherwise → 202.
  2. Store the searchId and follow poll. GET the poll path on the same host with the same key. 202 → wait and poll again. 200 → done; read result. 422 → the stored query was refused when it rendered (query_too_broad); nothing was billed. 404 → unknown id or another account's.
  3. Pace it. Every 2 seconds, for up to 5 minutes. A job that is still pending after that is not lost — GET /v1/criminal/history lists it and the same poll path keeps working — so hand it to a slower retry rather than discarding it.
  4. Read the result like any envelope. result.status (complete or partial), result.sourceStatus[], result.usage, result.records[], result.meterEvents[].
import time, requests

API, KEY = "https://sandbox.offendersearch.app", "os_test_your_key"
H = {"X-API-Key": KEY}

def live_search(query, jurisdictions, every=2.0, max_wait=300):
    r = requests.post(f"{API}/v1/criminal/search", headers=H,
                      json={"query": query, "live": {"jurisdictions": jurisdictions}})
    body = r.json()
    if r.status_code == 200:
        return body                                   # ran inline (≤2 sync-eligible sources)
    if r.status_code != 202:
        raise RuntimeError(f"{r.status_code}: {body.get('error') or body.get('detail')}")

    search_id, poll = body["searchId"], body["poll"]  # poll is a path on the same host
    print("accepted:", search_id, body["reason"])
    deadline = time.monotonic() + max_wait
    while time.monotonic() < deadline:
        p = requests.get(API + poll, headers=H)
        job = p.json()
        if p.status_code == 200:
            return job["result"]                      # the envelope lives under result
        if p.status_code == 202:
            time.sleep(every)                         # JobRecord, status pending, result null
            continue
        if p.status_code == 422:                      # refused when it rendered — nothing billed
            raise ValueError(f"refused {job['error']['code']}: {job['error']['message']}")
        p.raise_for_status()
    raise TimeoutError(f"still pending after {max_wait}s; keep polling {poll} later")

env = live_search({"lastName": "Record", "firstName": "Felony", "dob": "1975-06-15"},
                  ["OH-SAMPLE", "TX-SAMPLE", "FL-SAMPLE"])
print(env["status"], env["counts"]["records"], env["usage"]["display"])
for row in env["sourceStatus"]:
    print(row["code"], "ok" if row["ok"] else "failed", row["incompleteReason"], row["records"])
202 from POST /v1/criminal/search
{
  "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",
  "sandbox": true
}
GET poll → 202 while pending (JobRecord, result null)
{
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "accountId": "acct_1a2b3c",
  "tier": "live",
  "status": "pending",
  "recordsCount": 0,
  "liveCompleted": 0,
  "createdAt": "2026-10-25T15:04:11+00:00",
  "updatedAt": "2026-10-25T15:04:11+00:00",
  "result": null
}
GET poll → 200 when complete (the envelope under result), trimmed
{
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "accountId": "acct_1a2b3c",
  "tier": "live",
  "status": "complete",
  "recordsCount": 1,
  "liveCompleted": 3,
  "createdAt": "2026-10-25T15:04:11+00:00",
  "updatedAt": "2026-10-25T15:04:29+00:00",
  "result": {
    "status": "complete",
    "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 3, "sourcesComplete": 3, "sourcesIncomplete": 0,
                "candidatesCapped": false, "exclusionsIncluded": false },
    "warnings": [],
    "sourceStatus": [
      { "code": "OH-SAMPLE", "source": "OH-SAMPLE", "ok": true, "incomplete": false, "incompleteReason": null, "records": 1, "lastCheckedAt": "2026-10-25T15:04:12+00:00", "live": true },
      { "code": "TX-SAMPLE", "source": "TX-SAMPLE", "ok": true, "incomplete": false, "incompleteReason": null, "records": 0, "lastCheckedAt": "2026-10-25T15:04:12+00:00", "live": true },
      { "code": "FL-SAMPLE", "source": "FL-SAMPLE", "ok": true, "incomplete": false, "incompleteReason": null, "records": 0, "lastCheckedAt": "2026-10-25T15:04:12+00:00", "live": true }
    ],
    "records": [ { "recordCategory": "court", "liveChecked": true, "matchState": "dob_match", "…": "…" } ],
    "usage": { "liveChecks": 3, "liveChargeUsd": 0.06, "capped": false, "display": "3 live checks · $0.06" },
    "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
    "meterEvents": [ { "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null },
                     { "kind": "criminal_live_source", "quantity": 3, "chargeCeilingUsd": 2.0 } ],
    "legal": { "notice": "Not a consumer report. …" }
  }
}
GET poll → 422 when the stored query was refused (not a JobRecord)
{
  "searchId": "crs_7f2a1c9e0b4d6a8f1e23",
  "error": { "code": "query_too_broad",
             "message": "This query is too broad to be answered completely: … Narrow it with firstName or state." },
  "detail": "This query is too broad to be answered completely: …"
}

Reading the result

FieldValuesRead as
HTTP on POST /v1/criminal/search200 · 202200: inline answer, read the envelope. 202: a job; read searchId, poll, reason, liveSourcesRequested.
HTTP on the poll202 · 200 · 422 · 404Pending · complete · refused when it rendered · unknown or another account's id.
status (JobRecord)pending · completeMirrors the HTTP status. A refused job is not a JobRecord; GET /v1/criminal/history lists it with status: "refused".
tiercore · livelive for a job. A cached searchId is pollable too and answers 200 with tier: "core".
liveCompletedintegerLive sources that finished; 0 while pending.
resultenvelope or nullThe whole search envelope on 200. Read result.status — it can be partial even though the job is complete.
result.usage{liveChecks, liveChargeUsd, capped, display}True completed count; the charge is min(completed × $0.02, $2.00) on the published rate. Failed sources are not billed.
result.meterEvents[]criminal_call + criminal_live_sourcePersisted to your usage once, on the first complete poll of the job.

Edge cases and pitfalls

  • Filters and locations do not travel with an async job. filters, locations, a non-default sort or include: ["facets"] on a live search that must run async is 422 filters_with_async_live. Narrow live.jurisdictions to two sources, or filter the finished result yourself.
  • Clearances are not applied to a polled result. subjectId is honoured only on a synchronous 200. Apply your own suppression to result.records[] by personRef, or run the clearance-aware cached search separately. Clearances.
  • A `202` means a job exists even if you never poll it. It runs to completion and is listed in history; the live sources that complete are metered when the first complete poll is made (jobs created since 2026-10-14). Poll what you start.
  • `no_live_sources` happens before the `202`. An empty live.jurisdictions, or scope: "matched" on a search with no cached hits, is refused at once. One unrecognised code among valid ones is an ok: false row, not a refusal.
  • The sync threshold is not only a count. One source that cannot run inline makes a one-source search async. Read the 202's reason; do not assume two codes always mean 200.
  • Poll with the same key and host. A 404 on the poll usually means a different account's key or the wrong host.
  • Do not hammer the poll. Two seconds is the documented cadence; the gateway queues busy requests for up to 45 seconds and may answer 429 with Retry-After beyond that.

Production checklist

  • Branch on 202 vs 200 from the search, and on 202/200/422/404 from the poll.
  • Persist searchId and poll before the first poll; resume from history after a restart.
  • Poll every 2 seconds for up to 5 minutes, then hand the id to a slower retry rather than dropping it.
  • Read result.status and result.sourceStatus[] — a complete job can hold a partial answer.
  • Keep live.jurisdictions to the sources you need; the $2.00 ceiling caps the charge, not the latency.

Product overview