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.
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 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.
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 deliver
new,updatedandremovedalerts to your URL. - Long-running orders. An FCRA order moves through states over hours;
order.completed,dispute.createdandadverse_action.completedarrive as signed events. - Asynchronous searches at volume. A batch of thousands of names finishes in its own time;
webhookUrlonPOST /v1/searchestells 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.
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.
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 | — |
localhost is not reachable from the internet; see 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:
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:
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. The signing scheme itself — why the timestamp is inside the signed text, why compare_digest — is in API signature.
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
statusin 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
- Verify your verifier first. Paste a header, a body and a secret into the webhook tester: it computes the signature in your browser and tells you whether your code would accept it. No server needed.
- Expose a local port. A tunnel (ngrok, Cloudflare Tunnel) gives your
localhost:3000a public HTTPS URL for the session. Register that URL as thewebhookUrl. - Fire a real event. In the sandbox,
POST /v1/sandbox/monitors/{monitorId}/test-eventsends a signed delivery to the monitor's URL on demand — the production signing code, your secret, your endpoint. Nothing is billed. - Break it on purpose. Answer
500once 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 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 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 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.
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.
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.