FCRA webhooks
Signed events carry identifiers and statuses, never personal data. Verify, then fetch the object with your key.
What you’ll build
A webhook endpoint for the FCRA API: registered once with PUT /v1/fcra/webhook-endpoint, verifying every delivery’s HMAC signature, idempotent on event id, and backed by a catch-up loop over GET /v1/fcra/events so a missed delivery is never lost. Unlike monitoring deliveries, FCRA deliveries are signed.
Before you start
- A test key against
https://sandbox.offendersearch.appand a publichttpsURL. The URL must be on a public host (1–2,000 characters); private hosts are a422. - Only an account admin may set or delete the endpoint. An API key is always admin; a dashboard session needs the admin seat, else a
403with a plain-stringdetail. - Seven event types:
order.created,search.completed,order.completed,order.cancelled,dispute.created,dispute.updated,record.corrected.datacarries ids and statuses only. - An order’s optional
callbackUrlreceives that order’s events instead of the account endpoint, signed with the same secret;record.correctedalways goes to the account endpoint.
The secret is shown once. The
PUT response carries secret only when the endpoint is created and when you send rotateSecret: true. GET /v1/fcra/webhook-endpoint never returns it. Store it at that moment; if you lose it, rotate.Step by step
- Register the endpoint.
PUT /v1/fcra/webhook-endpointwith{ "url": "https://…" }. Savesecretfrom the response. - Verify each delivery. Header
X-Offendersearch-Signature: t=<unix>,v1=<hex>. Recompute HMAC-SHA256 with your secret overt+.+ the raw body; compare in constant time; reject whentis more than five minutes old.X-Offendersearch-EventandX-Offendersearch-Event-Idare also sent. - Act on the type, fetch the object. Switch on
type; usedata.orderId/searchId/disputeId/recordIdto fetch the current object with your key. Respond2xxwithin 10 seconds. - Catch up.
GET /v1/fcra/events?after=<last id>&limit=500on a schedule; stop wheneventsis empty.POST /v1/fcra/events/{event_id}/resendre-delivers one.
curl -X PUT https://sandbox.offendersearch.app/v1/fcra/webhook-endpoint \
-H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-cra.example/hooks/offendersearch" }'{
"configured": true,
"url": "https://your-cra.example/hooks/offendersearch",
"createdAt": "2026-10-25T14:20:03.119472+00:00",
"updatedAt": "2026-10-25T14:20:03.119472+00:00",
"signatureHeader": "X-Offendersearch-Signature",
"secret": "4f0b…c91e"
}Verify a delivery
import hmac, hashlib, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["OS_FCRA_WEBHOOK_SECRET"].encode()
def verify(raw_body: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(SECRET, parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(expected, parts["v1"])
@app.post("/hooks/offendersearch")
def fcra_event():
if not verify(request.get_data(), request.headers.get("X-Offendersearch-Signature", "")):
abort(401)
event = json.loads(request.get_data()) # {"id","type","createdAt","data"}
if already_seen(event["id"]):
return "", 200
if event["type"] == "order.completed":
enqueue_fetch_order(event["data"]["orderId"])
elif event["type"] == "search.completed":
enqueue_fetch_search(event["data"]["orderId"], event["data"]["searchId"])
elif event["type"] in ("dispute.created", "dispute.updated"):
enqueue_fetch_dispute(event["data"]["disputeId"])
elif event["type"] == "record.corrected":
flag_record(event["data"]["recordId"], event["data"]["outcome"])
return "", 200 # within 10 seconds{
"id": "fev_5f1e3340306f1d02",
"type": "search.completed",
"createdAt": "2026-10-25T14:01:07.455930+00:00",
"data": {
"orderId": "fo_ee0b34c5b52fd35b",
"searchId": "fs_9c03d7e1a4b6f258",
"type": "criminal",
"status": "complete",
"result": "records_found"
}
}Catch up from the event log
curl "https://sandbox.offendersearch.app/v1/fcra/events?after=fev_5f1e3340306f1d02&limit=500" \
-H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"{
"events": [
{
"id": "fev_6a0b2c41d7e8f913",
"type": "order.completed",
"createdAt": "2026-10-25T14:01:07.502118+00:00",
"data": { "orderId": "fo_ee0b34c5b52fd35b", "status": "complete" },
"delivery": { "status": "delivered", "attempts": 1, "deliveredAt": "2026-10-25T14:01:07.914402+00:00",
"nextAttemptAt": null, "lastStatus": 200 }
}
],
"nextAfter": "fev_6a0b2c41d7e8f913"
}Reading the result
| Event | `data` | What to do |
|---|---|---|
order.created | orderId, status: "received" | Confirm the order is in flight. |
search.completed | orderId, searchId, type, status (complete, unavailable, or error with no result), result | Fetch the search; an error status has no result — read it, do not infer clear. |
order.completed | orderId, status: "complete" or "error" | Fetch the order and the report. |
order.cancelled | orderId, status: "cancelled" | Close it on your side. |
dispute.created | disputeId, orderId, origin (partner, consumer_portal), status: "received" | A dispute opened by you or in the consumer portal. |
dispute.updated | disputeId, orderId, status | Moved to investigating, extended, or resolved. Fetch for the outcome. |
record.corrected | recordId, disputeId, outcome (verified, modified, deleted, unable_to_verify) | A record you were furnished changed; sent to every partner that received it, always to the account endpoint. |
delivery.status on GET /v1/fcra/events is pending, delivered, failed or no_endpoint; lastStatus is your HTTP status, 0 for a refused host or transport failure.
Edge cases and pitfalls
- Verify over the raw bytes. The body is compact JSON with sorted keys; re-serialising a parsed object will not reproduce it. Read the raw request body before any JSON middleware.
- `nextAfter` never signals the end. It is the last returned id (or your
afterechoed on an empty page); stop wheneventsis empty. An unknown or foreignafterid is ignored and the list starts from the beginning. - Retries: immediately, then every minute for the first ten attempts, then hourly, until 24 hours after the event; then
failed. Each attempt waits 10 seconds. Redirects are not followed. - Events can arrive out of order. Read the object’s current status rather than inferring it from the sequence.
- `no_endpoint` — events are still logged when no endpoint is configured; set one and
resendwhat matters. - Rotating the secret (
rotateSecret: true) invalidates signatures made with the old one at once; deploy the new secret to your receiver first, then rotate. - `record.corrected` ignores `callbackUrl` — it always goes to the account endpoint, because it may concern an order placed long ago.
Production checklist
- Register the endpoint with an admin credential; store the secret in your secret manager at creation.
- Verify signature and freshness; idempotency on event
id; respond2xxfast. - A scheduled catch-up over
GET /v1/fcra/eventswith a persistedaftercursor. - Alert on
delivery.status: "failed"and onno_endpoint. - Treat
dataas pointers; fetch the object before acting.