Offendersearch
Criminal API Reference

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.app

Two 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 askOmit liveSend a live block
What it doesOne 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.
SpeedSub-second≤2 sources may run inline; more → asynchronous
statusAlways "complete""complete" or "partial" (a source could not be searched to the end)
BillingOne criminal_callcriminal_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.
  • status is "complete", always. A cached search cannot be "partial".
  • counts.sourcesQueried, counts.sourcesComplete and counts.sourcesIncomplete are all 0 — and that is not a shortfall. There is no fan-out to count.
  • There is no usage block (that key appears on live searches only), and meterEvents carries exactly one criminal_call.
Currency is published per record, not as a flag. Each 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:

The live block
{
  "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 in sourceStatus[].
  • scope: "matched" — re-check every jurisdiction that produced a cached-mode hit for this query. The set is derived from the sources[].code of 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 own sourceStatus[] 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.

An empty live result is not, on its own, evidence of absence. A live source is bounded to stay fast, so it can be a lower bound even when it answered. Before treating an empty 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 forAll sync-eligible?Runs
1–2 sourcesyessync (200)
1–2 sourcesno (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.

202 Accepted
{
  "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.

statusMeaningActionHTTP
pendingQueued / runningPoll again202
completeDoneRead result200

While the job is pending the wrapper carries result: null and the poll returns 202:

Pending (HTTP 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:

Complete (HTTP 200)
{
  "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 + poll
# 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.json

What 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 with ok: false, incomplete: true, an incompleteReason, records: 0 — and is excluded from the billed count.
  • The usage block always reports the true count, even when the charge is capped — the denominator is never rounded away.
usage — capped, true count preserved
"usage": { "liveChecks": 247, "liveChargeUsd": 2.00, "capped": true,
           "display": "247 live checks · capped at $2.00" }
Live verification is available on every account. It spends real per-source money, so you are billed per live source you consult — but there is no entitlement to enable. Batch is cached-only — live verification is not offered in a batch.