FCRA Partner API · v1.0.0

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.app and a public https URL. The URL must be on a public host (1–2,000 characters); private hosts are a 422.
  • 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 403 with a plain-string detail.
  • Seven event types: order.created, search.completed, order.completed, order.cancelled, dispute.created, dispute.updated, record.corrected. data carries ids and statuses only.
  • An order’s optional callbackUrl receives that order’s events instead of the account endpoint, signed with the same secret; record.corrected always 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

  1. Register the endpoint. PUT /v1/fcra/webhook-endpoint with { "url": "https://…" }. Save secret from the response.
  2. Verify each delivery. Header X-Offendersearch-Signature: t=<unix>,v1=<hex>. Recompute HMAC-SHA256 with your secret over t + . + the raw body; compare in constant time; reject when t is more than five minutes old. X-Offendersearch-Event and X-Offendersearch-Event-Id are also sent.
  3. Act on the type, fetch the object. Switch on type; use data.orderId / searchId / disputeId / recordId to fetch the current object with your key. Respond 2xx within 10 seconds.
  4. Catch up. GET /v1/fcra/events?after=<last id>&limit=500 on a schedule; stop when events is empty. POST /v1/fcra/events/{event_id}/resend re-delivers one.
Register
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" }'
200 — created; secret shown this once
{
  "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
A delivery body
{
  "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"
200 — oldest first, with delivery state
{
  "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.createdorderId, status: "received"Confirm the order is in flight.
search.completedorderId, searchId, type, status (complete, unavailable, or error with no result), resultFetch the search; an error status has no result — read it, do not infer clear.
order.completedorderId, status: "complete" or "error"Fetch the order and the report.
order.cancelledorderId, status: "cancelled"Close it on your side.
dispute.createddisputeId, orderId, origin (partner, consumer_portal), status: "received"A dispute opened by you or in the consumer portal.
dispute.updateddisputeId, orderId, statusMoved to investigating, extended, or resolved. Fetch for the outcome.
record.correctedrecordId, 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 after echoed on an empty page); stop when events is empty. An unknown or foreign after id 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 resend what 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; respond 2xx fast.
  • A scheduled catch-up over GET /v1/fcra/events with a persisted after cursor.
  • Alert on delivery.status: "failed" and on no_endpoint.
  • Treat data as pointers; fetch the object before acting.

Product overview