# FCRA webhooks

> Signed events carry identifiers and statuses, never personal data. Verify, then fetch the object with your key.

- **HTML:** https://offendersearch.app/docs/guides/fcra/webhooks
- **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 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*
```bash
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*
```json
{
  "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

**Python (Flask)**
```python
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
```

**JavaScript (Express)**
```javascript
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.OS_FCRA_WEBHOOK_SECRET;

function verify(rawBody, header) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", SECRET).update(parts.t + "." + rawBody).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && expected.length === parts.v1?.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

app.post("/hooks/offendersearch", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("X-Offendersearch-Signature") ?? "")) return res.sendStatus(401);
  const event = JSON.parse(req.body);                 // { id, type, createdAt, data }
  if (alreadySeen(event.id)) return res.sendStatus(200);
  switch (event.type) {
    case "order.completed":  enqueueFetchOrder(event.data.orderId); break;
    case "search.completed": enqueueFetchSearch(event.data.orderId, event.data.searchId); break;
    case "dispute.created":
    case "dispute.updated":  enqueueFetchDispute(event.data.disputeId); break;
    case "record.corrected": flagRecord(event.data.recordId, event.data.outcome); break;
  }
  res.sendStatus(200);                                // within 10 seconds
});
```

*A delivery body*
```json
{
  "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

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

## Related

- [Webhooks & events (reference)](https://offendersearch.app/docs/fcra/webhooks)
- [Error codes](https://offendersearch.app/docs/fcra/errors)
- [Async & webhooks (platform)](https://offendersearch.app/docs/platform/async-and-webhooks)
- [FCRA API reference](https://offendersearch.app/docs/fcra/api-reference)
