Async live jobs & polling
When a live search goes async, the 202 body, and the poll contract for collecting the result.
Base URL https://api.offendersearch.appTwo modes: cached, and live verification
There is one search endpoint, and which mode you get is decided by the request body. Omit the live block and you get the cached answer; send a live block and the API re-checks the actual jurisdiction(s) in real time. Both modes run every result through the same name/DOB matcher and return the same response envelope, so a cached record and a live record agree about identity and rank together — the choice is about how the answer is obtained, not about a different product.
| Cached (default) | Live verification | |
|---|---|---|
| How you ask | Omit live | Send a live block |
| What it does | One indexed query over the continuously-updated corpus — every covered county, statewide and federal jurisdiction. No live data source is consulted. | Re-checks the actual jurisdiction(s) in-request, then merges the fresh results with the cached answer through the same matcher. |
| Speed | Sub-second | ≤2 sources may run inline; more → asynchronous |
status | Always "complete" | "complete" or "partial" (a source could not be searched to the end) |
| Billing | One criminal_call | criminal_call plus per completed live source, to a $2.00 ceiling |
Cached — the default, and what almost every caller wants
The cached corpus is continuously updated. A cached search reads the newest stored copy of every jurisdiction, assembled into one ranked answer, and returns in under a second. It never reaches out to a live data source — that is the speed guarantee, and it is absolute. On a cached search:
sourceStatus[]is[]— there was no per-source fan-out to report; the answer came from the corpus in one indexed query.statusis"complete", always. A cached search cannot be"partial".counts.sourcesQueried,counts.sourcesCompleteandcounts.sourcesIncompleteare all0— and that is not a shortfall. There is no fan-out to count.- There is no
usageblock (that key appears on live searches only), andmeterEventscarries exactly onecriminal_call.
sources[] entry carries lastCheckedAt — when we last saw that person at that jurisdiction — and each record carries a lifecycle block telling you whether the person was presentInLastSweep or has droppedOut. There is no separate freshness flag; the timestamp is the answer. On a cached search an empty records array genuinely means “nobody in the corpus matched” — the incompleteness failure mode is a property of live verification only.Live verification — checked at the source, right now
A live check is interactive and real-time — the mode to reach for when compliance needs a current, moment-of-decision verification of a specific person. Send a live block and the API re-verifies at the actual jurisdiction(s) as part of your request, then merges the fresh results with the cached answer. The block has three inputs, usable in any combination:
{
"query": { "lastName": "Hamilton", "firstName": "Alex", "state": "TX" },
"live": {
"jurisdictions": ["TX-DALLAS"],
"scope": "matched",
"websites": ["https://sheriff.examplecounty.gov/inmates"]
}
}jurisdictions: [codes]— an explicit list of neutral public codes to check live. One public county code may fan out to more than one internal system, and each reports its own row insourceStatus[].scope: "matched"— re-check every jurisdiction that produced a cached-mode hit for this query. The set is derived from thesources[].codeof the cached records.websites: […]— roster/records URLs; each is resolved to a public code and fed into the live path exactly as if you had listed the code. An entry that maps to no covered jurisdiction is reported as its ownsourceStatus[]row — it does not fail the search. See Resolve a website.
A live-verified record carries liveChecked: true and a fresh lastCheckedAt; the merge lets a live result supersede the cached copy of the same person (matched on the source’s own stable externalId), and the whole set is re-ranked. A person only the live pull found is added; a person only the cache held stays — nothing is lost.
records array as “not found”, check counts.sourcesIncomplete == 0 first — > 0 means at least one source could not be searched to the end, so the result is a lower bound, not a confirmed absence. Record not determined and retry. Full discipline in Result completeness.Sync vs async — a latency rule, not a product difference
A live check of ≤2 sync-eligible sources runs synchronously — one 200, results inline. More than two sources, or any source that needs heavier handling, runs asynchronously: a 202 with a searchId and a poll URL. Both paths run the same live checks and return the same envelope — the only difference is whether you hold the connection open or collect the result later. Cached searches and synchronous live searches never produce a job to poll.
| You asked for | All sync-eligible? | Runs |
|---|---|---|
| 1–2 sources | yes | sync (200) |
| 1–2 sources | no (one needs heavier transport) | async (202) |
| 3+ sources | (irrelevant) | async (202) |
A source is sync-eligible only when its transport is fast and free of anti-bot friction. A source that needs heavier handling is never run synchronously, no matter how few you asked for — so two sources can still go async if one is not sync-eligible. Because one public county code can expand to several internal systems, a single live.jurisdictions: ["TX-DALLAS"] may resolve to more than two connectors and therefore run async; liveSourcesRequested in the 202 body tells you the true count.
{
"searchId": "crs_9a3b71c0e28d4f6a5b12",
"status": "pending",
"mode": "async",
"liveSourcesRequested": 4,
"poll": "/v1/criminal/searches/crs_9a3b71c0e28d4f6a5b12",
"reason": "4 live sources (> 2 or not sync-eligible) — running asynchronously"
}reason is free text for humans — do not branch on it. Poll the poll URL until it returns 200.
GET /v1/criminal/searches/{searchId}
Polling is the delivery model. The API has no webhook or callback for a criminal search — you collect an async result by polling this endpoint with your X-API-Key. A search is only visible to the account that created it; another account’s searchId returns 404. The poll returns the stored job record, and the shape depends on job state.
| status | Meaning | Action | HTTP |
|---|---|---|---|
pending | Queued / running | Poll again | 202 |
complete | Done | Read result | 200 |
While the job is pending the wrapper carries result: null and the poll returns 202:
{
"searchId": "crs_9a3b71c0e28d4f6a5b12",
"accountId": "acct_…",
"tier": "live",
"status": "pending",
"recordsCount": 0,
"liveCompleted": 0,
"createdAt": "2026-08-25T15:04:11+00:00",
"updatedAt": "2026-08-25T15:04:11+00:00",
"result": null
}When it completes, the same wrapper returns 200 with result carrying the full response envelope — read result.counts.sourcesIncomplete and result.warnings exactly as you would for a synchronous live search:
{
"searchId": "crs_9a3b71c0e28d4f6a5b12",
"accountId": "acct_…",
"tier": "live",
"status": "complete",
"recordsCount": 3,
"liveCompleted": 4,
"createdAt": "2026-08-25T15:04:11+00:00",
"updatedAt": "2026-08-25T15:04:29+00:00",
"result": {
"status": "complete",
"counts": { "records": 3, "recordsReturned": 3,
"sourcesQueried": 4, "sourcesComplete": 4, "sourcesIncomplete": 0 },
"warnings": [],
"page": 1, "perPage": 50, "totalPages": 1,
"sourceStatus": [ "… 4 rows, live: true …" ],
"cappedOmittedSources": [],
"omittedFields": ["sourceData"],
"records": [ "… 3 records …" ],
"proof": { "status": "none" },
"usage": { "liveChecks": 4, "liveChargeUsd": 0.08, "capped": false,
"display": "4 live checks · $0.08" },
"searchId": "crs_9a3b71c0e28d4f6a5b12",
"meterEvents": [
{ "kind": "criminal_call", "quantity": 1, "chargeCeilingUsd": null },
{ "kind": "criminal_live_source", "quantity": 4, "chargeCeilingUsd": 2.00 }
],
"legal": { "notice": "Not a consumer report. …" }
}
}There is no separate error job state: a live source that fails is reported inside the completed result as an incomplete sourceStatus[] row with an incompleteReason, and the job still completes. See Result completeness.
# submit, then poll until 200
SID=$(curl -s -X POST https://api.offendersearch.app/v1/criminal/search \
-H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
-d '{"query":{"lastName":"Hamilton","state":"TX"},
"live":{"jurisdictions":["TX-DALLAS","TX-HARRIS","TX-DOC"]}}' | jq -r '.searchId')
while :; do
code=$(curl -s -o /tmp/job.json -w '%{http_code}' \
"https://api.offendersearch.app/v1/criminal/searches/$SID" -H "X-API-Key: $OFFENDERSEARCH_KEY")
[ "$code" = "200" ] && break # 202 while pending, 200 when complete
sleep 2
done
jq '.result.counts' /tmp/job.jsonWhat a live check costs
- +$0.02 per live source that completes.
- The live charge for one search never exceeds $2.00. This is a price ceiling, not a source limit — you may request live checks across more sources than the ceiling covers (a statewide-everything live check) and all of them run. The ceiling binds at 100 completed checks (100 × $0.02 = $2.00); past that, the marginal live check is free.
- A live check that fails is reported as failed and is NOT billed. Only sources that complete are metered (
criminal_live_source, quantity = sources that finished OK). A failed source appears withok: false,incomplete: true, anincompleteReason,records: 0— and is excluded from the billed count. - The
usageblock always reports the true count, even when the charge is capped — the denominator is never rounded away.
"usage": { "liveChecks": 247, "liveChargeUsd": 2.00, "capped": true,
"display": "247 live checks · capped at $2.00" }