Monitoring API · v1.0.0

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 in recentAlerts; GET /v1/monitors/{monitorId}/alerts?limit= returns the history, most recently observed first, limit default 50, clamped to 1–200.
  • Three event values: new, changed, removed. Five match labels: dob_match, year_match, age_match, name_match, in_radius (and null on a removed event).
The alert id is the person, not the event. A 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

  1. Read the preview. GET /v1/monitors/{monitorId} — recentAlerts is the last ten, enough for a dashboard card. Do not page on it; there is no more.
  2. Read the history. GET /v1/monitors/{monitorId}/alerts?limit=200 for the full picture. There is no cursor; 200 is the cap per monitor per call.
  3. File a second event (sandbox). POST /v1/sandbox/monitors/{monitorId}/test-event files one more new alert and POSTs it to the monitor’s webhook now; the response carries alert and webhookDelivered. Sandbox only; 409 on a canceled monitor.
  4. Reconcile. For every alert, upsert your own row keyed on id; set its state from eventType; record notifiedAt to know whether a delivery reached you.
The history, newest first
curl "https://sandbox.offendersearch.app/v1/monitors/mon_b8f40d7afb9dc0d8/alerts?limit=200" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
200 — a new alert and a later removed one
{
  "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

FieldValuesWhat it decides
idhit_ + 16 hexStable per (monitor, person within registry or source). The webhook carries it as alertId. Your primary key.
eventTypenew, changed, removedThe latest event on this person. The history does not keep one row per event; your store should.
matchLabeldob_match, year_match, age_match, name_match, in_radius, nullHow the match was made; null on removed.
recordobjectThe matched record summarised: name, jurisdiction, locality (city only), matchLabel, matchConfidence, personRef, recordId (SO) or recordType / externalId / exclusion (criminal). On removed: event, jurisdiction, personRef.
firstSeenAtdate-timeWhen this person first matched the monitor.
occurredAtdate-timeWhen the current eventType was observed.
notifiedAtdate-time or nullnull 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 → changed is one row with eventType: "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 /alerts for the rest.
  • `limit` is clamped, not rejected. limit=1000 returns 200; limit=0 returns 1. There is no cursor beyond 200 per call.
  • A `removed` record is sparse by design — no name, no locality. Look up the person in your own store by personRef.
  • A redelivered alert looks like a duplicate — same alertId, possibly a different event. Idempotency on alertId alone would drop the removed; key on (alertId, event).
  • Exclusion-list alerts on a criminal monitor carry recordType: "healthcare_exclusion" and an exclusion block (authority, convictionBased, excludedOn); new/changed/removed then 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=200 on a schedule, not only on webhook receipt.
  • Surface removed separately — it is the quiet event people miss.
  • Store personRef and jurisdiction; fetch recordId on demand for sex-offender records.
  • Alerts are not a consumer report and may not be used for any FCRA-covered decision.

Product overview