Offendersearch
API Reference · v1.0.0

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

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

SynchronousAsynchronous
EndpointPOST /v1/searchPOST /v1/searches
ShapeOne round trip; elapsedMs on every responseSubmit now, collect when ready
ScopeBounded by deadlineMsNo request-timeout ceiling — every named jurisdiction runs to completion
DataIdentical — same corpus, same freshness tier, same response envelope
DeliveryIn the responsePoll or webhook
Best forInteractive checks, anything user-facingBulk 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.

POST/v1/searchesAuth: X-API-Key

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.

What you can do
  • 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.
Headers
FieldTypeDescription
X-API-Key
required
stringYour secret API key.
Idempotency-Key
optional
stringOptional. A unique client token; retries with the same value return the original job.
Body
FieldTypeDescription
…SearchRequest
required
objectEvery field from POST /v1/search is accepted (query, jurisdictions, match, freshness, include, …).
webhookUrl
optional
uriWe POST the completed SearchResponse here when the search finishes.
Request
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"
  }'
Response
200 OK
{
  "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.

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.

Delivery
POST to your webhookUrl
{
  "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 … */ ]
}
Verifying the signature
Express handler
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
});
Verify the 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.

Safe retry
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.