Webhook example: receive, verify and retry in 40 lines
A real webhook, end to end: the URL you register, the exact payload that arrives, the signature you check, the code that receives it in Node and Python, and what the sender does when you are down.
What a webhook is, and what this example sends
A webhook is an HTTP POST that a service makes to you when something happens, instead of you polling the service to ask. You give the service a URL; it sends a JSON body to that URL with a signature you can check. The whole contract is four things: the URL, the payload, the signature, and what happens on failure.
This page walks through the OffenderSearch webhooks because they are small and fully specified: the FCRA Partner API sends an event when an order, search, dispute, invitation, adverse action or monitor changes state, and the Monitoring API sends monitor.alert when a watched person or place changes. Both sign the same way, so one receiver handles every webhook from the platform. If you are building your own webhook sender, copy the shape.
| Part | In this example |
|---|---|
| URL | Yours, HTTPS, public. Registered with PUT /v1/fcra/webhook-endpoint or as channels.webhookUrl on a monitor. |
| Payload | JSON. FCRA: {id, type, createdAt, data} with ids and statuses only. Monitoring: the monitor.alert object. |
| Signature | X-Offendersearch-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>." + body)> |
| Retries | FCRA: immediately, then every minute ×10, then hourly until 24 h after the event. Monitoring: the next daily run. |
| Fallback | GET /v1/fcra/events lists every event with its delivery state; POST …/resend sends one again. |
Webhook URL example: what the sender needs from your endpoint
A webhook URL is an ordinary route on your server, for example https://hooks.example.com/hooks/offendersearch. The sender requires three things of it:
- HTTPS on a public host. The platform resolves the hostname and refuses private, loopback and link-local addresses, and it never follows a redirect, so the URL must answer directly. (This is the SSRF guard every sender should have.)
- A `2xx` within 10 seconds. Anything else, including a timeout, is a failed attempt and is retried on the schedule below. Do the work after you answer.
- Tolerance for duplicates. Delivery is at-least-once. If your
200is lost on the network, the same event arrives again.
Do not put a secret in the URL as your only authentication: URLs land in access logs. The signature header is the authentication; a path segment nobody can guess is a fine extra.
The webhook payload: a real event body
The FCRA event body carries identifiers and statuses only. The sender refuses to emit any other key, because a webhook body lands in third-party request logs and a name or a record must never travel that way. Fetch the object by its id when you need the detail.
{
"id": "fev_7c1d9e2a4b6f8a0c1e3d",
"type": "order.completed",
"createdAt": "2026-10-28T00:00:00+00:00",
"data": { "orderId": "ord_4f2a9c7e1b3d5f8a0c2e", "status": "complete" }
}The monitoring alert carries a record summary, because the alert *is* the record summary; the same object is what GET /v1/monitors/{monitorId}/alerts returns.
{
"type": "monitor.alert",
"event": "new",
"alertId": "hit_7fa176c8889c7569",
"monitorId": "mon_b8f40d7afb9dc0d8",
"monitorLabel": "Jordan Rivera — SO watch",
"product": "sex-offender",
"detail": {
"name": "JORDAN RIVERA",
"jurisdiction": "TX",
"locality": "Houston",
"matchLabel": "dob_match",
"matchConfidence": 1.0,
"personRef": "prs_3c9e1f0a7b2d4e6f8a1b",
"recordId": "rec_ab12cd34ef56gh78ij90"
},
"occurredAt": "2026-09-06T12:00:41+00:00",
"sentAt": "2026-09-06T12:01:03+00:00"
}Two headers travel with every FCRA delivery besides the signature: X-Offendersearch-Event (the type, so you can route before parsing) and X-Offendersearch-Event-Id (the id, so you can dedupe before parsing). Monitoring sends User-Agent: Offendersearch-Monitor/1.
Create a webhook endpoint in Node (Express)
Four steps: read the raw body, verify the signature, deduplicate, acknowledge. The order matters, and step one is where most receivers go wrong: express.json() parses and re-serialises the body, and a re-serialised body does not hash to the sender's signature.
// npm i express
import express from "express";
import crypto from "node:crypto";
const SECRET = process.env.OS_WEBHOOK_SECRET; // whsec_… shown once when you registered the URL
const seen = new Set(); // replace with your database in production
const app = express();
// 1. RAW body. express.json() would re-serialise it and the signature would never match.
app.post("/hooks/offendersearch", express.raw({ type: "application/json" }), (req, res) => {
// 2. Verify before you parse.
const header = String(req.get("X-Offendersearch-Signature") ?? ""); // "t=<unix>,v1=<hex>"
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300; // five-minute window
const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.`).update(req.body).digest("hex");
const ok = fresh && parts.v1?.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
// 3. Dedupe. Deliveries are at-least-once; the event id is the key.
const event = JSON.parse(req.body);
const key = event.id ?? event.alertId;
if (seen.has(key)) return res.sendStatus(200);
seen.add(key);
// 4. Acknowledge fast, work later. The sender waits 10 seconds, no more.
queue.push(event); // your job queue
res.sendStatus(200);
});
app.listen(3000);Create a webhook endpoint in Python (FastAPI)
The same four steps. await request.body() gives the raw bytes; hmac.compare_digest is the constant-time comparison.
# pip install fastapi uvicorn
import hmac, hashlib, os, time, json
from fastapi import FastAPI, Request, Response
SECRET = os.environ["OS_WEBHOOK_SECRET"] # whsec_… shown once when you registered the URL
seen: set[str] = set() # replace with your database in production
app = FastAPI()
@app.post("/hooks/offendersearch")
async def receive(request: Request):
body = await request.body() # 1. raw bytes — never request.json() first
header = request.headers.get("X-Offendersearch-Signature", "")
try: # 2. verify before you trust
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
except Exception:
return Response(status_code=401)
if abs(time.time() - t) > 300:
return Response(status_code=401) # stale: outside the replay window
expected = hmac.new(SECRET.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
return Response(status_code=401)
event = json.loads(body) # 3. dedupe on the event id
key = event.get("id") or event.get("alertId")
if key in seen:
return Response(status_code=200)
seen.add(key)
queue.enqueue(event) # 4. acknowledge fast, work later
return Response(status_code=200)Flask users: request.get_data() is the raw body. Django: request.body. Rails: request.raw_post. Go: io.ReadAll(r.Body). Whatever the framework, the rule is the same: hash the bytes you received, not an object you rebuilt.
Verify the webhook signature before you trust the payload
The header is t=<unix seconds>,v1=<hex>. Recompute HMAC-SHA256(secret, "<t>." + raw body) with the timestamp from the header, not from your clock, and compare in constant time. Refuse a t more than five minutes from now: that is the replay window. The full reasoning, a known-answer test vector and code in four languages are on the API signature page, and you can check any header you received in the browser on the webhook tester.
JSON.parse.Idempotency: handling the same webhook twice
Delivery is at-least-once. The sender retries when it does not see a 2xx, and a 200 lost on the way back is the same as no 200. So every receiver needs a key to deduplicate on: the FCRA event id (fev_…), or the monitoring alertId (hit_…, one per monitor and person per registry, stable across its new, changed and removed events).
- Look the key up in your store. If it is there, answer
200and stop. - Store it, then enqueue the work, then answer
200. - If storing and enqueueing are not one transaction, store first: a duplicate job is cheaper than a lost one.
The platform itself does not read an idempotency header on its own endpoints; the same rule, applied in reverse, is why you should persist a `searchId` before retrying an async submission.
What the sender does when you are down: the retry schedule
Every FCRA event is a row first and a delivery second. A failed attempt (non-2xx, timeout, refused host) schedules the next one; a recovery sweep delivers anything due.
| Attempt | When | Then |
|---|---|---|
| 1 | Immediately, as the event is created | — |
| 2–11 | Every 60 seconds (10 fast retries) | status pending, nextAttemptAt set |
| 12+ | Every hour | until 24 hours after createdAt |
| — | After 24 hours | status failed; the event stays listable and can be resent |
If no endpoint is configured the event is marked no_endpoint and is still listable: polling GET /v1/fcra/events is the delivery. Monitoring alerts retry differently, because they are produced by a daily evaluation: an alert whose webhook and email both fail stays pending and both are tried again on the next run, the following day. There is no faster retry and no backoff schedule; the alert history is the record to reconcile against.
# Missed a delivery? The events list is the record. Page oldest-first with ?after=<event id>.
curl "https://api.offendersearch.app/v1/fcra/events?limit=100" -H "X-API-Key: $OFFENDERSEARCH_API_KEY"
# Each event carries its delivery state:
{ "id": "fev_7c1d9e2a4b6f8a0c1e3d", "type": "order.completed", "createdAt": "…",
"data": { "orderId": "ord_4f2a9c7e1b3d5f8a0c2e", "status": "complete" },
"delivery": { "status": "pending", "attempts": 3, "deliveredAt": null,
"nextAttemptAt": "2026-10-28T00:04:00+00:00", "lastStatus": 503 } }
# Send one again:
curl -X POST https://api.offendersearch.app/v1/fcra/events/fev_7c1d9e2a4b6f8a0c1e3d/resend \
-H "X-API-Key: $OFFENDERSEARCH_API_KEY"Respond fast, work later
The sender waits 10 seconds for your answer. A handler that calls your database, your mail provider and a third-party API before answering will time out under load, be counted as failed, and be retried, which makes the load worse. Answer 200 as soon as the event is stored and queued; do everything else on a worker. If you cannot run a worker, at least answer before the slow call and accept that a crash in between loses the work.
Set up the webhook: register the URL and get the signing secret
One endpoint per FCRA partner account; the secret is shown once in the response and is never readable again. Send "rotateSecret": true to the same PUT to get a new one (the old one stops verifying at once).
# FCRA Partner API: one endpoint per account, secret returned once
curl -X PUT https://api.offendersearch.app/v1/fcra/webhook-endpoint \
-H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/hooks/offendersearch"}'
# 200
{
"configured": true,
"url": "https://hooks.example.com/hooks/offendersearch",
"signatureHeader": "X-Offendersearch-Signature",
"secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef",
"createdAt": "2026-10-28T00:00:00+00:00",
"updatedAt": "2026-10-28T00:00:00+00:00"
}For monitoring, the webhook is a channel on the monitor and the secret comes back on the 201 that created it:
# Monitoring API: the webhook is a channel on the monitor; the secret comes back on the 201
curl -X POST https://api.offendersearch.app/v1/monitors \
-H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"product": "sex-offender", "type": "person", "label": "Jordan Rivera — SO watch",
"person": {"firstName": "Jordan", "lastName": "Rivera", "dob": "1988-04-12"},
"channels": {"webhookUrl": "https://hooks.example.com/hooks/offendersearch"}}'
# 201 … "channels": {"webhookUrl": "…", "webhookSecret": "whsec_…"} ← store it now
# Rotate later: POST /v1/monitors/{monitorId}/webhook-secretStore the secret where you store other credentials. Never commit it, never log it, never put it in the URL.
Test the webhook before go-live
You do not need a real event to test a receiver. Sign the sample body with your own secret and POST it yourself:
# Sign and POST the sample event to your own endpoint (a webhook.site URL works for a first test)
BODY='{"createdAt":"2026-10-28T00:00:00+00:00","data":{"orderId":"ord_4f2a9c7e1b3d5f8a0c2e","status":"complete"},"id":"fev_7c1d9e2a4b6f8a0c1e3d","type":"order.completed"}'
SECRET=whsec_0123456789abcdef0123456789abcdef0123456789abcdef
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST https://hooks.example.com/hooks/offendersearch \
-H "Content-Type: application/json" \
-H "X-Offendersearch-Signature: t=$T,v1=$SIG" \
-H "X-Offendersearch-Event: order.completed" \
--data-binary "$BODY"The webhook tester does the same in the browser: pick an event type, generate a secret, see the signed header, copy the curl, and paste a header you received to check it. For a receiver that is not yet reachable from the internet, point the URL at a webhook.site address to see what arrives, or at an ngrok tunnel to reach your laptop. The sandbox runs the same FCRA and monitoring flows with test keys, so an end-to-end rehearsal costs nothing.
The webhook event catalogue
Every type the FCRA Partner API can send. The data object carries the ids and the status named in the table, nothing else.
| Family | Types | data carries |
|---|---|---|
| Order | order.created, order.completed, order.cancelled, order.revised | orderId, status; revision on a revision |
| Search | search.completed, search.superseded | orderId, searchId, type; supersedes |
| Dispute | dispute.created, dispute.updated, record.corrected | disputeId, orderId, status; recordId |
| Invitation (hosted consent) | invitation.created, invitation.completed, invitation.expired, invitation.cancelled | invitationId, status |
| Adverse action | adverse_action.created, adverse_action.completed, adverse_action.paused, adverse_action.resumed, adverse_action.cancelled, adverse_action.notice_not_delivered | adverseActionId, orderId, status, reason |
| Monitor (continuous checks) | monitor.created, monitor.deactivated, monitor.change_found | monitorId, status; orderId on a change |
| Partner | partner.credentialed, partner.declined | applicationId, status |
| Monitoring API | monitor.alert | the alert object above |
Security checklist for a webhook receiver
- HTTPS only; the sender will not call plain HTTP.
- Verify the signature on the raw bytes, with the header's timestamp, in constant time, inside a five-minute window.
- Treat the body as untrusted until verified; never log it before then.
- Keep the secret out of code, logs and URLs; rotate it if it ever appears in one.
- Deduplicate on the event id; expect replays from the sender's own retries.
- Fetch the object by id for anything that drives a decision. The FCRA body deliberately carries no record, because a decision about a person must come from the report, not from a notification.
Webhook questions developers ask
How do I make a webhook?
Write an HTTPS route that reads the raw body, verifies the signature, deduplicates on the event id, and answers 200 fast. The Express and FastAPI code above is complete. Then register the route's URL with the sender and store the secret it returns.
How do I create a webhook URL?
It is just a route on your server, for example /hooks/offendersearch. Make the host public and HTTPS. For local development, expose it with an ngrok tunnel; for a first look at the payload, use a webhook.site URL.
What is in a webhook payload?
JSON describing what happened. Here: an event id, a type, a timestamp and a data object with ids and a status. Senders that put full records in the payload make every receiver's logs a data-protection problem; fetch by id instead.
Is a webhook a POST?
Yes. A webhook is an HTTP POST from the service to you, with a JSON body and, from any sender you should trust, a signature header.
Do I need a webhook server?
You need something that answers HTTPS on the public internet: a route in your existing app, a serverless function, or a small dedicated service. It does not need to be separate from your application.
What is a webhook event?
One occurrence of something the sender promised to tell you about: order.completed, monitor.alert. Each has an id, and that id is what you deduplicate on.
Can I use webhooks without an API?
A webhook is one half of an integration. You still use the API to fetch the object the event refers to and to register the URL in the first place. The webhooks vs API page covers when each one is the right tool.
What this page does not cover
- The unsigned
webhookUrlonPOST /v1/searches(async sex offender searches), which authenticates with a secret token in the URL instead: see async & webhooks. - Building a sender: queueing, retries and the SSRF guard on the sending side are on the signature and platform pages.
- Which FCRA events fire when: that is the FCRA webhooks reference.