# Location monitoring

> A location monitor stands watch over a place. Give it a centre and a radius, and it reports who moves in, changes, or moves out.

- **HTML:** https://offendersearch.app/docs/guides/monitoring/location-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 sex-offender **location monitor**: a centre (`lat`/`lng`) and a `radiusMiles`, evaluated once a day against the registered addresses the registries publish. Alerts carry `matchLabel: "in_radius"` and arrive when a record enters the radius or is newly listed inside it (`new`), when a record inside it changes (`changed`), or when it leaves or comes off a registry (`removed`).

This is the watch for a school, a facility, a property or a route hub — anywhere the question is "who is registered near here" rather than "is this person listed".

## Before you start

- A test key against `https://sandbox.offendersearch.app`. The sandbox point is **39.9612, -82.9988** (Sampleville, OH): a 1-mile radius there files one `in_radius` alert for the sample registrant Exact Match. **34.0522, -118.2437** with any radius files none.
- Coordinates for your centre. On its own, a ZIP code or `"City, ST"` in `address` is placed at its centre; a street address **must** come with `lat` and `lng` or the call is a `422`.
- `radiusMiles` defaults to **3** and accepts any value above 0 up to **100**.

> **Criminal location monitoring is not available yet.** `product: "criminal"` with `type: "location"` answers **`200`** with `status: "not_yet_available"`. No monitor is created and nothing is billed. It is a `200`, not a `4xx`, so check `status` before assuming you hold an `id`.

## Step by step

1. **Resolve your centre.** Geocode the street address yourself and send `lat` + `lng`. Keep `address` too — it is stored as sent and names the monitor on alert emails when you set no `label`.
2. **Pick the radius.** Start tight (1–2 miles) for a single site; widen for a district. Every record inside the radius alerts on the first evaluation, so the radius sets the first day’s volume.
3. **Create the monitor.** `POST /v1/monitors` with `type: "location"`. `minConfidence` is ignored on a location monitor.
4. **Read the alerts.** `GET /v1/monitors/{monitorId}/alerts`. Each is a person at a registered address inside the radius, labelled `in_radius`.

### 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": "location",
    "location": {
      "address": "100 Sample St, Sampleville, OH 00000",
      "lat": 39.9612, "lng": -82.9988, "radiusMiles": 1
    },
    "label": "Sampleville campus — 1 mi",
    "channels": { "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": "location",
        "location": {
            "address": "100 Sample St, Sampleville, OH 00000",
            "lat": 39.9612, "lng": -82.9988, "radiusMiles": 1,
        },
        "label": "Sampleville campus — 1 mi",
        "channels": {"webhookUrl": "https://your-app.example.com/hooks/offendersearch?token=YOUR_SECRET"},
    },
    timeout=30,
)
if res.status_code == 200 and res.json().get("status") == "not_yet_available":
    print("coming soon:", res.json()["message"])     # only criminal + location
res.raise_for_status()
monitor = res.json()
print(monitor["id"], monitor["location"])
```

**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: "location",
    location: { address: "100 Sample St, Sampleville, OH 00000", lat: 39.9612, lng: -82.9988, radiusMiles: 1 },
    label: "Sampleville campus — 1 mi",
    channels: { webhookUrl: "https://your-app.example.com/hooks/offendersearch?token=YOUR_SECRET" },
  }),
});
const body = await res.json();
if (res.status === 200 && body.status === "not_yet_available") {
  console.log("coming soon:", body.message);          // only criminal + location
} else if (res.status === 201) {
  console.log(body.id, body.location);
} else {
  throw new Error(JSON.stringify(body));
}
```

*201 Created*
```json
{
  "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=YOUR_SECRET" },
  "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 },
  "sandbox": true
}
```

A location monitor carries `location` and no `person` key. `lat` and `lng` are always filled in the response — when you sent only a ZIP or "City, ST", they are the centre the API placed it at.

### The alert

*POST to your webhookUrl*
```json
{
  "type": "monitor.alert",
  "event": "new",
  "alertId": "hit_a7c3e19f5b2d8046",
  "monitorId": "mon_4f2a9c7e1b8d6035",
  "monitorLabel": "Sampleville campus — 1 mi",
  "product": "sex-offender",
  "detail": {
    "name": "EXACT MATCH",
    "jurisdiction": "OH",
    "locality": "Sampleville",
    "matchLabel": "in_radius",
    "matchConfidence": null,
    "personRef": "prs_3c9e1f0a7b2d4e6f8a1b",
    "recordId": "rec_ab12cd34ef56ab78cd90"
  },
  "occurredAt": "2026-10-25T15:20:40.902211+00:00",
  "sentAt": "2026-10-25T15:20:41.336508+00:00"
}
```

### The criminal + location acknowledgement

*200 OK — nothing created*
```json
{
  "status": "not_yet_available",
  "message": "Location monitoring for criminal records is coming soon — it activates as location coverage across criminal records expands. Person monitoring is available for criminal records today.",
  "sandbox": true
}
```

## Reading the result

| Field | Values | What it means for a radius watch |
| --- | --- | --- |
| `matchLabel` | `in_radius` | The only label a location monitor produces. There is no identity floor; `matchConfidence` is `null`. |
| `eventType` | `new` | A registered address inside the radius that was not there at the previous evaluation — a move in, or a new listing. |
| `eventType` | `changed` | A record inside the radius changed a fact (address within the radius, offense field, status). |
| `eventType` | `removed` | The person moved out of the radius or came off the registry. `record` carries `event`, `jurisdiction` and `personRef` only. |
| `record.locality` | city | City only — an alert never carries a street address. Open `recordId` with `GET /v1/records/{recordId}` for the record. |
| `location.lat`, `location.lng` | numbers | The centre actually evaluated. Check them after creation when you sent an `address` alone. |

## Edge cases and pitfalls

- **`422 location monitors need location.lat and location.lng`** — you sent a street address without coordinates. Geocode first; the API only places ZIPs and "City, ST" on its own.
- **`422 location.radiusMiles must be between 0 and 100`** — `0`, negatives and anything over 100 are refused; omit it for the 3-mile default.
- **`lat`/`lng` are not range-checked** — a swapped pair (`lat: -82.99, lng: 39.96`) creates a monitor over the wrong hemisphere and alerts never. Validate ranges yourself.
- **Day one is the loudest.** Every registered address already inside the radius is a `new` alert on the first evaluation. Size the radius, or stage the rollout, with that in mind.
- **A registered address is not a current location.** The radius is evaluated against addresses as the registries publish them; a `removed` event may mean a move or a lapse in listing.
- **Criminal + location** returns `200 not_yet_available` on both hosts and bills nothing. Hold the watch and re-check the docs rather than retrying in a loop.
- **Same place, two products** is not possible today; a sex-offender location monitor costs $10 per cycle and is the only location product.

## Production checklist

- Geocode on your side; send `lat`, `lng` and the `address` string together.
- Branch on HTTP `201` vs `200 not_yet_available`; never store an `id` from a `200`.
- Expect the first day’s `new` burst; de-duplicate on `alertId`.
- Show `locality` and the record via `recordId`; never infer a street address from an alert.
- Alerts are not a consumer report; every alert email carries that notice.

## Related

- [Location monitoring (reference)](https://offendersearch.app/docs/monitoring/location-monitoring)
- [Alerts](https://offendersearch.app/docs/monitoring/alerts)
- [Billing](https://offendersearch.app/docs/monitoring/billing)
- [Monitoring sandbox](https://offendersearch.app/docs/monitoring/sandbox)
