# Webhooks vs API polling: when each one is right

> An API call is you asking. A webhook is the service telling you. Both are HTTP; the difference is who starts the conversation — and that decides everything about cost, latency and failure handling.

- **HTML:** https://offendersearch.app/docs/learn/webhooks-vs-api
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## What is a webhook (web hook)?

A **webhook** is an HTTP request that a service sends to a URL you own when an event happens. You register the URL once; from then on the service POSTs a JSON body to it — a monitor found a change, an order completed, a payment settled. The word is "hook" in the programming sense: a place where you hang your own code to run when something happens, reached over the web.

A normal **API call** runs the other way: your program sends a request to the service and waits for the answer. Same protocol, same JSON, opposite direction. That is the whole difference, and it has consequences for how you build, test and secure each one.

|  | API call (pull) | Webhook (push) |
| --- | --- | --- |
| Who starts it | Your code | The service |
| When it happens | Whenever you ask | When the event occurs |
| Who needs a public URL | The service | You |
| Who authenticates | You, with an API key | The service, with a signature you verify |
| Typical status | `200` with the data | `200` from you, meaning "received" |
| Fails how | You get an error and retry | The delivery fails and the service retries you |
| Latency to learn of a change | Your polling interval | Seconds |
| Cost at scale | One request per check, whether anything changed or not | One request per event |

## The difference between webhooks and API polling, with real numbers

Say you watch 5,000 people for changes to their registry status and you want to know within an hour. By **polling**, that is 5,000 calls an hour, 120,000 a day, nearly all of which return "no change". By **webhook**, it is one delivery per change — a handful a day. The monitor costs the same either way; the polling traffic is pure waste, and at $0.15 a live call it is expensive waste.

This is why the [Monitoring API](/docs/monitoring) is built around webhooks: you create a monitor once with a `webhookUrl`, the platform keeps checking, and your endpoint hears about it only when something is found. Polling remains available (`GET /v1/monitors/{monitorId}/alerts`) for reconciliation and for the cases where a webhook is not practical.

> **Polling is not wrong.** For a one-off asynchronous job — a search that went async and will finish in seconds — polling the result URL every few seconds is simpler than standing up a webhook endpoint, and the [async jobs guide](/docs/platform/async-and-webhooks) recommends exactly that. Webhooks pay off when events are rare relative to checks, or when many things are being watched.

## Webhook use cases: when to be told instead of asking

- **Continuous monitoring.** A person or an address is watched; you want the alert, not a daily report of nothing. The [person and location monitors](/docs/monitoring/person-monitoring) deliver `new`, `updated` and `removed` alerts to your URL.
- **Long-running orders.** An [FCRA order](/docs/fcra/webhooks) moves through states over hours; `order.completed`, `dispute.created` and `adverse_action.completed` arrive as signed events.
- **Asynchronous searches at volume.** A [batch](/docs/batch) of thousands of names finishes in its own time; `webhookUrl` on `POST /v1/searches` tells you when, so no worker sits polling.
- **Fan-out to other systems.** The delivery can trigger a ticket, a Slack message, a record update — the webhook is the integration point.
- **Audit and compliance trails.** Every delivery is an event with an id and a timestamp; storing them gives you a log of what the platform told you and when.

And the use cases where an API call is right: anything you need *now* in the same request (a screening answer during onboarding), anything you initiate (create a monitor, run a search), and reading state you already know exists (`GET /v1/searches/{searchId}`).

## API polling example: submit, persist the id, poll

The pull side first, because every integration needs it even when it also uses webhooks. An asynchronous search answers `202` with a `searchId` and a `resultsUrl`; you poll that URL until the status is final. Note the three disciplines: persist the id immediately, wait between polls, cap the attempts.

```python
import os, time, requests

BASE, KEY = "https://sandbox.offendersearch.app", os.environ["OFFENDERSEARCH_TEST_KEY"]
H = {"X-API-Key": KEY, "Content-Type": "application/json"}

# 1. Submit an asynchronous search: the answer is an id, not the result
r = requests.post(f"{BASE}/v1/searches", headers=H, timeout=30,
                  json={"query": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"}})
job = r.json()                                   # 202 → {"searchId": "srch_…", "resultsUrl": "/v1/searches/srch_…"}
search_id = job["searchId"]                      # persist this before anything else

# 2. Poll until it is done (cap the attempts)
for attempt in range(60):
    s = requests.get(f"{BASE}{job['resultsUrl']}", headers=H, timeout=30).json()
    if s["status"] in ("complete", "partial", "error"):
        break
    time.sleep(3)
print(s["status"], s["counts"]["records"])
```

The same request can carry a `webhookUrl` instead, and the finished envelope is POSTed to you. One caution specific to that endpoint: the sex offender search `webhookUrl` delivery is **not signed** — authenticate it with a secret token in the URL you register, and fetch the result by id if you need certainty. The monitoring and FCRA webhooks below are signed.

## Setting up a webhook: register the URL, store the secret

Setting up a webhook is two steps: tell the service your URL, and keep the secret it gives you back. Here is a person monitor created with a webhook channel. The sandbox person **Exact Match** produces one `new` alert at once, delivered to the URL once — a real event you can receive while developing.

```bash
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "sex-offender",
    "type": "person",
    "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
    "channels": {"webhookUrl": "https://your-app.example.com/hooks/offendersearch"}
  }'

# 201 Created → the monitor, plus channels.webhookSecret ("whsec_…") shown ONCE. Store it.
```

| What you give | What you get back | Keep it where |
| --- | --- | --- |
| `channels.webhookUrl` — an HTTPS URL you control | `channels.webhookSecret` — `whsec_…`, shown only on this `201` | Your secrets store, next to the API key |
| (optional) `channels.email` | Alerts by email as well | — |

> **The URL must be public and HTTPS.** The service has to reach you. `localhost` is not reachable from the internet; see [testing webhooks locally](#testing-webhooks-locally). A plain `http://` URL would carry the event, and your secret-derived signature, in the clear.

## Webhook format: headers, body and the event shape

A delivery is an ordinary HTTPS `POST` with a JSON body. The body carries **ids and statuses only** — never a person's name or a record — because a webhook lands in whatever request logs your infrastructure keeps. You fetch the detail from the API with the id. Here is a monitoring delivery as your server sees it:

```http
POST /hooks/offendersearch HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
X-Offendersearch-Signature: t=1793180000,v1=8f3a1c…e92b
X-Offendersearch-Event: monitor.change_found
X-Offendersearch-Event-Id: evt_5a6b7c8d9e0f10213243

{"id": "evt_5a6b7c8d9e0f10213243", "type": "monitor.change_found",
 "createdAt": "2026-10-28T03:12:41+00:00",
 "data": {"monitorId": "mon_4d5e6f70819a2b3c4d5e", "status": "active"}}
```

| Header / field | Meaning |
| --- | --- |
| `X-Offendersearch-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` over `<t>.<raw body>` with your `whsec_…` secret. The same scheme on every signed webhook this platform sends, so one verifier serves all of them. |
| `X-Offendersearch-Event` | The event type, repeated from the body for routing before you parse. |
| `X-Offendersearch-Event-Id` | The event id, repeated from the body, for idempotency. |
| `id`, `type`, `createdAt` | Identity, kind and time of the event. |
| `data` | The ids that let you fetch the detail: `monitorId`, `orderId`, `searchId`, `disputeId`, plus a `status`. |

## Receive webhooks: verify the signature, acknowledge, then work

Receiving a webhook well is four rules in order: read the **raw** body, verify the signature with a constant-time compare and a timestamp window, answer `200` **before** doing the work, and make the work idempotent on the event id. Here is the whole thing in Express:

```javascript
import express from "express";
import crypto from "node:crypto";

const SECRET = process.env.OS_WEBHOOK_SECRET;                // the whsec_… from the 201
const app = express();

// Keep the RAW body: a re-serialised JSON body will not verify.
app.post("/hooks/offendersearch", express.raw({ type: "application/json" }), (req, res) => {
  const header = String(req.get("X-Offendersearch-Signature") ?? "");     // "t=<unix>,v1=<hex>"
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).end("stale");
  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(req.body).digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1 ?? ""))) return res.status(400).end("bad signature");

  const event = JSON.parse(req.body.toString("utf8"));
  res.status(200).end();                                      // acknowledge first, work after
  queue.add(event.id, event);                                 // idempotent on event.id
});
```

A Python (FastAPI and Flask) version, the retry schedule, and the "what if my server was down for an hour" answer are in the [webhook example](/docs/learn/webhook-example). The signing scheme itself — why the timestamp is inside the signed text, why `compare_digest` — is in [API signature](/docs/learn/api-signature).

> **Answer fast, then process.** A handler that does its work before responding will time out under load, the delivery will be marked failed, and the service will retry — so you process the same event twice and still look down. Enqueue, respond `200`, process.

## Webhook delivery, retries and at-least-once semantics

A webhook is delivered **at least once**. If your endpoint is unreachable, slow, or answers anything but `2xx`, the platform schedules another attempt — on the FCRA events, about every minute for the first ten attempts and then hourly, giving up 24 hours after the event was created. Every event is also listable (`GET /v1/fcra/events`, `GET /v1/monitors/{monitorId}/alerts`), so a missed delivery is recoverable by polling, and a single event can be re-queued with a resend call.

- **Duplicates are normal.** A retry after a timeout can deliver an event you already processed. Key on `id`.
- **Order is not guaranteed.** Two events can arrive out of sequence after a retry. Act on the `status` in the latest state you fetch, not on arrival order.
- **Reconcile.** Once a day, list events or alerts since your last high-water mark and process anything you never saw.

## Securing webhooks: signature, timestamp, secret rotation

| Threat | Defence | Where |
| --- | --- | --- |
| Forged delivery (anyone can POST to your URL) | HMAC signature with a secret only you and the platform hold | `X-Offendersearch-Signature`, verified with a constant-time compare |
| Replay of a captured delivery | The timestamp is inside the signed text; refuse anything older than your window (5 minutes is the documented default) | `t=` in the header |
| Body tampering | The signature covers the raw body byte for byte | Keep the raw bytes; verify before parsing |
| Leaked secret | Rotate it; the old secret stops verifying at once | `POST /v1/monitors/{monitorId}/webhook-secret` |
| Sensitive data in logs | The body carries ids and statuses only; fetch detail from the API | By design on every webhook here |

One exception to know: the sex offender async search `webhookUrl` is not signed. Treat that delivery as a notification, authenticate it with a token in the URL, and read the result from `GET /v1/searches/{searchId}`.

## Testing webhooks locally and in the sandbox

1. **Verify your verifier first.** Paste a header, a body and a secret into the [webhook tester](/tools/webhook-tester): it computes the signature in your browser and tells you whether your code would accept it. No server needed.
2. **Expose a local port.** A tunnel (ngrok, Cloudflare Tunnel) gives your `localhost:3000` a public HTTPS URL for the session. Register that URL as the `webhookUrl`.
3. **Fire a real event.** In the sandbox, `POST /v1/sandbox/monitors/{monitorId}/test-event` sends a signed delivery to the monitor's URL on demand — the production signing code, your secret, your endpoint. Nothing is billed.
4. **Break it on purpose.** Answer `500` once and watch the retry arrive. Change one byte of the body and watch verification fail. Replay a delivery from ten minutes ago and watch the timestamp check reject it.

The [monitoring sandbox page](/docs/monitoring/sandbox) lists every test scenario and what each one delivers.

## Webhook vs API: the decision in one table

| You need… | Use | Here |
| --- | --- | --- |
| An answer inside the current request | API call | `POST /v1/search`, `POST /v1/criminal/search` |
| To start something | API call | `POST /v1/monitors`, `POST /v1/searches` |
| A result that takes seconds | API call + polling | `GET /v1/searches/{searchId}` |
| To know when a watched thing changes | Webhook | Monitor `channels.webhookUrl` |
| To follow a multi-day workflow | Webhook (+ polling to reconcile) | FCRA events |
| To trigger your own systems from an event | Webhook | Any signed delivery |
| Certainty about what happened | API call | Fetch by the id the webhook gave you |

## What this page does not cover

- **Outbound webhooks from your app to others.** This page is about receiving them from an API. Sending them well uses the same signing scheme in reverse — [API signature](/docs/learn/api-signature) is the reference.
- **WebSockets and server-sent events.** Those keep a connection open for a stream; webhooks are discrete HTTP requests. Different tools for different shapes.
- **Message queues.** A webhook often *feeds* a queue on your side, but the queue is your architecture, not the API's.
- **What the events contain.** Ids and statuses only. The records, names and results live behind the API and are fetched by id — a deliberate limit, not an omission.

## FAQ: webhook vs API questions, answered

### What is the difference between an API and a webhook?

Direction. With an API, your code sends a request and gets an answer. With a webhook, the service sends a request to your URL when an event happens. Both are HTTP with JSON bodies.

### Is a webhook an API?

A webhook is part of an API's contract — the part where the API calls you. People say "webhook API" to mean the endpoints that let you register and manage webhook URLs, which exist here on the monitoring and FCRA APIs.

### What is an example of a webhook?

A registry monitor finds that a watched person's record changed and POSTs `{"type": "monitor.change_found", "data": {"monitorId": "mon_…"}}` to your URL with a signature header. The [delivery above](#webhook-format) is the literal request.

### How do I verify a webhook?

Recompute the HMAC-SHA256 of `<t>.<raw body>` with your secret, compare it to `v1` in constant time, and reject timestamps outside your window. Code in [receive webhooks](#receive-webhooks).

### How do I test a webhook locally?

Tunnel a local port to a public HTTPS URL, register it, then fire a sandbox test event. Or check your verifier without a server in the [webhook tester](/tools/webhook-tester).

### Are webhooks real time?

Near enough: a delivery is attempted as soon as the event exists, typically within seconds. The event itself depends on when the platform checks — a monitor is checked continuously, an order progresses as its work completes.

## Related

- [Learn APIs by example (hub)](https://offendersearch.app/docs/learn)
- [Webhook example: receive, verify, retry](https://offendersearch.app/docs/learn/webhook-example): the receiving code in Python and Node
- [API signature](https://offendersearch.app/docs/learn/api-signature): how the HMAC scheme works
- [Webhook tester](https://offendersearch.app/tools/webhook-tester): check your verifier in the browser
- [RESTful API example](https://offendersearch.app/docs/learn/restful-api-example): the pull side, explained
- [What is an API? Glossary](https://offendersearch.app/docs/learn/what-is-an-api): event, polling, at-least-once, replay
- [Async jobs & webhooks (platform)](https://offendersearch.app/docs/platform/async-and-webhooks): per-product async and webhook table
- [Monitoring API: delivery](https://offendersearch.app/docs/monitoring/delivery): the signed alert webhook
- [FCRA webhooks & events](https://offendersearch.app/docs/fcra/webhooks): order and dispute events
- [Async & webhooks (sex offender API)](https://offendersearch.app/docs/async-and-webhooks): the unsigned webhookUrl case
- [Background check API](https://offendersearch.app/background-check-api)
- [Monitoring & alerts](https://offendersearch.app/sex-offender-monitoring): the product that uses webhooks most
