# Async search, webhooks & idempotency

> Submit a search, collect it by polling or webhook: POST /v1/searches, GET /v1/searches/{searchId}, signed webhook delivery, and retries with Idempotency-Key.

- **HTML:** https://offendersearch.app/docs/async-and-webhooks
- **Base URL:** https://api.offendersearch.app
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **All documentation as markdown:** https://offendersearch.app/docs.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`. 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.

|  | 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 |
| Delivery | In the response | Poll or webhook |
| Best for | Interactive checks, anything user-facing | Bulk screening, periodic re-screens, backfills, scheduled jobs |

## POST /v1/searches — 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.

**Authentication:** `X-API-Key` header.

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | Your secret API key. |
| `Idempotency-Key` | `string` | optional | Optional. A unique client token; retries with the same value return the original job. |

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `…SearchRequest` | `object` | required | Every field from POST /v1/search is accepted (query, jurisdictions, match, freshness, include, …). |
| `webhookUrl` | `uri` | optional | We POST the completed SearchResponse here when the search finishes. |

### Request

**cURL**

```bash
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"
  }'
```

**Node**

```javascript
const res = await fetch("https://api.offendersearch.app/v1/searches", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": "7f0c2e14-order-4821",
  },
  body: JSON.stringify({
    query: { lastName: "Doe" },
    jurisdictions: null,
    freshness: "daily",
    webhookUrl: "https://acme.example/hooks/os",
  }),
});

const { searchId, resultsUrl, status } = await res.json();
console.log(status, resultsUrl); // "running", ".../v1/searches/srch_9f2a7c"
```

**Python**

```python
import os, requests

resp = requests.post(
    "https://api.offendersearch.app/v1/searches",
    headers={
        "X-API-Key": os.environ["OFFENDERSEARCH_KEY"],
        "Idempotency-Key": "7f0c2e14-order-4821",
    },
    json={
        "query": {"lastName": "Doe"},
        "jurisdictions": None,
        "freshness": "daily",
        "webhookUrl": "https://acme.example/hooks/os",
    },
)
job = resp.json()
print(job["status"], job["resultsUrl"])
```

### Response

```json
{
  "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 /v1/searches/{searchId} — Get a search

Fetch the current status and results of an asynchronous search.

**Authentication:** `X-API-Key` header.

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.

### Path parameters

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `searchId` | `string` | required | The id returned by POST /v1/searches. |

### Request

**cURL**

```bash
curl https://api.offendersearch.app/v1/searches/srch_9f2a7c \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

**Node**

```javascript
// Poll until the search is complete.
async function poll(searchId) {
  for (;;) {
    const res = await fetch(
      `https://api.offendersearch.app/v1/searches/${searchId}`,
      { headers: { "X-API-Key": process.env.OFFENDERSEARCH_KEY } },
    );
    const data = await res.json();
    if (data.status === "complete" || data.status === "error") return data;
    await new Promise((r) => setTimeout(r, 2000));
  }
}

const result = await poll("srch_9f2a7c");
console.log(result.counts.records, "records");
```

**Python**

```python
import os, time, requests

def poll(search_id):
    url = f"https://api.offendersearch.app/v1/searches/{search_id}"
    headers = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}
    while True:
        data = requests.get(url, headers=headers).json()
        if data["status"] in ("complete", "error"):
            return data
        time.sleep(2)

result = poll("srch_9f2a7c")
print(result["counts"]["records"], "records")
```

### Response

```json
{
  "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. 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.

```json
{
  "event": "search.completed",
  "searchId": "srch_9f2a7c",
  "status": "complete",
  "counts": { "records": 3, "sourcesQueried": 58,
              "sourcesComplete": 58, "sourcesIncomplete": 0 },
  "sourceStatus": [],
  "records": []
}
```

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

```javascript
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"));
  enqueue(event.searchId, event);  // at-least-once: de-duplicate on searchId
  res.sendStatus(200);
});
```

## Idempotency

Network retries should never start — or bill — a second search. Send an `Idempotency-Key` header (any unique client-generated string) on `POST /v1/searches`. If that key has been seen before, the original job is returned instead of a new one being created.

```bash
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.

---

## Related

- Previous: [Jurisdictions & codes](https://offendersearch.app/docs/jurisdictions.md)
- Next: [Batch & CSV search](https://offendersearch.app/docs/batch.md)
- Index: [Offendersearch API documentation](https://offendersearch.app/docs.md)
