Async search, webhooks & idempotency
Submit and collect, with no request-timeout ceiling. Signed webhooks and idempotent retries.
Base URL https://api.offendersearch.appThis page as Markdown/docs/async-and-webhooks.mdChoosing sync or async
Use POST /v1/search when a person is waiting on the answer — it is the interactive endpoint and returns in one round trip within deadlineMs, with per-jurisdiction status in sourceStatus. Use POST /v1/searches for batch and high-volume work: submit the search, then collect the result by polling GET /v1/searches/{searchId} or by supplying a webhookUrl. Both read the same corpus at the same freshness tier and return the same envelope — the choice is about how you collect the result, not how current it is.
| Synchronous | Asynchronous | |
|---|---|---|
| Endpoint | POST /v1/search | POST /v1/searches |
| Shape | One round trip; elapsedMs on every response | Submit now, collect when ready |
| Scope | Bounded by deadlineMs | No request-timeout ceiling — every named jurisdiction runs to completion |
| Data | Identical — same corpus, same freshness tier, same response envelope | |
| Delivery | In the response | Poll or webhook |
| Best for | Interactive checks, anything user-facing | Bulk screening, periodic re-screens, backfills, scheduled jobs |
For many independent lookups in a single HTTP call — a roster, a CSV export — see Batch & CSV search, which is the third shape and returns one result per input row.
Asynchronous search — built for batch and high volume
The batch endpoint. Submit the search, get a job id back immediately, and collect the result when it is ready. Built for bulk screening, periodic re-screens, backfills and unattended scheduled jobs.
The async endpoint accepts every field the synchronous search accepts, plus an optional webhookUrl. Instead of waiting, it returns 202 Accepted with a searchId and a resultsUrl right away and keeps working in the background until every named jurisdiction has responded.
Because you are not holding a connection open, there is no request-timeout ceiling on the work — which is what makes this the right endpoint for bulk screening a roster, re-screening a population on a schedule, backfills, and broad national sweeps where you want every jurisdiction rather than a fast answer.
Retrieve results by polling GET /v1/searches/{searchId} until status is complete, or supply a webhookUrl and we will POST the finished SearchResponse to it — no polling required.
Reach for POST /v1/search instead when a person is waiting on the answer: that is the interactive endpoint and it returns in one round trip. Both endpoints read the same corpus at the same freshness tier and return the same envelope — the choice is about how you collect the result, not how current it is.
Send an Idempotency-Key header to make retries safe: a repeated key returns the original job instead of starting (and billing) a second search. See Idempotency.
- Built for volume. No request-timeout ceiling, so every named jurisdiction runs to completion. Submit many, collect the results as they finish.
- Webhook or poll. Get notified via webhookUrl, or poll the results URL on your own schedule.
- Same query surface. All query fields, jurisdictions, match, freshness, and include carry over.
- Safe retries. An Idempotency-Key header collapses duplicate submissions to one job and one bill.
| Field | Type | Description |
|---|---|---|
| X-API-Key required | string | Your secret API key. |
| Idempotency-Key optional | string | Optional. A unique client token; retries with the same value return the original job. |
| Field | Type | Description |
|---|---|---|
| …SearchRequest required | object | Every field from POST /v1/search is accepted (query, jurisdictions, match, freshness, include, …). |
| webhookUrl optional | uri | We POST the completed SearchResponse here when the search finishes. |
curl https://api.offendersearch.app/v1/searches \
-H "X-API-Key: $OFFENDERSEARCH_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f0c2e14-order-4821" \
-d '{
"query": { "lastName": "Doe" },
"jurisdictions": null,
"freshness": "daily",
"webhookUrl": "https://acme.example/hooks/os"
}'{
"searchId": "srch_9f2a7c",
"status": "running",
"estimatedCompletionSec": 45,
"resultsUrl": "https://api.offendersearch.app/v1/searches/srch_9f2a7c"
}Returns 202 Accepted. status is "pending" or "running"; poll resultsUrl or wait for the webhook.
Get a search
Fetch the current status and results of an asynchronous search.
Returns the same SearchResponse shape as the synchronous endpoint. While the search is still running, status is running and records fills in as jurisdictions complete; once every jurisdiction has reported, status becomes complete.
Poll on your own cadence (a few seconds is typical), or skip polling entirely by supplying a webhookUrl on the original request.
| Field | Type | Description |
|---|---|---|
| searchId required | string | The id returned by POST /v1/searches. |
curl https://api.offendersearch.app/v1/searches/srch_9f2a7c \
-H "X-API-Key: $OFFENDERSEARCH_KEY"{
"searchId": "srch_9f2a7c",
"status": "complete",
"elapsedMs": 41230,
"counts": { "records": 3 },
"sourceStatus": [ /* … per-jurisdiction status … */ ],
"records": [ /* … scored, de-duplicated records … */ ]
}Returns 404 if the searchId does not exist. status is running until every jurisdiction reports.
Webhooks
Supply a webhookUrl on an async search and the finished SearchResponse is POSTed to it when the search completes — no polling required. Respond 2xx promptly to acknowledge; a non-2xx response is retried with exponential backoff for several attempts. Deliveries are signed so you can verify authenticity.
{
"event": "search.completed",
"searchId": "srch_9f2a7c",
"status": "complete",
"counts": { "records": 3, "sourcesQueried": 58,
"sourcesComplete": 58, "sourcesIncomplete": 0 },
"sourceStatus": [ /* one entry per jurisdiction */ ],
"records": [ /* … scored, de-duplicated records … */ ]
}import crypto from "node:crypto";
app.post("/hooks/os", express.raw({ type: "*/*" }), (req, res) => {
const sent = req.get("X-Offendersearch-Signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.OS_WEBHOOK_SECRET)
.update(req.body) // the RAW body, before JSON parsing
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected))) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
// Delivery is at-least-once: de-duplicate on searchId.
enqueue(event.searchId, event);
res.sendStatus(200); // acknowledge promptly
});X-Offendersearch-Signature header (an HMAC of the raw request body using your signing secret) before trusting a webhook, and treat delivery as at-least-once — de-duplicate on searchId.Idempotency
Network retries should never start — or bill — a second search. Send an Idempotency-Key header (any unique client-generated string, e.g. a UUID or your order id) on POST /v1/searches. If that key has been seen before, the original job is returned instead of a new one being created, so a dropped-connection retry is safe.
curl https://api.offendersearch.app/v1/searches \
-H "X-API-Key: $OFFENDERSEARCH_KEY" \
-H "Idempotency-Key: 7f0c2e14-order-4821" \
-H "Content-Type: application/json" \
-d '{ "query": { "lastName": "Doe" }, "freshness": "daily" }'Reuse the same key when retrying the same logical request; use a fresh key for a genuinely new search. Keys are scoped to your account and retained long enough to cover normal retry windows. For 429 handling and backoff, see Errors & rate limits.