# Asynchronous searches

> Submit, get a job id, collect the result when it is ready — by polling or by webhook. Built for rosters, backfills and unattended jobs.

- **HTML:** https://offendersearch.app/docs/guides/sex-offender/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

## When to use this

`POST /v1/search` is the interactive endpoint: a person is waiting, and the answer comes back in one round trip. `POST /v1/searches` is for everything else — re-screening a roster overnight, a backfill, a scheduled job, a live check of many registries — where holding a connection open is the wrong design. You submit, receive a `202` with a `searchId`, and collect the finished envelope by polling `GET /v1/searches/{searchId}` or by having it POSTed to a `webhookUrl` you control. Both endpoints search the same registries and return the same `SearchResponse`; the choice is about how you collect it.

You will build a submit-and-collect worker with a durable `searchId` store, and a webhook receiver that authenticates deliveries with a token in its own URL.

## Before you start

- A test key. `POST /v1/searches` and the poll accept an **API key only**; a dashboard session cannot submit or poll. On the sandbox the job is complete immediately (`estimatedCompletionSec: 0`), and a webhook is delivered exactly once right after the `202`.
- The body is a `SearchRequest` plus one optional field, `webhookUrl`. The URL must be `https://` on a public host; `http://localhost/hook` is a `422` (`webhookUrl must be an https:// URL`).
- A Regular job is billed as one search when accepted; a `live` block inside the job is billed when its sources complete. There is no `Idempotency-Key`: **every accepted request is a new, billed job.**

> **The one thing people get wrong.** Persist the `searchId` from the `202` **before** you do anything else — before polling, before enqueuing, before returning. A crash between the `202` and your first poll otherwise leaves a billed search you can no longer find, and resubmitting creates a second one.

## Step by step

1. **Submit.** `POST /v1/searches` with the query and, optionally, `webhookUrl`. Store the `searchId` from the `202`.
2. **Poll.** `GET` the `resultsUrl` (`/v1/searches/{searchId}`) every 2–5 seconds with a cap of a few minutes. `pending`/`running` carry an empty envelope; `complete`/`partial` carry the full `SearchResponse`; `error` carries an `error` string and nothing was billed.
3. **Or receive the webhook.** One `POST` of the finished envelope, `Content-Type: application/json`, unsigned, no retry. Authenticate it with the secret token in your URL and treat the body as a hint: read the result back with the API when it matters.
4. **Re-open later.** `GET /v1/searches/{searchId}/results` returns the stored records (up to 200) for 14 days, not billed; `202` while the job still runs; `410` once erased.

### Step 1 — submit

**cURL**
```bash
curl -s -i https://sandbox.offendersearch.app/v1/searches \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" -H "Content-Type: application/json" \
  -d '{"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "webhookUrl": "https://example.com/hooks/offendersearch?token=YOUR_SECRET"}'
```

**Python**
```python
import os, time, requests

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")
H = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}

def submit(query, webhook_url=None, store=None):
    body = {"query": query}
    if webhook_url:
        body["webhookUrl"] = webhook_url
    r = requests.post(BASE + "/v1/searches", headers=H, json=body, timeout=30)
    r.raise_for_status()                       # 202
    accepted = r.json()
    if store is not None:
        store(accepted["searchId"])            # durable write FIRST, before anything else
    return accepted

def collect(search_id, every=3, max_wait=300):
    deadline = time.time() + max_wait
    while time.time() < deadline:
        r = requests.get(BASE + "/v1/searches/" + search_id, headers=H, timeout=30)
        if r.status_code == 404:
            raise LookupError("unknown job, another account's job, or older than an hour")
        r.raise_for_status()
        body = r.json()
        if body["status"] in ("complete", "partial"):
            return body
        if body["status"] == "error":
            raise RuntimeError(body.get("error"))  # nothing billed for a failed job
        time.sleep(every)
    raise TimeoutError(search_id)

acc = submit({"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}, store=print)
result = collect(acc["searchId"])
print(result["status"], result["counts"]["records"])
```

**JavaScript**
```javascript
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';
const H = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY, 'Content-Type': 'application/json' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function submit(query, { webhookUrl, store } = {}) {
  const body = webhookUrl ? { query, webhookUrl } : { query };
  const res = await fetch(BASE + '/v1/searches', { method: 'POST', headers: H, body: JSON.stringify(body) });
  if (res.status !== 202) throw new Error('HTTP ' + res.status + ': ' + (await res.text()));
  const accepted = await res.json();
  if (store) await store(accepted.searchId);        // durable write FIRST
  return accepted;
}

async function collect(searchId, every = 3000, maxWait = 300000) {
  const deadline = Date.now() + maxWait;
  while (Date.now() < deadline) {
    const res = await fetch(BASE + '/v1/searches/' + searchId, { headers: H });
    if (res.status === 404) throw new Error('unknown job, another account, or older than an hour');
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const body = await res.json();
    if (body.status === 'complete' || body.status === 'partial') return body;
    if (body.status === 'error') throw new Error(body.error);   // nothing billed
    await sleep(every);
  }
  throw new Error('timed out: ' + searchId);
}

const acc = await submit({ firstName: 'Exact', lastName: 'Match', dob: '1980-01-01' }, { store: async (id) => console.log('stored', id) });
const result = await collect(acc.searchId);
console.log(result.status, result.counts.records);
```

*202 Accepted*
```json
{
  "searchId": "srch_5a2665f5b42646445b8f19a4",
  "status": "pending",
  "jurisdictionsQueried": 58,
  "estimatedCompletionSec": 0,
  "resultsUrl": "/v1/searches/srch_5a2665f5b42646445b8f19a4",
  "sandbox": true
}
```

In production `estimatedCompletionSec` is 20 and `status` is always `pending` on acceptance; `running` appears only on a later poll. `jurisdictionsQueried` is the fan-out the job will run — 58 here, fewer with `jurisdictions` or `locationScoped`.

### Step 2 — the poll

**while running**
```json
{
  "searchId": "srch_5a2665f5b42646445b8f19a4",
  "status": "running",
  "counts": { "records": 0, "sourcesQueried": 58, "sourcesIncomplete": 0 },
  "sourceStatus": [],
  "records": [],
  "proof": { "status": "none" }
}
```

**when finished**
```json
{
  "searchId": "srch_5a2665f5b42646445b8f19a4",
  "status": "complete",
  "counts": { "records": 1, "recordsReturned": 1, "sourcesQueried": 58, "sourcesComplete": 58, "sourcesIncomplete": 0, "sourcesSkippedByScope": 0 },
  "sourceStatus": [ "… 58 rows …" ],
  "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "personRef": "prs_28db9639d712c4c61e8e", "matchState": "dob_match", "matchConfidence": 0.9 } ],
  "warnings": [],
  "proof": { "status": "none" },
  "sandbox": true
}
```

**on failure**
```json
{
  "searchId": "srch_5a2665f5b42646445b8f19a4",
  "status": "error",
  "error": "…",
  "records": []
}
```

### Step 3 — the webhook receiver

**Node (http)**
```javascript
import { createServer } from 'node:http';

const TOKEN = process.env.HOOK_TOKEN;   // the same value you put in webhookUrl

createServer((req, res) => {
  const url = new URL(req.url, 'https://example.com');
  if (req.method !== 'POST' || url.pathname !== '/hooks/offendersearch' || url.searchParams.get('token') !== TOKEN) {
    res.writeHead(404).end(); return;    // deliveries are unsigned: the token is the authentication
  }
  let raw = '';
  req.on('data', (c) => (raw += c));
  req.on('end', () => {
    const envelope = JSON.parse(raw);    // the finished SearchResponse
    markFinished(envelope.searchId, envelope.status);   // your own store; then read the result back via the API
    res.writeHead(204).end();
  });
}).listen(8080);
```

**Python (Flask)**
```python
import os
from flask import Flask, request, abort

app = Flask(__name__)
TOKEN = os.environ["HOOK_TOKEN"]        # the same value you put in webhookUrl

@app.post("/hooks/offendersearch")
def hook():
    if request.args.get("token") != TOKEN:
        abort(404)                       # deliveries are unsigned: the token is the authentication
    envelope = request.get_json(force=True)   # the finished SearchResponse
    mark_finished(envelope["searchId"], envelope["status"])  # your own store; then read it back via the API
    return "", 204
```

The delivery is a single `POST` with a 10-second timeout and no retry, so a receiver that is down misses it; your poller is the fallback. Monitoring alerts are a different product with their own delivery rules — see [Delivery](/docs/monitoring/delivery).

### Step 4 — re-open the stored results

*GET /v1/searches/{searchId}/results → 200 (trimmed)*
```json
{
  "searchId": "srch_5a2665f5b42646445b8f19a4",
  "ranAt": "2026-10-25T14:18:55.257261+00:00",
  "status": "complete",
  "query": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" },
  "records": [ { "recordId": "rec_28db9639d712c4c61e8e", "personRef": "prs_28db9639d712c4c61e8e", "matchState": "dob_match" } ],
  "storedCount": 1,
  "totalCount": 1,
  "retentionDays": 14,
  "sandbox": true
}
```

This route works for synchronous searches too, and accepts a dashboard session as well as a key. `storedCount` is at most 200; `totalCount` is what the search matched. After 14 days: `410` with a sentence that says to run the search again.

## Reading the result

| Field | Where | What it decides |
| --- | --- | --- |
| `searchId` | `202` body | The durable key for the poll, `/results`, `/report` and clearances. Persist first. |
| `resultsUrl` | `202` body | The poll path, relative to the host. Use it verbatim. |
| `status` | poll body | `pending`/`running` → keep polling; `complete`/`partial` → read as a `SearchResponse`; `error` → failed, nothing billed. |
| `error` | poll body on failure | A sentence for logs. |
| HTTP `404` on the poll | — | Unknown id, another account's id, or a job row older than about an hour. The stored results are still at `/results`. |
| `storedCount` / `totalCount` / `retentionDays` | `/results` body | How much of the answer is on file and for how long. |

The finished envelope is read exactly like a synchronous one: [Understanding search results](/docs/guides/sex-offender/understanding-results).

## Edge cases and pitfalls

- **No idempotency.** A retried `POST /v1/searches` after a network error may have created a job you never saw the `202` for. Make the submit step idempotent on your side (one job per roster row per run) and reconcile against [Dashboard → Usage](/dashboard/usage).
- **A synchronous `searchId` cannot be polled.** `GET /v1/searches/{id}` for an id that came from `POST /v1/search` is a `404` — that answer was the response itself. Use `/results` to re-open it.
- **The poll needs the key that submitted** (or another key on the same account). A session token is refused.
- **Three poll keys across the platform**: `resultsUrl` here, `poll` on the live-search acknowledgement, `pollUrl` on an evidence job. All three are paths on the same host that `GET /v1/searches/{id}` serves.
- **Webhook bodies can be large**; the envelope carries up to 4,000 records. Acknowledge quickly and process off the request thread.
- **Trial accounts**: an async job counts against the 25-search trial and is capped at 50 records like any other search; a trial search must carry a name.
- **`live` in a job** can only name registries; a block that resolves to many sources runs in the background anyway, which is the point. The billing is the same as `POST /v1/search` ($0.02 per completed source, $2.00 ceiling).

## Production checklist

- `searchId` written durably before any other step; one job per unit of work per run.
- Poll interval 2–5 s, capped; `error` handled; `404` after an hour handled via `/results`.
- Webhook endpoint is `https`, public, token-checked, idempotent on `searchId`, and not the only path to the result.
- Stored results re-read within 14 days where the audit needs them; reports requested within 7.

## Related

- [Async search, webhooks & idempotency](https://offendersearch.app/docs/async-and-webhooks)
- [Platform: async & webhooks](https://offendersearch.app/docs/platform/async-and-webhooks)
- [Batch searches](https://offendersearch.app/docs/guides/sex-offender/batch-searches)
- [Regular vs. live searches](https://offendersearch.app/docs/guides/sex-offender/regular-vs-live)
