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

- **HTML:** https://offendersearch.app/docs/guides/criminal/async-searches
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## 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](/docs/criminal/async).

> **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[]`.

**Python**
```python
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"])
```

**JavaScript**
```javascript
const API = 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': 'os_test_your_key', 'Content-Type': 'application/json' };
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

async function liveSearch(query, jurisdictions, { everyMs = 2000, maxWaitMs = 300000 } = {}) {
  const r = await fetch(API + '/v1/criminal/search', {
    method: 'POST', headers: H, body: JSON.stringify({ query, live: { jurisdictions } }),
  });
  const body = await r.json();
  if (r.status === 200) return body;                       // ran inline
  if (r.status !== 202) throw new Error(r.status + ': ' + JSON.stringify(body.error ?? body.detail));

  const { searchId, poll } = body;                         // poll is a path on the same host
  console.log('accepted:', searchId, body.reason);
  const deadline = Date.now() + maxWaitMs;
  while (Date.now() < deadline) {
    const p = await fetch(API + poll, { headers: H });
    const job = await p.json();
    if (p.status === 200) return job.result;               // the envelope lives under result
    if (p.status === 202) { await sleep(everyMs); continue; }
    if (p.status === 422) throw new Error('refused ' + job.error.code + ': ' + job.error.message);
    throw new Error('HTTP ' + p.status);
  }
  throw new Error('still pending after ' + maxWaitMs / 1000 + 's; keep polling ' + poll + ' later');
}

const env = await liveSearch({ lastName: 'Record', firstName: 'Felony', dob: '1975-06-15' }, ['OH-SAMPLE', 'TX-SAMPLE', 'FL-SAMPLE']);
console.log(env.status, env.counts.records, env.usage.display);
for (const row of env.sourceStatus) console.log(row.code, row.ok ? 'ok' : 'failed', row.incompleteReason, row.records);
```

*202 from POST /v1/criminal/search*
```json
{
  "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)*
```json
{
  "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*
```json
{
  "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)*
```json
{
  "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-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](/docs/criminal/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.

## Related

- [Async & long-running](https://offendersearch.app/docs/criminal/async): the reference
- [Cached vs. live verification](https://offendersearch.app/docs/guides/criminal/cached-vs-live)
- [Completeness & source status](https://offendersearch.app/docs/criminal/result-completeness)
- [Async & webhooks across products](https://offendersearch.app/docs/platform/async-and-webhooks)
