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.
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/searchesand 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 the202. - The body is a
SearchRequestplus one optional field,webhookUrl. The URL must behttps://on a public host;http://localhost/hookis a422(webhookUrl must be an https:// URL). - A Regular job is billed as one search when accepted; a
liveblock inside the job is billed when its sources complete. There is noIdempotency-Key: every accepted request is a new, billed job.
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
- Submit.
POST /v1/searcheswith the query and, optionally,webhookUrl. Store thesearchIdfrom the202. - Poll.
GETtheresultsUrl(/v1/searches/{searchId}) every 2–5 seconds with a cap of a few minutes.pending/runningcarry an empty envelope;complete/partialcarry the fullSearchResponse;errorcarries anerrorstring and nothing was billed. - Or receive the webhook. One
POSTof 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. - Re-open later.
GET /v1/searches/{searchId}/resultsreturns the stored records (up to 200) for 14 days, not billed;202while the job still runs;410once erased.
Step 1 — submit
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"}'{
"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
{
"searchId": "srch_5a2665f5b42646445b8f19a4",
"status": "running",
"counts": { "records": 0, "sourcesQueried": 58, "sourcesIncomplete": 0 },
"sourceStatus": [],
"records": [],
"proof": { "status": "none" }
}Step 3 — the webhook receiver
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);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.
Step 4 — re-open the stored results
{
"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.
Edge cases and pitfalls
- No idempotency. A retried
POST /v1/searchesafter a network error may have created a job you never saw the202for. Make the submit step idempotent on your side (one job per roster row per run) and reconcile against Dashboard → Usage. - A synchronous `searchId` cannot be polled.
GET /v1/searches/{id}for an id that came fromPOST /v1/searchis a404— that answer was the response itself. Use/resultsto 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:
resultsUrlhere,pollon the live-search acknowledgement,pollUrlon an evidence job. All three are paths on the same host thatGET /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
searchIdwritten durably before any other step; one job per unit of work per run.- Poll interval 2–5 s, capped;
errorhandled;404after an hour handled via/results. - Webhook endpoint is
https, public, token-checked, idempotent onsearchId, 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 products
Product overview
- Sex offender search API
- Sex offender registry API
- Offender search API
- Background check API
- Bulk sex offender search
- Background check API pricing
- Background check API integration
- Integrations
- Free background check API
- Pay-as-you-go background check API
- Background check API in Python
- Background check API in Node.js
- NSOPW API