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.
Updated November 3, 2026
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 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, which covers the same signature for FCRA events; the background check API overview places monitoring beside the search endpoints.
Python and Node.js SDKs ship with the first registry release; see SDKs. Until then the raw HTTP calls below are the integration. A test key is free from the dashboard, nothing is billed on the 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.
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 -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"
}
}'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.
{
"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 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:
{
"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 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.
// 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);# 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 page has a known-answer test vector so you can prove the function before a real alert arrives, and the webhook tester checks any header you received in the browser.
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.
# 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 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.
# 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 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.
- Expose your receiver on a public HTTPS URL (an ngrok tunnel to your laptop is fine), or point
webhookUrlat a webhook.site address to see the raw delivery first. - Create the monitor with the request above and store
channels.webhookSecret. - Watch the alert arrive, verify it, and confirm your handler answers
200within 10 seconds. - Read
GET /v1/monitors/{monitorId}/alertsand check that the one alert there hasnotifiedAtset. - Send the alert again yourself, signed with the same secret, and confirm the duplicate is acknowledged without a second job.
The 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
- Build the receiver first. Add an HTTPS route that reads the raw body, verifies
X-Offendersearch-Signaturewith the monitor's secret inside a five-minute window, deduplicates onalertIdandevent, and answers200within 10 seconds. - Create the monitor.
POST /v1/monitorswithproduct,type: "person",person(lastNamerequired,firstName,dob),minConfidence,labelandchannels.webhookUrl(plusemailif you want mail too). On the sandbox use Exact Match, 1980-01-01. - Store the secret from the 201.
channels.webhookSecretappears once. Save it keyed by the monitorid; the receiver looks it up by themonitorIdin each alert. - Handle the alert off the request thread. Queue the verified alert and act on it later: fetch the record by
detail.recordIdfor a registry monitor, or matchdetail.personRefandexternalIdfor a criminal monitor. - Reconcile on a schedule. Walk
GET /v1/monitors, read eachGET /v1/monitors/{monitorId}/alerts, and process any(id, eventType)pair your table does not have. - Add the second product if you need it. A second
POST /v1/monitorswithproduct: "criminal"watches the same person in criminal records; it is a separate monitor with its own secret. - Rotate and retire.
POST /v1/monitors/{monitorId}/webhook-secretwhen 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. Consult counsel.
What this page does not cover
- Location monitors (a circle around an address, $10 per location monitor): the location monitoring reference.
- 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 page.
- Running a one-off search instead of a standing watch: the background check API tutorial.