Understanding alerts
One alert per person per registry, with a stable id that carries the latest event. Read it in order and de-duplicate on it.
What you’ll build
A correct consumer of monitoring alerts: code that reads eventType before anything else, keys its work on the alert id, distinguishes the ten-alert recentAlerts preview from the full history, and treats GET /v1/monitors/{monitorId}/alerts as the system of record whatever happened to a delivery.
The model behind it: a monitor keeps one alert per person per registry (or per source on a criminal monitor). When a fact about that person changes or they stop matching, the same alert — same id — takes the new eventType and is delivered again. Nothing is keyed on the listing number.
Before you start
- A sandbox monitor with at least one alert — Exact Match (1980-01-01) files one on creation. To file another on demand, the sandbox offers
POST /v1/sandbox/monitors/{monitorId}/test-event. - Two read endpoints:
GET /v1/monitors/{monitorId}returns the monitor with its ten most recent alerts inrecentAlerts;GET /v1/monitors/{monitorId}/alerts?limit=returns the history, most recently observed first,limitdefault 50, clamped to 1–200. - Three event values:
new,changed,removed. Five match labels:dob_match,year_match,age_match,name_match,in_radius(andnullon aremovedevent).
changed or removed event does not create a new alert. It updates the existing alert’s eventType, resets notifiedAt to null, and is delivered again with the same alertId. De-duplicate deliveries on alertId, and treat (alertId, event) as the unit of work.Step by step
- Read the preview.
GET /v1/monitors/{monitorId}—recentAlertsis the last ten, enough for a dashboard card. Do not page on it; there is no more. - Read the history.
GET /v1/monitors/{monitorId}/alerts?limit=200for the full picture. There is no cursor; 200 is the cap per monitor per call. - File a second event (sandbox).
POST /v1/sandbox/monitors/{monitorId}/test-eventfiles one morenewalert and POSTs it to the monitor’s webhook now; the response carriesalertandwebhookDelivered. Sandbox only;409on a canceled monitor. - Reconcile. For every alert, upsert your own row keyed on
id; set its state fromeventType; recordnotifiedAtto know whether a delivery reached you.
curl "https://sandbox.offendersearch.app/v1/monitors/mon_b8f40d7afb9dc0d8/alerts?limit=200" \
-H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"{
"alerts": [
{
"id": "hit_5f91357bf82b5637",
"eventType": "removed",
"matchLabel": null,
"record": { "event": "removed", "jurisdiction": "TX", "personRef": "prs_8b7a6c5d4e3f20119283" },
"firstSeenAt": "2026-08-02T12:00:37.221004+00:00",
"occurredAt": "2026-09-14T12:00:52.904117+00:00",
"notifiedAt": null
},
{
"id": "hit_7fa176c8889c7569",
"eventType": "new",
"matchLabel": "dob_match",
"record": {
"name": "JORDAN RIVERA", "jurisdiction": "TX", "locality": "Houston",
"matchLabel": "dob_match", "matchConfidence": 1.0,
"personRef": "prs_3c9e1f0a7b2d4e6f8a1b", "recordId": "rec_ab12cd34ef56ab78cd90"
},
"firstSeenAt": "2026-09-06T12:00:41.118274+00:00",
"occurredAt": "2026-09-06T12:00:41.118274+00:00",
"notifiedAt": "2026-09-06T12:01:03.550091+00:00"
}
]
}The first alert is a person who was matched on 2 August and stopped matching on 14 September: eventType is now removed, matchLabel is null, record is sparse, and notifiedAt is null because the redelivery is pending. The second is a person still matching, delivered once.
import os, requests
BASE = "https://sandbox.offendersearch.app"
H = {"X-API-Key": os.environ["OFFENDERSEARCH_TEST_KEY"]}
def reconcile(monitor_id, store):
"""store: dict alertId -> {event, personRef, jurisdiction, notifiedAt}"""
alerts = requests.get(BASE + "/v1/monitors/" + monitor_id + "/alerts",
params={"limit": 200}, headers=H, timeout=30).json()["alerts"]
for a in alerts:
row = store.get(a["id"])
if row is None or row["event"] != a["eventType"]:
# new alert, or the same person moved to a new event — act once per (id, event)
handle(monitor_id, a)
store[a["id"]] = {
"event": a["eventType"],
"personRef": a["record"].get("personRef"),
"jurisdiction": a["record"].get("jurisdiction"),
"notifiedAt": a["notifiedAt"],
}
def handle(monitor_id, a):
if a["eventType"] == "removed":
print("no longer listed in", a["record"]["jurisdiction"], a["record"].get("personRef"))
else:
r = a["record"]
print(a["eventType"], a["matchLabel"], r["name"], r["jurisdiction"], r["personRef"])Reading the result
| Field | Values | What it decides |
|---|---|---|
id | hit_ + 16 hex | Stable per (monitor, person within registry or source). The webhook carries it as alertId. Your primary key. |
eventType | new, changed, removed | The latest event on this person. The history does not keep one row per event; your store should. |
matchLabel | dob_match, year_match, age_match, name_match, in_radius, null | How the match was made; null on removed. |
record | object | The matched record summarised: name, jurisdiction, locality (city only), matchLabel, matchConfidence, personRef, recordId (SO) or recordType / externalId / exclusion (criminal). On removed: event, jurisdiction, personRef. |
firstSeenAt | date-time | When this person first matched the monitor. |
occurredAt | date-time | When the current eventType was observed. |
notifiedAt | date-time or null | null until a delivery (email or webhook) succeeded for the current event; reset to null on every new event. |
Edge cases and pitfalls
- Counting events from the history undercounts. A person who went
new→changed→changedis one row witheventType: "changed". Keep your own event log from deliveries plus reconciliation. - `recentAlerts` is ten, full stop. A monitor with twelve people inside a radius shows ten on
GET /v1/monitors/{id}; read/alertsfor the rest. - `limit` is clamped, not rejected.
limit=1000returns 200;limit=0returns 1. There is no cursor beyond 200 per call. - A `removed` record is sparse by design — no
name, nolocality. Look up the person in your own store bypersonRef. - A redelivered alert looks like a duplicate — same
alertId, possibly a differentevent. Idempotency onalertIdalone would drop theremoved; key on (alertId,event). - Exclusion-list alerts on a criminal monitor carry
recordType: "healthcare_exclusion"and anexclusionblock (authority,convictionBased,excludedOn);new/changed/removedthen mean added to / updated on / removed from the list. - The sandbox `test-event` route does not exist on the live host; production has no on-demand evaluation.
Production checklist
- Upsert on
id; act once per (id,eventType). - Reconcile every monitor against
/alerts?limit=200on a schedule, not only on webhook receipt. - Surface
removedseparately — it is the quiet event people miss. - Store
personRefandjurisdiction; fetchrecordIdon demand for sex-offender records. - Alerts are not a consumer report and may not be used for any FCRA-covered decision.