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_…) onhttps://sandbox.offendersearch.app. The sandbox applies the same rule as production: alive.jurisdictionslist of three or more codes answers202; on the sandbox the job is complete by the time you poll, so your pending branch will be exercised only in production. Felony Record, born1975-06-15, with the sandbox codesOH-SAMPLE,TX-SAMPLE,FL-SAMPLE. On the sandbox every requested source answers complete.- The reference: Async & long-running.
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
- Send the live search. Add
live: { "jurisdictions": [...] }(orscope: "matched"). Two sync-eligible sources or fewer →200inline. Otherwise →202. - Store the searchId and follow poll.
GETthepollpath on the same host with the same key.202→ wait and poll again.200→ done; readresult.422→ the stored query was refused when it rendered (query_too_broad); nothing was billed.404→ unknown id or another account's. - Pace it. Every 2 seconds, for up to 5 minutes. A job that is still pending after that is not lost —
GET /v1/criminal/historylists it and the samepollpath keeps working — so hand it to a slower retry rather than discarding it. - Read the result like any envelope.
result.status(completeorpartial),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"]){
"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
}{
"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
}{
"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. …" }
}
}{
"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
| Field | Values | Read as |
|---|---|---|
HTTP on POST /v1/criminal/search | 200 · 202 | 200: inline answer, read the envelope. 202: a job; read searchId, poll, reason, liveSourcesRequested. |
| HTTP on the poll | 202 · 200 · 422 · 404 | Pending · complete · refused when it rendered · unknown or another account's id. |
status (JobRecord) | pending · complete | Mirrors the HTTP status. A refused job is not a JobRecord; GET /v1/criminal/history lists it with status: "refused". |
tier | core · live | live for a job. A cached searchId is pollable too and answers 200 with tier: "core". |
liveCompleted | integer | Live sources that finished; 0 while pending. |
result | envelope or null | The 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_source | Persisted 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-defaultsortorinclude: ["facets"]on a live search that must run async is422 filters_with_async_live. Narrowlive.jurisdictionsto two sources, or filter the finishedresultyourself. - Clearances are not applied to a polled result.
subjectIdis honoured only on a synchronous200. Apply your own suppression toresult.records[]bypersonRef, 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, orscope: "matched"on a search with no cached hits, is refused at once. One unrecognised code among valid ones is anok: falserow, 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'sreason; do not assume two codes always mean200. - Poll with the same key and host. A
404on 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
429withRetry-Afterbeyond that.
Production checklist
- Branch on
202vs200from the search, and on202/200/422/404from the poll. - Persist
searchIdandpollbefore 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.statusandresult.sourceStatus[]— a complete job can hold apartialanswer. - Keep
live.jurisdictionsto the sources you need; the$2.00ceiling caps the charge, not the latency.