Async search, webhooks & idempotency
Submit and collect, with no request-timeout ceiling. Signed webhooks and idempotent retries.
Base URL https://api.offendersearch.appChoosing 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 and return the same envelope — the choice is about how you collect the result, not how current it is. A live re-verification of more than two sources is collected here the same way.
| 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 and 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 and return the same envelope — the choice is about how you collect the result, not how current it is. A live re-verification of more than two sources is collected here the same way.
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, live, 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, live, 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,
"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. Delivery is a single best-effort attempt with a 10-second timeout: it is not retried and not signed. Treat it as a notification, then confirm the result by fetching GET /v1/searches/{searchId} with your API key — that response is the source of truth, and polling it always works even if the webhook never arrives.
{
"searchId": "srch_9f2a7c",
"status": "complete",
"counts": { "records": 3, "sourcesQueried": 58,
"sourcesComplete": 58, "sourcesIncomplete": 0 },
"sourceStatus": [ /* one entry per jurisdiction */ ],
"records": [ /* … scored, de-duplicated records … */ ]
}app.post("/hooks/os", express.json(), async (req, res) => {
res.sendStatus(200); // acknowledge promptly
// The webhook is a notification: it is unsigned and sent once.
// Fetch the search with YOUR key and trust that response instead.
const { searchId } = req.body;
const r = await fetch(
`https://api.offendersearch.app/v1/searches/${searchId}`,
{ headers: { "X-API-Key": process.env.OS_API_KEY } });
if (r.ok) enqueue(searchId, await r.json()); // de-duplicate on searchId
});GET /v1/searches/{searchId} with your API key to confirm, and keep polling as the fallback. (Monitoring alerts are different — those webhooks are signed; see the Monitoring API.)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" } }'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.