# Person monitoring

> A person monitor follows an identity, not a listing. Get the criteria right and the alert stream stays quiet until something real happens.

- **HTML:** https://offendersearch.app/docs/guides/monitoring/person-monitoring
- **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 watch on one named person in one dataset — the sex-offender registries or the criminal records the platform holds — that alerts when a matching record **appears** (`new`), when a fact about a matched person **changes** (`changed`), or when the person is **no longer listed** in that registry or source (`removed`). You will create the monitor with a confidence floor, read the alerts it files, and learn which field to key your own records on.

Typical operators: a compliance team watching a roster of caregivers, a housing platform watching admitted residents, a marketplace watching its providers. Each person on the list is one monitor per dataset.

## Before you start

- A test key against `https://sandbox.offendersearch.app`. Sandbox people for this guide: **Exact Match** (1980-01-01, one `dob_match` alert in `OH`), **Multi State** (1972-03-14, two alerts — `FL` and `GA`), **Name Only** (three `name_match` alerts), **Felony Record** (1975-06-15, a criminal monitor alert with `externalId` `SBX-CR-2015-000123`).
- The `person` object: `lastName` is required; `firstName` and `dob` (`YYYY-MM-DD`) are optional but a `dob` is what keeps the monitor precise.
- Decide which dataset you are watching. `product: "sex-offender"` and `product: "criminal"` are two monitors, even for the same person.

> **The date of birth is checked for shape only.** `person.dob` must match `YYYY-MM-DD` or the call is a `422`. It is **not** checked as a calendar date, so `1988-13-45` is accepted and the monitor then runs as if no date had been sent. Validate the date yourself before you send it.

## Step by step

1. **Pick the floor.** `minConfidence` is the weakest match the monitor alerts on: `dob_match` (full date agrees), `year_match` (birth year agrees — the default with a `dob`), `age_match` (a published age is consistent), `name_match` (name alone — the default without a `dob`). `any` means "the default".
2. **Create the monitor.** `POST /v1/monitors` with `product`, `type: "person"`, `person`, `minConfidence` and `channels`.
3. **Read the alerts.** `GET /v1/monitors/{monitorId}/alerts`. Each alert is one person in one registry; `record.personRef` is the key.
4. **Store the key.** Save `record.personRef` with `record.jurisdiction` against your own person record. Treat `record.recordId` as a pointer to the listing at that moment, not as the person’s identity.

### Create a person monitor with a dob_match floor

**cURL**
```bash
curl -X POST https://sandbox.offendersearch.app/v1/monitors \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "sex-offender",
    "type": "person",
    "person": { "firstName": "Multi", "lastName": "State", "dob": "1972-03-14" },
    "minConfidence": "dob_match",
    "label": "roster-00412",
    "channels": { "email": "alerts@your-app.example.com" }
  }'
```

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

BASE = "https://sandbox.offendersearch.app"
H = {"X-API-Key": os.environ["OFFENDERSEARCH_TEST_KEY"]}

res = requests.post(BASE + "/v1/monitors", headers=H, timeout=30, json={
    "product": "sex-offender",
    "type": "person",
    "person": {"firstName": "Multi", "lastName": "State", "dob": "1972-03-14"},
    "minConfidence": "dob_match",
    "label": "roster-00412",
    "channels": {"email": "alerts@your-app.example.com"},
})
res.raise_for_status()
monitor_id = res.json()["id"]

alerts = requests.get(BASE + "/v1/monitors/" + monitor_id + "/alerts",
                      headers=H, timeout=30).json()["alerts"]
for a in alerts:
    r = a["record"]
    print(a["eventType"], a["matchLabel"], r["jurisdiction"], r["personRef"])
```

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

const created = await fetch(BASE + "/v1/monitors", {
  method: "POST", headers,
  body: JSON.stringify({
    product: "sex-offender",
    type: "person",
    person: { firstName: "Multi", lastName: "State", dob: "1972-03-14" },
    minConfidence: "dob_match",
    label: "roster-00412",
    channels: { email: "alerts@your-app.example.com" },
  }),
});
const monitor = await created.json();

const { alerts } = await (await fetch(BASE + "/v1/monitors/" + monitor.id + "/alerts", { headers })).json();
for (const a of alerts) {
  console.log(a.eventType, a.matchLabel, a.record.jurisdiction, a.record.personRef);
}
```

*GET /v1/monitors/{monitorId}/alerts — one alert per registry that lists the person*
```json
{
  "alerts": [
    {
      "id": "hit_0d3e9b7c51a2f684",
      "eventType": "new",
      "matchLabel": "dob_match",
      "record": {
        "name": "MULTI STATE", "jurisdiction": "GA", "locality": "Sampleville",
        "matchLabel": "dob_match", "matchConfidence": 1.0,
        "personRef": "prs_9a1b2c3d4e5f60718293", "recordId": "rec_7f8e9d0c1b2a39485766"
      },
      "firstSeenAt": "2026-10-25T14:10:02.118930+00:00",
      "occurredAt": "2026-10-25T14:10:02.118930+00:00",
      "notifiedAt": null
    },
    {
      "id": "hit_6c2a8f1e94d7b035",
      "eventType": "new",
      "matchLabel": "dob_match",
      "record": {
        "name": "MULTI STATE", "jurisdiction": "FL", "locality": "Sampleville",
        "matchLabel": "dob_match", "matchConfidence": 1.0,
        "personRef": "prs_4e5f60718293a1b2c3d4", "recordId": "rec_1b2a39485766f7e8d9c0"
      },
      "firstSeenAt": "2026-10-25T14:10:02.118930+00:00",
      "occurredAt": "2026-10-25T14:10:02.118930+00:00",
      "notifiedAt": null
    }
  ],
  "sandbox": true
}
```

Two alerts, two `id`s, two `personRef`s: the person is listed in two registries, and each registry is its own thread. `notifiedAt` is `null` here because the sandbox sends no email and this monitor has no webhook — the alerts are still filed and readable.

### A criminal person monitor

Same request with `product: "criminal"`. The alert record then carries `externalId` (the record’s own id at its source, the same value the Criminal Records API returns), `recordType` (`court_case`, `doc_inmate`, `jail_booking`, `healthcare_exclusion`, …) and, for an exclusion-list record, an `exclusion` block; `recordId` is `null`.

*Criminal alert record (sandbox Felony Record, 1975-06-15)*
```json
{
  "name": "FELONY RECORD",
  "jurisdiction": "OH",
  "locality": "Sampleville",
  "matchLabel": "dob_match",
  "matchConfidence": 0.98,
  "personRef": "prs_6d0c2a9e4b1f7e3a8c5d",
  "recordId": null,
  "recordType": "court_case",
  "externalId": "SBX-CR-2015-000123"
}
```

## Reading the result

| Field | Values | What it means for a person watch |
| --- | --- | --- |
| `eventType` | `new` | The person newly matches: they were not matching at the previous evaluation. Everyone who already matches is `new` on the first evaluation. |
| `eventType` | `changed` | A fact about an already-matched person changed — an address, an offense field, a status, or on a criminal monitor a new booking. A registry republishing the listing under a new number is **not** a change. |
| `eventType` | `removed` | The person is no longer listed in that registry or source. Reported once; `matchLabel` is `null` and `record` carries only `event`, `jurisdiction` and `personRef`. |
| `matchLabel` | `dob_match`, `year_match`, `age_match`, `name_match` | Which strength actually fired. Under any floor you can tell a full-date confirmation from a year-only or age-only one. |
| `record.personRef` | `prs_…` | The person within the publishing registry or source — the same value the matching record carries in search results. Stable across renumbering. Your reconciliation key. |
| `record.recordId` | `rec_…` or `null` | Sex-offender monitors only: the matching record at the time of the event, for `GET /v1/records/{recordId}`. `null` on a criminal monitor. |
| `record.externalId` | source id or `null` | Criminal monitors: the record’s id at its source. `null` for registry records. |

## Edge cases and pitfalls

- **Common names without a `dob`** flood the stream: every same-named record alerts at `name_match`. Supply a `dob`; if you truly cannot, plan to triage the volume.
- **A contradicting `dob`** is silent — the monitor is created and never alerts on that person. Check the `dob` you hold before blaming coverage.
- **`minConfidence: "dob_match"` hides year-only records.** A registry that publishes only a birth year can never reach `dob_match`; use `year_match` (the default) if that matters to you.
- **One person, several registries** is several alerts with different `personRef`s. Reconcile on (`jurisdiction`, `personRef`), not on the name.
- **A renumbered listing is not an event.** No `new`, no `changed`, no `removed`; the same alert `id` and `personRef` continue. Do not key on `recordId`.
- **Exclusion lists on a criminal monitor**: only conviction-based exclusions alert, and list entries that publish no date of birth (most SAM.gov and state entries) never alert, because a person monitor is keyed on identity.
- **`minConfidence: "any"`** is stored and echoed but behaves as "unset"; an unknown value is a `422`.

## Production checklist

- Validate `dob` as a real calendar date before sending; always send it when you have it.
- Create one monitor per dataset you need — `sex-offender`, `criminal`, or both — and store each `mon_` id.
- Persist `record.personRef` + `record.jurisdiction` per alert; look up `recordId` only to show the listing.
- Route `removed` events to a human: disappearance is data, and the alert record is deliberately sparse.
- Alerts are not a consumer report and may not be used for employment, tenant, credit or insurance decisions.

## Related

- [Person monitoring (reference)](https://offendersearch.app/docs/monitoring/person-monitoring)
- [Alerts](https://offendersearch.app/docs/monitoring/alerts)
- [The Record object](https://offendersearch.app/docs/record-object)
- [Criminal record object](https://offendersearch.app/docs/criminal/record-object)
