# Monitor a person for new criminal records with webhooks

> Create a standing watch on one person with `POST /v1/monitors`, receive a signed `monitor.alert` the moment a record appears, changes or is removed, verify it in Express or FastAPI, deduplicate it, and reconcile against the alert history.

- **HTML:** https://offendersearch.app/docs/learn/monitor-a-person-with-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 a person monitor with webhooks does

A search answers once. A monitor keeps asking. `POST /v1/monitors` creates a standing watch on one person in one product, either the 58 US registries (`product: "sex-offender"`) or criminal records (`product: "criminal"`), and the [Monitoring API](/sex-offender-monitoring) evaluates it continuously. When a matching record is first seen, changes, or disappears, the platform sends a `monitor.alert` to your `channels.webhookUrl`, signed with a secret that only you and the platform hold, and e-mails any addresses you listed.

This tutorial builds the whole loop: the request that creates the monitor, the exact payload that arrives, two receivers that verify it on the raw body, deduplication on `alertId`, the alert history you reconcile against when a delivery is missed, secret rotation, and the sandbox rehearsal. It is modelled on the [webhook example](/docs/learn/webhook-example), which covers the same signature for FCRA events; the [background check API](/background-check-api) overview places monitoring beside the search endpoints.

Python and Node.js SDKs ship with the first registry release; see [SDKs](/sdk). Until then the raw HTTP calls below are the integration. A test key is free from the dashboard, nothing is billed on the [sandbox](/sandbox), and the sandbox allows 60 calls a minute and 2,000 a day per key. A person monitor costs $3 a month per person monitor, a location monitor $10 per location monitor.

> **Monitoring is not a consumer report.** An alert tells you that a record appeared. It is not a report about the person, and the response carries no permissible purpose. If an alert is going to drive an employment, housing, credit or insurance action, that action is an FCRA workflow: open an order on the [FCRA Partner API](/fcra-api), where the same record arrives with consent, a report and notices. Ask your counsel which applies to you.

## Monitor a person: create the monitor with POST /v1/monitors

The body is one product, one predicate and at least one channel. `product` is `sex-offender` or `criminal`; `type` is `person` here (`location` watches a circle instead); `person.lastName` is required, `firstName` and a `YYYY-MM-DD` `dob` are optional but decide how strong a match can be. `minConfidence` is the weakest match the monitor alerts on: `dob_match`, `year_match`, `age_match`, `name_match` or `any`; unset, it is `year_match` when a `dob` was sent and `name_match` when it was not. `label` is echoed back on every alert. `channels` takes `webhookUrl`, `email` and `emails[]`.

**curl**
```bash
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "sex-offender",
    "type": "person",
    "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
    "minConfidence": "dob_match",
    "label": "Exact Match — sandbox watch",
    "channels": {
      "webhookUrl": "https://hooks.example.com/hooks/offendersearch",
      "email": "compliance@example.com"
    }
  }'
```

**Python**
```python
import os, requests

r = requests.post(
    "https://sandbox.offendersearch.app/v1/monitors",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_API_KEY"]},   # os_test_… from the dashboard
    json={
        "product": "sex-offender",
        "type": "person",
        "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
        "minConfidence": "dob_match",
        "label": "Exact Match — sandbox watch",
        "channels": {
            "webhookUrl": "https://hooks.example.com/hooks/offendersearch",
            "email": "compliance@example.com",
        },
    },
    timeout=30,
)
r.raise_for_status()                      # 201
monitor = r.json()
print(monitor["id"], monitor["status"])   # mon_…  active

# The signing secret is in THIS response and never again. Store it with your other credentials.
store_secret(monitor["id"], monitor["channels"]["webhookSecret"])
```

**Node**
```javascript
// Node 18+ — fetch is built in
const res = await fetch("https://sandbox.offendersearch.app/v1/monitors", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_API_KEY,   // os_test_… from the dashboard
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    product: "sex-offender",
    type: "person",
    person: { firstName: "Exact", lastName: "Match", dob: "1980-01-01" },
    minConfidence: "dob_match",
    label: "Exact Match — sandbox watch",
    channels: {
      webhookUrl: "https://hooks.example.com/hooks/offendersearch",
      email: "compliance@example.com",
    },
  }),
});
if (res.status !== 201) throw new Error(`HTTP ${res.status}`);
const monitor = await res.json();
console.log(monitor.id, monitor.status);                       // mon_…  active

// The signing secret is in THIS response and never again. Store it with your other credentials.
await storeSecret(monitor.id, monitor.channels.webhookSecret);
```

The answer is `201` with the monitor object. `channels.webhookSecret` is on **this response only**; `GET /v1/monitors` and `GET /v1/monitors/{monitorId}` never show it again. Store it beside your other credentials, keyed by `id`, because every delivery to this monitor's URL is signed with it.

*201 Created*
```json
{
  "id": "mon_b8f40d7afb9dc0d8",
  "product": "sex-offender",
  "type": "person",
  "minConfidence": "dob_match",
  "label": "Exact Match — sandbox watch",
  "status": "active",
  "channels": {
    "emails": ["compliance@example.com"],
    "webhookUrl": "https://hooks.example.com/hooks/offendersearch",
    "webhookSecret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef"
  },
  "createdAt": "2026-11-03T14:05:33+00:00",
  "canceledAt": null,
  "person": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" }
}
```

One combination is refused politely: `product: "criminal"` with `type: "location"` answers `200 { "status": "not_yet_available" }` and creates nothing. The [monitor object reference](/docs/monitoring/monitor-object) lists every key.

## The monitor.alert webhook payload

Every alert is one HTTP `POST` to `channels.webhookUrl` with `Content-Type: application/json`, `User-Agent: Offendersearch-Monitor/1` and the `X-Offendersearch-Signature` header. The body is the same for registry and criminal monitors:

*monitor.alert (the sandbox alert for Exact Match)*
```json
{
  "type": "monitor.alert",
  "event": "new",
  "alertId": "hit_7fa176c8889c7569",
  "monitorId": "mon_b8f40d7afb9dc0d8",
  "monitorLabel": "Exact Match — sandbox watch",
  "product": "sex-offender",
  "detail": {
    "name": "EXACT MATCH",
    "jurisdiction": "OH",
    "locality": "Sampleville",
    "matchLabel": "dob_match",
    "matchConfidence": 1.0,
    "personRef": "prs_3c9e1f0a7b2d4e6f8a1b",
    "recordId": "rec_ab12cd34ef56gh78ij90"
  },
  "occurredAt": "2026-11-03T14:05:34+00:00",
  "sentAt": "2026-11-03T14:05:35+00:00"
}
```

| Key | Meaning |
| --- | --- |
| `type` | Always `monitor.alert`. |
| `event` | `new` (first seen), `changed` (the listing changed), `removed` (no longer published). On `removed`, `detail` carries only `event` and `jurisdiction`, plus the `personRef` so you can match the removal to the person. |
| `alertId` | `hit_` + 16 hex. One alert per monitor and person per registry or source; a later change to the same person **updates the same alert** with a new `event`. This is your deduplication key, together with `event`. |
| `monitorId`, `monitorLabel`, `product` | Which watch fired, your label, and `sex-offender` or `criminal`. |
| `detail` | `name`, `jurisdiction`, `locality` (city only, never a street address), `matchLabel`, `matchConfidence`, `personRef`, `recordId`. `recordId` is set on registry monitors, for `GET /v1/records/{recordId}`, and `null` on criminal monitors, which carry `recordType` and `externalId` instead. |
| `occurredAt`, `sentAt` | When the change was observed and when this delivery left. |

The [alerts reference](/docs/monitoring/alerts) explains the events and labels in full.

## Verify the monitor webhook signature in Express and FastAPI

The header is `t=<unix seconds>,v1=<hex>`, and `v1` is `HMAC-SHA256(secret, "<t>." + raw body)`. Three rules, in order: hash the **raw bytes** you received, not an object you rebuilt; use the timestamp **from the header** and refuse one more than five minutes from your clock, which is the replay window; compare in **constant time**. One difference from the FCRA receiver: the secret is per monitor, so the receiver peeks at `monitorId` to pick the secret and only then trusts the rest of the body.

*Node (Express)*
```javascript
// npm i express
import express from "express";
import crypto from "node:crypto";

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" }), async (req, res) => {
  // 2. Parse the header: "t=<unix seconds>,v1=<hex>"
  const header = String(req.get("X-Offendersearch-Signature") ?? "");
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (!parts.t || !parts.v1) return res.sendStatus(401);

  // 3. Five-minute replay window, measured against the header's own timestamp
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.sendStatus(401);

  // 4. Peek at monitorId to pick the secret (one secret per monitor), then verify on the raw bytes
  const monitorId = JSON.parse(req.body).monitorId;
  const secret = await loadSecret(monitorId);                    // whsec_… from the 201
  if (!secret) return res.sendStatus(401);
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(req.body).digest("hex");
  const ok = parts.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  // 5. Now the body is trusted. Deduplicate on alertId; deliveries are at-least-once.
  const alert = JSON.parse(req.body);
  if (await alreadySeen(alert.alertId, alert.event)) return res.sendStatus(200);
  await markSeen(alert.alertId, alert.event);

  // 6. Acknowledge fast, work later.
  queue.push(alert);                                             // your job queue
  res.sendStatus(200);
});

app.listen(3000);
```

*Python (FastAPI)*
```python
# pip install fastapi uvicorn
import hmac, hashlib, json, time
from fastapi import FastAPI, Request, Response

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. "t=<unix seconds>,v1=<hex>"
        parts = dict(p.split("=", 1) for p in header.split(","))
        t = int(parts["t"])
        v1 = parts["v1"]
    except Exception:
        return Response(status_code=401)
    if abs(time.time() - t) > 300:                   # 3. five-minute replay window
        return Response(status_code=401)

    monitor_id = json.loads(body).get("monitorId")   # 4. one secret per monitor
    secret = load_secret(monitor_id)                 #    whsec_… from the 201
    if not secret:
        return Response(status_code=401)
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):        #    constant time
        return Response(status_code=401)

    alert = json.loads(body)                         # 5. trusted now; dedupe on alertId + event
    if already_seen(alert["alertId"], alert["event"]):
        return Response(status_code=200)
    mark_seen(alert["alertId"], alert["event"])

    queue.enqueue(alert)                             # 6. acknowledge fast, work later
    return Response(status_code=200)
```

Flask users: `request.get_data()` is the raw body. Django: `request.body`. Go: `io.ReadAll(r.Body)`. The [API signature](/docs/learn/api-signature) page has a known-answer test vector so you can prove the function before a real alert arrives, and the [webhook tester](/tools/webhook-tester) checks any header you received in the browser.

> **Verify, then act. Never the other way round..** A body you acted on before verifying is a body anyone on the internet could have sent to your URL. Answer `401` to a bad signature and log nothing but the failure.

## Deduplicate alerts and reconcile against the monitor's alert history

Delivery is at-least-once. A `200` lost on the network looks to the sender like no `200`, and a failed delivery is retried on the next evaluation. So the receiver keeps a table of `(alertId, event)` pairs it has processed: if the pair is there, answer `200` and stop; if not, store it, enqueue the work, then answer `200`. Deduplicate on the pair rather than `alertId` alone, because the same `alertId` legitimately arrives again when the person's listing goes from `new` to `changed` to `removed`.

When you suspect a miss, during an outage, after a deploy, or on a schedule, read the monitor's history. `GET /v1/monitors/{monitorId}/alerts` returns the same alerts the webhook carried, newest first, with `notifiedAt` set when a delivery succeeded and `null` when it did not. That list is the record; the webhook is the notification.

```bash
# The alert history is the record. Newest first; limit is clamped to 1–200.
curl "https://sandbox.offendersearch.app/v1/monitors/mon_b8f40d7afb9dc0d8/alerts?limit=200" \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY"

# 200
{
  "alerts": [
    {
      "id": "hit_7fa176c8889c7569",
      "eventType": "new",
      "matchLabel": "dob_match",
      "record": {
        "name": "EXACT MATCH", "jurisdiction": "OH", "locality": "Sampleville",
        "matchLabel": "dob_match", "matchConfidence": 1.0,
        "personRef": "prs_3c9e1f0a7b2d4e6f8a1b", "recordId": "rec_ab12cd34ef56gh78ij90"
      },
      "firstSeenAt": "2026-11-03T14:05:34+00:00",
      "occurredAt": "2026-11-03T14:05:34+00:00",
      "notifiedAt": "2026-11-03T14:05:35+00:00"
    }
  ]
}
```

A reconciliation job that walks every active monitor from `GET /v1/monitors`, reads its alerts, and processes any `(id, eventType)` pair missing from your table closes the gap without a resend endpoint. The [delivery reference](/docs/monitoring/delivery) describes the retry behaviour.

## One monitor per product, pricing, and rotating the webhook secret

A monitor watches one product. To watch the same person in the 58 US registries **and** in criminal records, create two monitors: one with `product: "sex-offender"` and one with `product: "criminal"`. Each has its own `id`, its own `webhookSecret` and its own alert history, and each costs $3 a month per person monitor. They can share a `webhookUrl`; the receiver above picks the secret by `monitorId`, so one route serves both.

Rotate the secret when it may have leaked, when a person who held it leaves, or on your own schedule. `POST /v1/monitors/{monitorId}/webhook-secret` issues a new `whsec_…`, returned in that response only; the old one stops verifying at once, so update your store before the next evaluation. A monitor without a `webhookUrl` has nothing to sign and the rotate call is `422`.

```bash
# Rotate the signing secret: the old one stops verifying at once; the new one is in this response only
curl -X POST https://sandbox.offendersearch.app/v1/monitors/mon_b8f40d7afb9dc0d8/webhook-secret \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY"
# 200 … "channels": { "webhookUrl": "…", "webhookSecret": "whsec_<new>" }

# List, read (with recentAlerts), and stop a monitor
curl https://sandbox.offendersearch.app/v1/monitors -H "X-API-Key: $OFFENDERSEARCH_API_KEY"
curl https://sandbox.offendersearch.app/v1/monitors/mon_b8f40d7afb9dc0d8 -H "X-API-Key: $OFFENDERSEARCH_API_KEY"
curl -X DELETE https://sandbox.offendersearch.app/v1/monitors/mon_b8f40d7afb9dc0d8 -H "X-API-Key: $OFFENDERSEARCH_API_KEY"

# The same person in criminal records is a SECOND monitor
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"product": "criminal", "type": "person",
       "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
       "label": "Exact Match — criminal watch",
       "channels": {"webhookUrl": "https://hooks.example.com/hooks/offendersearch"}}'
```

`DELETE /v1/monitors/{monitorId}` stops evaluation; the monitor's alerts stay readable. The [person monitoring guide](/docs/guides/monitoring/person-monitoring) covers choosing `minConfidence` for a name with or without a date of birth.

## Test the person monitor on the sandbox

On `https://sandbox.offendersearch.app` with an `os_test_` key, the magic names drive monitors exactly as they drive searches. A person monitor for **Exact Match**, `dob: "1980-01-01"`, `product: "sex-offender"`, matches the sandbox's one DOB-verified registrant in OH and fires **one `new` alert at once**, delivered to your `webhookUrl` right after the `201` and signed with the secret the `201` returned. Any other name, including Jordan Rivera, 1988-04-12, creates a monitor with no alerts. The sandbox sends no e-mail; `notifiedAt` on the alert records the webhook delivery.

1. Expose your receiver on a public HTTPS URL (an ngrok tunnel to your laptop is fine), or point `webhookUrl` at a webhook.site address to see the raw delivery first.
2. Create the monitor with the request above and store `channels.webhookSecret`.
3. Watch the alert arrive, verify it, and confirm your handler answers `200` within 10 seconds.
4. Read `GET /v1/monitors/{monitorId}/alerts` and check that the one alert there has `notifiedAt` set.
5. Send the alert again yourself, signed with the same secret, and confirm the duplicate is acknowledged without a second job.

The [webhook tester](/tools/webhook-tester) signs a sample `monitor.alert` with a secret you choose and gives you the curl, so you can exercise the receiver before the monitor exists. Rely only on the documented behaviour above; the sandbox has no other way to fire an alert.

## Step by step: monitor a person with webhooks

1. **Build the receiver first.** Add an HTTPS route that reads the raw body, verifies `X-Offendersearch-Signature` with the monitor's secret inside a five-minute window, deduplicates on `alertId` and `event`, and answers `200` within 10 seconds.
2. **Create the monitor.** `POST /v1/monitors` with `product`, `type: "person"`, `person` (`lastName` required, `firstName`, `dob`), `minConfidence`, `label` and `channels.webhookUrl` (plus `email` if you want mail too). On the sandbox use Exact Match, 1980-01-01.
3. **Store the secret from the 201.** `channels.webhookSecret` appears once. Save it keyed by the monitor `id`; the receiver looks it up by the `monitorId` in each alert.
4. **Handle the alert off the request thread.** Queue the verified alert and act on it later: fetch the record by `detail.recordId` for a registry monitor, or match `detail.personRef` and `externalId` for a criminal monitor.
5. **Reconcile on a schedule.** Walk `GET /v1/monitors`, read each `GET /v1/monitors/{monitorId}/alerts`, and process any `(id, eventType)` pair your table does not have.
6. **Add the second product if you need it.** A second `POST /v1/monitors` with `product: "criminal"` watches the same person in criminal records; it is a separate monitor with its own secret.
7. **Rotate and retire.** `POST /v1/monitors/{monitorId}/webhook-secret` when a secret may have leaked; `DELETE /v1/monitors/{monitorId}` when the watch should end.

## Monitoring webhook questions developers ask

### How do I monitor a person for new criminal records?

Create a monitor with `product: "criminal"`, `type: "person"`, the person's name and date of birth, and a `channels.webhookUrl`. Every time a matching court case, booking, custody record, warrant or conviction-based exclusion is first seen, changes or is removed, a signed `monitor.alert` arrives.

### Can one monitor watch both the registries and criminal records?

No. A monitor watches one product. Create two monitors for the same person; they may share a webhook URL and each has its own secret and alert history.

### How often is a monitor evaluated?

Continuously, against the continuously updated dataset. You do not schedule anything; an alert is sent when the evaluation sees a change, and a failed delivery is tried again on the next evaluation.

### What do I deduplicate on?

The pair `(alertId, event)`. `alertId` is stable for one person on one monitor per registry or source, and the same id arrives again with a different `event` when the listing changes or is removed.

### What happens if my endpoint is down?

The delivery fails and is retried on the next evaluation. The alert is already in `GET /v1/monitors/{monitorId}/alerts` with `notifiedAt: null`, so a reconciliation job can pick it up without waiting.

### Can I act on an alert in a hiring decision?

An alert is not a consumer report. An employment or housing action on it is an FCRA workflow; approved partners run it as an order on the [FCRA Partner API](/fcra-api). Consult counsel.

## What this page does not cover

- Location monitors (a circle around an address, $10 per location monitor): the [location monitoring reference](/docs/monitoring/location-monitoring).
- FCRA continuous checks, which re-run a consented order on an interval rather than watching a dataset.
- The HMAC scheme in other languages and the known-answer test vector: the [API signature](/docs/learn/api-signature) page.
- Running a one-off search instead of a standing watch: the [background check API tutorial](/docs/learn/background-check-api-tutorial).

## Related

- [Webhook example: receive, verify and retry](https://offendersearch.app/docs/learn/webhook-example)
- [API signature: HMAC for requests and webhooks](https://offendersearch.app/docs/learn/api-signature)
- [Test a webhook (browser tool)](https://offendersearch.app/tools/webhook-tester)
- [Sex offender monitoring (product)](https://offendersearch.app/sex-offender-monitoring)
- [Monitoring quickstart](https://offendersearch.app/docs/monitoring/quickstart)
- [Person monitoring reference](https://offendersearch.app/docs/monitoring/person-monitoring)
- [Alerts reference](https://offendersearch.app/docs/monitoring/alerts)
- [Alert delivery](https://offendersearch.app/docs/monitoring/delivery)
- [Webhooks and the monitor lifecycle](https://offendersearch.app/docs/guides/monitoring/webhooks-and-lifecycle)
- [Background check API tutorial](https://offendersearch.app/docs/learn/background-check-api-tutorial)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
