# 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.

- **HTML:** https://offendersearch.app/docs/guides/monitoring/alerts-and-events
- **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 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*
```bash
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*
```json
{
  "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.

**Python**
```python
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"])
```

**JavaScript**
```javascript
const BASE = "https://sandbox.offendersearch.app";
const headers = { "X-API-Key": process.env.OFFENDERSEARCH_TEST_KEY };

// store: Map<alertId, { event, personRef, jurisdiction, notifiedAt }>
async function reconcile(monitorId, store) {
  const res = await fetch(BASE + "/v1/monitors/" + monitorId + "/alerts?limit=200", { headers });
  const { alerts } = await res.json();
  for (const a of alerts) {
    const row = store.get(a.id);
    if (!row || row.event !== a.eventType) handle(monitorId, a);   // once per (id, event)
    store.set(a.id, {
      event: a.eventType,
      personRef: a.record.personRef ?? null,
      jurisdiction: a.record.jurisdiction,
      notifiedAt: a.notifiedAt,
    });
  }
}

function handle(monitorId, a) {
  if (a.eventType === "removed") {
    console.log("no longer listed in", a.record.jurisdiction, a.record.personRef);
  } else {
    console.log(a.eventType, a.matchLabel, a.record.name, a.record.jurisdiction, a.record.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` → `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.

## Related

- [Alerts (reference)](https://offendersearch.app/docs/monitoring/alerts)
- [Endpoints](https://offendersearch.app/docs/monitoring/endpoints)
- [Monitoring enums](https://offendersearch.app/docs/monitoring/enums)
- [Webhooks, duplicates & cancelling](https://offendersearch.app/docs/guides/monitoring/webhooks-and-lifecycle)
