Sex Offender Search API · v1.0.0

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/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 -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"}'
202 Accepted
{
  "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

GET /v1/searches/{searchId}/results → 200 (trimmed)
{
  "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

FieldWhereWhat it decides
searchId202 bodyThe durable key for the poll, /results, /report and clearances. Persist first.
resultsUrl202 bodyThe poll path, relative to the host. Use it verbatim.
statuspoll bodypending/running → keep polling; complete/partial → read as a SearchResponse; error → failed, nothing billed.
errorpoll body on failureA 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 bodyHow 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/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.
  • 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.

Product overview