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

- **HTML:** https://offendersearch.app/docs/learn/webhook-example
- **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 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](/background-check-api) webhooks because they are small and fully specified: the [FCRA Partner API](/docs/fcra/webhooks) sends an event when an order, search, dispute, invitation, adverse action or monitor changes state, and the [Monitoring API](/docs/monitoring/delivery) 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](/docs/platform/async-and-webhooks) 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 `200` is 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.

*FCRA event (ids and statuses only)*
```json
{
  "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.

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

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

```python
# 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](/docs/learn/api-signature) page, and you can check any header you received in the browser on the [webhook tester](/tools/webhook-tester).

> **Verify, then parse. Never the other way round..** A body you parsed before verifying is a body an attacker chose. The only safe order is: raw bytes in, signature checked, then `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).

1. Look the key up in your store. If it is there, answer `200` and stop.
2. Store it, then enqueue the work, then answer `200`.
3. 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](/docs/platform/async-and-webhooks).

## 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](/docs/monitoring/alerts) is the record to reconcile against.

*Poll, then resend*
```bash
# 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).

```bash
# 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:

```bash
# 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-secret
```

Store 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:

```bash
# 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](/tools/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](/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](/docs/learn/webhooks-vs-api) page covers when each one is the right tool.

## What this page does not cover

- The unsigned `webhookUrl` on `POST /v1/searches` (async sex offender searches), which authenticates with a secret token in the URL instead: see [async & webhooks](/docs/async-and-webhooks).
- Building a sender: queueing, retries and the SSRF guard on the sending side are on the [signature](/docs/learn/api-signature) and [platform](/docs/platform/async-and-webhooks) pages.
- Which FCRA events fire when: that is the [FCRA webhooks reference](/docs/fcra/webhooks).

## Related

- [API signature: HMAC signing for requests and webhooks](https://offendersearch.app/docs/learn/api-signature)
- [Test a webhook (browser tool)](https://offendersearch.app/tools/webhook-tester)
- [Webhooks vs API polling](https://offendersearch.app/docs/learn/webhooks-vs-api)
- [API errors](https://offendersearch.app/docs/learn/api-errors)
- [FCRA webhooks & events reference](https://offendersearch.app/docs/fcra/webhooks)
- [Monitoring alert delivery](https://offendersearch.app/docs/monitoring/delivery)
- [Async jobs & webhooks (platform)](https://offendersearch.app/docs/platform/async-and-webhooks)
- [Sex offender async searches & webhookUrl](https://offendersearch.app/docs/async-and-webhooks)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Background check API](https://offendersearch.app/background-check-api)
- [Sex offender API](https://offendersearch.app/sex-offender-api)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
