# Managing and cancelling monitors

> A monitor has two states and one lifecycle call. This guide covers listing, inspecting, retiring and what each answer means for your records and your bill.

- **HTML:** https://offendersearch.app/docs/guides/monitoring/managing-and-cancelling
- **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

The housekeeping side of monitoring: a job that lists what your account is watching, matches each `mon_` id to the person or place it is for, retires watches that are no longer needed before the next invoice, and keeps the alert history of retired monitors readable. There is no update call — a monitor’s criteria and channels are fixed at creation — so "change" means create a replacement and cancel the old one.

## Before you start

- Four calls: `GET /v1/monitors` (list, newest first, active only unless `includeCanceled=true`), `GET /v1/monitors/{monitorId}` (one monitor with `recentAlerts`), `DELETE /v1/monitors/{monitorId}` (cancel), `GET /v1/monitors/{monitorId}/alerts` (history, also for canceled monitors).
- A monitor’s `status` is `active` or `canceled`; `canceledAt` is `null` until you cancel.
- The list has no pagination and no filter other than `includeCanceled`; use `label` to carry your own reference.

> **What cancel means, precisely.** Evaluation stops **now**. The monitor is **not charged on any invoice prepared after the cancel** — including the invoice for the month you cancel in — and there is **no proration** for the time it was active in that cycle. The monitor and every alert it filed **stay readable**.

## Step by step

1. **List what you are paying for.** `GET /v1/monitors` returns every active monitor. Each is one charge at the next invoice ($3 person, $10 location).
2. **Match each monitor to your record.** Use `label` (set it to your own id at creation) and `person` / `location` to find the subject. Anything you cannot match is a candidate for cancelling.
3. **Cancel.** `DELETE /v1/monitors/{monitorId}`. A `200` with `status: "canceled"` confirms it; cancelling again is harmless (another `200`).
4. **Keep the history.** The alerts of a canceled monitor remain at `GET /v1/monitors/{monitorId}/alerts`; `GET /v1/monitors?includeCanceled=true` lists the monitor itself with `canceledAt` set.

### List active monitors

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

*200 — newest first, active only*
```json
{
  "monitors": [
    {
      "id": "mon_4f2a9c7e1b8d6035",
      "product": "sex-offender",
      "type": "location",
      "minConfidence": null,
      "label": "Sampleville campus — 1 mi",
      "status": "active",
      "channels": { "emails": [], "webhookUrl": "https://your-app.example.com/hooks/offendersearch?token=…" },
      "createdAt": "2026-10-25T15:20:40.771302+00:00",
      "canceledAt": null,
      "location": { "address": "100 Sample St, Sampleville, OH 00000", "lat": 39.9612, "lng": -82.9988, "radiusMiles": 1 }
    },
    {
      "id": "mon_b8f40d7afb9dc0d8",
      "product": "sex-offender",
      "type": "person",
      "minConfidence": null,
      "label": "roster-00412",
      "status": "active",
      "channels": { "emails": ["alerts@your-app.example.com"] },
      "createdAt": "2026-10-25T14:02:11.418223+00:00",
      "canceledAt": null,
      "person": { "firstName": "Exact", "lastName": "Match", "dob": "1980-01-01" }
    }
  ],
  "sandbox": true
}
```

### Cancel one

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

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

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

def cancel(monitor_id):
    res = requests.delete(BASE + "/v1/monitors/" + monitor_id, headers=H, timeout=30)
    if res.status_code == 200:
        return res.json()                      # {"status": "canceled", "id": ..., "note": ...}
    if res.status_code == 409 and res.json()["detail"]["code"] == "personal_alert":
        raise RuntimeError("billed by its own subscription; cancel it from the personal dashboard")
    if res.status_code == 404:
        raise LookupError("not one of this account's monitors")
    res.raise_for_status()

def retire_unmatched(known_labels):
    monitors = requests.get(BASE + "/v1/monitors", headers=H, timeout=30).json()["monitors"]
    for m in monitors:
        if m["label"] not in known_labels:
            print("cancelling", m["id"], m["label"], cancel(m["id"])["status"])
```

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

async function cancel(monitorId) {
  const res = await fetch(BASE + "/v1/monitors/" + monitorId, { method: "DELETE", headers });
  const body = await res.json();
  if (res.status === 200) return body;                       // { status: "canceled", id, note }
  if (res.status === 409 && body.detail?.code === "personal_alert") {
    throw new Error("billed by its own subscription; cancel it from the personal dashboard");
  }
  if (res.status === 404) throw new Error("not one of this account's monitors");
  throw new Error(JSON.stringify(body));
}

async function retireUnmatched(knownLabels) {
  const { monitors } = await (await fetch(BASE + "/v1/monitors", { headers })).json();
  for (const m of monitors) {
    if (!knownLabels.has(m.label)) console.log("cancelling", m.id, m.label, (await cancel(m.id)).status);
  }
}
```

*200 — canceled*
```json
{
  "status": "canceled",
  "id": "mon_b8f40d7afb9dc0d8",
  "note": "Evaluation stops now. The monitor is not charged on any invoice prepared after this cancel; its alerts stay readable.",
  "sandbox": true
}
```

*409 — a personal alert*
```json
{
  "detail": {
    "code": "personal_alert",
    "message": "This alert is billed by its own subscription. Cancel it from Alerts in your personal dashboard."
  }
}
```

### See it afterwards

```bash
curl "https://sandbox.offendersearch.app/v1/monitors?includeCanceled=true" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
# … "status": "canceled", "canceledAt": "2026-10-25T16:03:19.511270+00:00" …

curl "https://sandbox.offendersearch.app/v1/monitors/mon_b8f40d7afb9dc0d8/alerts" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
# the alert history is unchanged
```

## Reading the result

| Answer | Values | What it means |
| --- | --- | --- |
| `GET /v1/monitors` → `monitors[].status` | `active` | Being evaluated daily and will be charged at the next invoice. |
| `?includeCanceled=true` → `status`, `canceledAt` | `canceled`, date-time | Not evaluated, not charged on invoices prepared after `canceledAt`; readable. |
| `DELETE` → `200` | `{status: "canceled", id, note}` | Done. `note` is a fixed sentence restating the semantics. |
| `DELETE` → `409` | `detail.code: "personal_alert"` | The monitor belongs to a personal subscription on the same account; it is canceled from the personal dashboard, not the API. |
| `DELETE` / `GET` → `404` | `{"detail": "Monitor not found."}` | Unknown id, or another account’s monitor — the API never distinguishes. |
| `401` / `402` | string `detail` | Bad key, or no active subscription and no active trial. |

## Edge cases and pitfalls

- **There is no PATCH.** To change a radius, a `dob`, a channel or a label, create a new monitor and cancel the old one. Its history stays on the old `id`; link the two in your own store.
- **Cancelling twice returns `200` again** and overwrites `canceledAt`. Harmless, but do not use `canceledAt` as "first cancelled at" if your job may re-run.
- **The list is everything, unpaginated.** With thousands of monitors, cache it and work from `label`; there is no server-side search.
- **Personal alerts appear in the list** when a personal subscription shares the account; they are excluded from your invoice and answer `409` on `DELETE`.
- **Cancel before the invoice is prepared** to avoid the charge for that cycle; a monitor active at invoice time is charged in full. The cancel itself is instant.
- **A canceled monitor never becomes active again.** Re-watching the same person is a new monitor and a new `id`.

## Production checklist

- Set `label` to your own reference at creation; it is the only filter you will have.
- Run a reconciliation that lists active monitors and cancels anything unmatched before each billing cycle.
- Handle `409 personal_alert` and `404` without retrying.
- Keep canceled `mon_` ids: their alert history is part of your audit trail.

## Related

- [Endpoints](https://offendersearch.app/docs/monitoring/endpoints)
- [Billing](https://offendersearch.app/docs/monitoring/billing)
- [The Monitor object](https://offendersearch.app/docs/monitoring/monitor-object)
- [Webhooks, duplicates & cancelling](https://offendersearch.app/docs/guides/monitoring/webhooks-and-lifecycle)
