# Creating your first monitor

> One POST creates a standing watch. This guide takes you from a test key to a delivered alert in four calls.

- **HTML:** https://offendersearch.app/docs/guides/monitoring/first-monitor
- **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 **monitor** watches one dataset for one predicate and tells you when the answer changes. You will create a sex-offender person monitor on the sandbox, receive its first alert at your webhook, and read that alert back from the API. The same calls, with a live key and the live host, create a production monitor that is evaluated once a day from then on.

This is the job for anyone who needs to know *after* a check, not only at the time of it: a resident screened on admission, a volunteer on a roster, a radius around a school. The result is a `Monitor` object with status `active` and, over time, a history of `MonitorAlert` objects.

## Before you start

- A test key (`os_test_…`) from [your dashboard](/dashboard/keys). Send it in `X-API-Key` against `https://sandbox.offendersearch.app`. A test key is refused on the live host with a `401` that names the sandbox.
- Monitoring is a product on your existing account, not a separate credential; on the live host it needs an active subscription or an active free trial (`402` otherwise).
- Sandbox names to use: **Exact Match** born **1980-01-01** (one `dob_match` alert, jurisdiction `OH`), **Clear Visitor** (no alert). The full list is on [the sandbox page](/docs/monitoring/sandbox).
- A public `https` URL that can receive a POST, if you want to see the webhook arrive. A request-inspection service or your staging endpoint both work.

> **One product, one predicate.** A monitor watches **one** `product` (`sex-offender` or `criminal`) with **one** `type` (`person` or `location`). Watching the same person in both datasets is two monitors and two charges. There is no combined monitor and no `products[]` array.

## Step by step

1. **Choose the product and the predicate.** `product: "sex-offender"` with `type: "person"` for this guide. `type: "person"` requires a `person` object with at least `lastName`; `type: "location"` requires a `location` object with `lat` + `lng` or a ZIP / "City, ST" `address`.
2. **Choose the channels.** `channels` must carry at least one email address (`email`, or several in `emails[]`) or a `webhookUrl`. Both at once is fine. A monitor with a `webhookUrl` returns `channels.webhookSecret` once on the `201` — store it; every delivery is signed with it.
3. **Create the monitor.** `POST /v1/monitors`. A `201` returns the `Monitor` object; it is `active` immediately.
4. **Receive the first alert.** On the sandbox the monitor is evaluated once, at creation, and every match is filed as a `new` alert and POSTed to your `webhookUrl` right away. In production the first evaluation runs in the next daily cycle and reports every current match as `new`.
5. **Read the alert back.** `GET /v1/monitors/{monitorId}` returns the monitor with its ten most recent alerts in `recentAlerts`. This is the record to reconcile against.

### Create the monitor

**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": "Exact", "lastName": "Match", "dob": "1980-01-01" },
    "label": "Exact Match — first monitor",
    "channels": {
      "email": "alerts@your-app.example.com",
      "webhookUrl": "https://your-app.example.com/hooks/offendersearch?token=YOUR_SECRET"
    }
  }'
```

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

res = requests.post(
    "https://sandbox.offendersearch.app/v1/monitors",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_TEST_KEY"]},
    json={
        "product": "sex-offender",
        "type": "person",
        "person": {"firstName": "Exact", "lastName": "Match", "dob": "1980-01-01"},
        "label": "Exact Match — first monitor",
        "channels": {
            "email": "alerts@your-app.example.com",
            "webhookUrl": "https://your-app.example.com/hooks/offendersearch?token=YOUR_SECRET",
        },
    },
    timeout=30,
)
res.raise_for_status()          # 201 Created
monitor = res.json()
print(monitor["id"], monitor["status"])
```

**JavaScript**
```javascript
const res = await fetch("https://sandbox.offendersearch.app/v1/monitors", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.OFFENDERSEARCH_TEST_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    product: "sex-offender",
    type: "person",
    person: { firstName: "Exact", lastName: "Match", dob: "1980-01-01" },
    label: "Exact Match — first monitor",
    channels: {
      email: "alerts@your-app.example.com",
      webhookUrl: "https://your-app.example.com/hooks/offendersearch?token=YOUR_SECRET",
    },
  }),
});
if (res.status !== 201) throw new Error(await res.text());
const monitor = await res.json();
console.log(monitor.id, monitor.status);
```

*201 Created*
```json
{
  "id": "mon_b8f40d7afb9dc0d8",
  "product": "sex-offender",
  "type": "person",
  "minConfidence": null,
  "label": "Exact Match — first monitor",
  "status": "active",
  "channels": {
    "emails": ["alerts@your-app.example.com"],
    "webhookUrl": "https://your-app.example.com/hooks/offendersearch?token=YOUR_SECRET"
  },
  "createdAt": "2026-10-25T14:02:11.418223+00:00",
  "canceledAt": null,
  "person": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" },
  "sandbox": true
}
```

Read `id` (`mon_` + 16 hex) and store it with the thing you are watching — it is the key for every later call. `channels.email` and `channels.emails` come back merged as `channels.emails`; `webhookUrl` is absent when you set none. `minConfidence` is `null` because you left it unset, which means the default floor (`year_match` with a `dob`). `sandbox: true` appears on every sandbox body.

### Receive the first alert

Within moments the sandbox POSTs this to your `webhookUrl`, exactly once, in the production payload. Read `event` first, then `detail.matchLabel`, then `detail`.

*POST to your webhookUrl — type monitor.alert*
```json
{
  "type": "monitor.alert",
  "event": "new",
  "alertId": "hit_7fa176c8889c7569",
  "monitorId": "mon_b8f40d7afb9dc0d8",
  "monitorLabel": "Exact Match — first monitor",
  "product": "sex-offender",
  "detail": {
    "name": "EXACT MATCH",
    "jurisdiction": "OH",
    "locality": "Sampleville",
    "matchLabel": "dob_match",
    "matchConfidence": 1.0,
    "personRef": "prs_3c9e1f0a7b2d4e6f8a1b",
    "recordId": "rec_ab12cd34ef56ab78cd90"
  },
  "occurredAt": "2026-10-25T14:02:11.602114+00:00",
  "sentAt": "2026-10-25T14:02:12.044901+00:00"
}
```

### Read the alert back

```bash
curl https://sandbox.offendersearch.app/v1/monitors/mon_b8f40d7afb9dc0d8 \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
```

*200 — the Monitor with recentAlerts*
```json
{
  "id": "mon_b8f40d7afb9dc0d8",
  "product": "sex-offender",
  "type": "person",
  "status": "active",
  "person": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" },
  "recentAlerts": [
    {
      "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_ab12cd34ef56ab78cd90"
      },
      "firstSeenAt": "2026-10-25T14:02:11.602114+00:00",
      "occurredAt": "2026-10-25T14:02:11.602114+00:00",
      "notifiedAt": "2026-10-25T14:02:12.044901+00:00"
    }
  ],
  "sandbox": true
}
```

The webhook’s `alertId` is the alert’s `id` here, and the webhook’s `detail` is the alert’s `record`. `notifiedAt` is set because the delivery succeeded.

## Reading the result

| Field | Values | What it decides |
| --- | --- | --- |
| HTTP status | `201`, `200`, `401`, `402`, `422` | `201` created a monitor. `200` is **only** the criminal + location acknowledgement (`status: "not_yet_available"`) — no monitor exists and nothing is billed. Never treat a `200` as success here. |
| `status` | `active`, `canceled` | Whether the monitor is being evaluated. A new monitor is always `active`. |
| `channels.emails`, `channels.webhookUrl` | addresses; `https` URL | Where alerts go. Confirm the webhook URL round-tripped with your token intact. |
| `recentAlerts[].eventType` | `new`, `changed`, `removed` | What happened to the person. The first evaluation reports every current match as `new`. |
| `recentAlerts[].matchLabel` | `dob_match`, `year_match`, `age_match`, `name_match`, `in_radius` | How strongly identity agreed. `dob_match` is a full-date agreement; `name_match` is the name alone. |
| `record.personRef` | `prs_` + 20 hex | The person this alert is about within that registry — the value to reconcile on next time. |

## Edge cases and pitfalls

- **No channel** — `422 {"detail": "channels must include at least one email or a webhookUrl."}`. The monitor is not created.
- **Street address without coordinates** on a location monitor — `422`; only a ZIP or "City, ST" `address` is placed on its own. Send `lat` and `lng` with a street address.
- **`product: "criminal"` + `type: "location"`** — `200 {"status": "not_yet_available", "message": "…"}`. Branch on `status` before you store an `id`; there is none.
- **A different date of birth** — sandbox **Exact Match** with `dob: "1990-05-05"` creates the monitor (`201`) but files no alert: the date contradicts the record. Production behaves the same way, so a wrong `dob` is silent, not an error.
- **A name-only monitor** — sandbox **Name Only** (no `dob`) files three `name_match` alerts, one per same-named registrant. Expect that volume on common names in production.
- **No alert at all** — `201` and an empty `recentAlerts` is the normal result when nobody matches (sandbox **Clear Visitor**). In production, the first evaluation is in the next daily cycle, so an empty list right after creation is expected.
- **Sandbox state** — sandbox monitors and alerts live in memory and may be reset when the sandbox is redeployed; the sandbox sends no email, only the webhook.

## Production checklist

- Switch to `https://api.offendersearch.app` and a live key; store every `mon_` id against your own record.
- Treat only `201` as "monitor created"; handle `200 not_yet_available`, `402` and `422` explicitly.
- Send a `dob` whenever you have one, and set `label` to something your operators recognise — it is echoed on every webhook as `monitorLabel`.
- Verify the token in your webhook URL and answer `2xx` within 10 seconds; reconcile against `GET /v1/monitors/{monitorId}/alerts`.
- Alerts are not a consumer report and may not be used for any FCRA-covered decision.

## Related

- [Monitoring quickstart](https://offendersearch.app/docs/monitoring/quickstart)
- [The Monitor object](https://offendersearch.app/docs/monitoring/monitor-object)
- [Endpoints](https://offendersearch.app/docs/monitoring/endpoints)
- [Monitoring sandbox](https://offendersearch.app/docs/monitoring/sandbox)
