# Map API documentation

> Your map, our data: clusters when zoomed out, pins closer in, names close up, and the full record when one is opened.

- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed; see https://offendersearch.app/docs/map/sandbox.md
- **Authentication:** `X-API-Key` request header
- **HTML documentation:** https://offendersearch.app/docs/map
- **Per-section markdown:** https://offendersearch.app/docs/map/{section}.md

## Introduction

The Map API puts registered sex offenders on a map that you draw. Your app sends the view the map is showing. We return what to draw for it: clusters when zoomed out, pins closer in, names close up, and the complete record when one is opened.

- **Your map, any library.** The response is plain JSON or GeoJSON. There are complete examples for [MapLibre](https://offendersearch.app/docs/map/maplibre.md), [Leaflet](https://offendersearch.app/docs/map/leaflet.md) and [Google Maps](https://offendersearch.app/docs/map/google-maps.md).
- **Any size of view.** A whole country or one street is one map request. The zoom decides how much detail comes back.
- **The same data as search.** The map covers the same 58 registries as the Sex Offender API, and an opened record is the same record object `POST /v1/search` returns.
- **Build before you pay.** [Test mode](https://offendersearch.app/docs/map/test-mode.md) returns made-up people in the real response shape, with no plan.

> A pin shows a registered address, not a person's current location. Every response carries this sentence in `notice`. Show it on the map.

## The routes

| Route | What it does | Billed |
| --- | --- | --- |
| `GET /v1/map/viewport` | One map view: clusters, pins, or named pins. | 1 map request |
| `POST /v1/map/area` | The same answer for a drawn shape: a polygon, or a route with a buffer. | 1 map request |
| `POST /v1/map/route` | Exact counts and nameless pins along a route: a corridor either side of the line, a radius around each stop, or both. | 1 map request per route unit |
| `POST /v1/map/routes` | Up to 50 routes in one call, answered all or nothing. | The sum of the route units |
| `GET /v1/map/groups/{groupId}` | The people behind a many-people pin or an approximate-area pin. | 1 map request per page |
| `GET /v1/map/records/{markerId}` | The complete record behind a marker or a saved reference. | 1 record open |
| `GET /v1/map/status` | Your plan, rates, limits and this month’s usage. | Free |
| `POST /v1/map/enable` | Turn the Map API on. | Charges the $300 monthly minimum |
| `POST /v1/map/disable` | Turn it off. | Free |

Base URL: `https://api.offendersearch.app`. Send your key in the `X-API-Key` header. See [Access & keys](https://offendersearch.app/docs/map/access.md).

## Three detail levels

The zoom of the view picks what comes back. The response says which in `mode`.

What a view returns at each zoom.

```text
Zoom 3–12              Zoom 13–14             Zoom 15+
mode: clusters         mode: pins             mode: markers

  ( 412 )    ( 96 )      •    •     •          • J. Tester   • S. Demo
       ( 1.3k )             •    •                  (12)  • A. Sample

a count per grid cell  one pin per address    pins with a name label
                       no name                many-people pins
```

| Zoom | `mode` | Returned |
| --- | --- | --- |
| 3–12 | `clusters` | A count for each grid cell, with its extent and a breakdown. |
| 13–14 | `pins` | One pin for each address: a position, with no name. |
| 15+ | `markers` | Pins with a name label and basic fields. |

See [The viewport request](https://offendersearch.app/docs/map/viewport.md) for the exact rules and [Clusters & markers](https://offendersearch.app/docs/map/clusters-and-markers.md) for every field.

## Price

$2.00 per 1,000 map requests and $0.035 per record opened, with a $300 monthly minimum applied toward usage.

Clusters and pins are never charged per person. A view that has not changed (HTTP 304), the status route and test mode are free. See [Billing](https://offendersearch.app/docs/map/billing.md) for worked months.

## A first request

This runs before the plan is on, with any key on an account with a subscription or a free trial, because it is in test mode.

GET /v1/map/viewport

```bash
curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d bbox=-83.2,39.8,-82.8,40.1 \
  -d zoom=10 \
  -d sandbox=true
```

The [Quickstart](https://offendersearch.app/docs/map/quickstart.md) takes it from here.

## Turn it on

Any paying business account can turn the Map API on. Turning it on charges the $300 minimum that day, and it covers the rest of that calendar month.

- Dashboard: https://offendersearch.app/dashboard/map-api
- No account yet: https://offendersearch.app/sign-up?next=%2Fdashboard%2Fmap-api
- Product overview: https://offendersearch.app/sex-offender-map-api

---

# Quickstart

> Make a first map request in test mode, read the response, turn the Map API on and draw the result with the map library you already use. About five minutes.

HTML: https://offendersearch.app/docs/map/quickstart · Markdown: https://offendersearch.app/docs/map/quickstart.md

## 1. Make a request in test mode

Use the API key you already have. `sandbox=true` puts the request in test mode: it needs no plan and is never billed. The box is `west,south,east,north` in degrees.

GET /v1/map/viewport

```bash
curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d bbox=-83.2,39.8,-82.8,40.1 \
  -d zoom=10 \
  -d sandbox=true
```

## 2. Read the response

At this zoom the view is answered as clusters. Each one has a position, a count, the extent of the people in it, and the zoom at which it comes apart.

200 OK

```json
{
  "mode": "clusters",
  "zoom": 10,
  "bbox": [-83.2, 39.8, -82.8, 40.1],
  "datasetVersion": "2026-10-12",
  "total": 508,
  "breakdown": {
    "street": 508, "housed": 247, "homeless": 96, "incarcerated": 0
  },
  "clusters": [
    {
      "id": "c10_1103_1551",
      "lat": 39.9612, "lng": -82.9988,
      "count": 412,
      "bbox": [-83.06, 39.91, -82.97, 39.98],
      "expansionZoom": 11,
      "breakdown": {
        "street": 412, "housed": 201, "homeless": 77, "incarcerated": 0
      }
    }
  ],
  "markers": [],
  "areas": [],
  "unplaced": {"count": 96, "states": ["OH"]},
  "notice": "A pin shows a registered address, not a person's current location.",
  "sandbox": true
}
```

- `mode` tells you what to draw: `clusters`, `pins` or `markers`.
- `total` is the number of people in the view.
- `clusters[]` are the circles. Draw `count` inside each one.
- `markers[]` and `areas[]` fill in when the view is closer.
- `notice` is a sentence to show on the map.
- `sandbox: true` marks a test-mode answer.

Send the same request with a higher `zoom` and a smaller box. From zoom 13 you get pins. From zoom 15 the pins carry names.

## 3. Turn it on

Any paying business account can turn the Map API on. Turning it on charges the $300 minimum that day, and it covers the rest of that calendar month.

Turn it on in the dashboard under Map API, or with one call:

POST /v1/map/enable

```bash
curl -X POST https://api.offendersearch.app/v1/map/enable \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"acceptMinimum": true}'
```

The plan is active at once, on the same key. [Turn it on](https://offendersearch.app/docs/map/turn-it-on.md) has the details.

## 4. Make a live request

Drop `sandbox=true`. The request and the response shape are the same.

curl

```bash
curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d bbox=-83.2,39.8,-82.8,40.1 \
  -d zoom=10
```

Python

```python
import os, requests

r = requests.get(
    "https://api.offendersearch.app/v1/map/viewport",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    params={
        "bbox": "-83.2,39.8,-82.8,40.1",
        "zoom": 10,
    },
    timeout=30,
)
view = r.json()
for c in view["clusters"]:
    print(c["count"], c["lat"], c["lng"])
```

Node

```javascript
const query = new URLSearchParams({
  bbox: '-83.2,39.8,-82.8,40.1',
  zoom: '10',
});
const res = await fetch(
  'https://api.offendersearch.app/v1/map/viewport?' + query,
  {
    headers: {
      'X-API-Key': process.env.OFFENDERSEARCH_KEY,
    },
  },
);
const { mode, clusters, markers } = await res.json();
```

## 5. Draw it

1. When the map stops moving, call the viewport route with the box and the zoom.
2. Draw `clusters` as circles with their `count`. On click, fit the map to the cluster’s `bbox` at its `expansionZoom`.
3. Draw `markers` as pins. When a pin has a `label`, show it.
4. For a pin with a `groupId`, call the groups route and show the list.
5. When a person is chosen, call the records route with the marker `id` and show the record.

There is a complete, working map for each library: [MapLibre](https://offendersearch.app/docs/map/maplibre.md), [Leaflet](https://offendersearch.app/docs/map/leaflet.md), [Google Maps](https://offendersearch.app/docs/map/google-maps.md). [The click sequence](https://offendersearch.app/docs/map/click-sequence.md) lists every step with its call and its charge.

---

# Turn it on

> Who can turn the Map API on, what is charged at that moment, how later months are billed, what turning it off does, and the errors this flow can return.

HTML: https://offendersearch.app/docs/map/turn-it-on · Markdown: https://offendersearch.app/docs/map/turn-it-on.md

## Who can turn it on

Any paying business account. There is no application and no review. An account on the free trial cannot turn it on: add a payment method first. To build before paying, use [test mode](https://offendersearch.app/docs/map/test-mode.md).

## How to turn it on

In the dashboard, open Map API and turn it on. Or call `POST /v1/map/enable` with a secret key.

POST /v1/map/enable

```bash
curl -X POST https://api.offendersearch.app/v1/map/enable \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"acceptMinimum": true}'
```

1. `acceptMinimum` must be `true`. It is your agreement to the monthly minimum.
2. The account needs a payment method on file.
3. We charge the $300 minimum to the card at that moment. If the charge fails, nothing is turned on.
4. The plan is active at once, on the same key. A confirmation email is sent.

The response is the same object `GET /v1/map/status` returns, plus `changed` and a `charged` object with the amount, the month it covers and the invoice.

## Sex offender map API: POST /v1/map/enable request and response fields

`POST /v1/map/enable` · auth: `X-API-Key`

Turn the Map API on. Any paying business account can, with a secret key or an account admin's dashboard session. The monthly minimum is charged to the card at that moment and covers the current calendar month; if the charge fails, nothing is turned on. On an account that is already on it changes nothing and charges nothing.

### Request

**Headers**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | A secret API key. A dashboard session of an account admin can send `Authorization: Bearer <token>` instead. A map browser key is refused here. |

**Body** (application/json)

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `acceptMinimum` | `boolean` | optional · default `false` | Send `true`: you agree to the monthly minimum, which is charged now. Left out or `false`, an account whose Map API is off is refused with `422 minimum_not_accepted`; an account whose Map API is already on gets `200` with `changed: false`. Example `true`. |

### Response

**200** (application/json) — The plan after the call, with what was charged.

Every field of [the `MapStatus` object](https://offendersearch.app/docs/map/turn-it-on#reference-get-v1-map-status), plus:

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `changed` | `boolean` |  | `true` when this call turned the Map API on. `false` when it was already on; nothing was charged. |
| `invoiced` | `object` or null |  | For an account billed by invoice: the minimum this call put on the month-end invoice instead of charging it. `null` otherwise. |
| `invoiced.usd` | `number` |  | The minimum, in dollars. Example `300`. |
| `invoiced.netDays` | `integer` |  | Days the invoice is due after it is issued. Example `30`. |
| `invoiced.covers` | `string` |  | The month it covers. Example `"October 2026"`. |
| `charged` | `object` or null |  | The minimum charged by this call. `null` when no charge was due: the Map API was already on, or the account owes no minimum this month. |
| `charged.usd` | `number` |  | The amount, in dollars. Example `300`. |
| `charged.charged` | `boolean` |  | `true` when this call took the payment. |
| `charged.alreadyPaid` | `boolean` |  | `true` when this month's minimum was already paid; nothing more was charged. |
| `charged.pending` | `boolean` |  | `true` for a bank payment that has not settled yet. |
| `charged.invoice` | `string` or null |  | The invoice id. `null` when no invoice was created. |
| `charged.covers` | `string` |  | The calendar month the minimum covers. Example `"October 2026"`. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | The key is missing or invalid. The body is the plain `detail` string. |
| 402 | `payment_method_required` | No payment method on file, or a free-trial account. `payment_failed` when the card was declined; nothing was turned on. |
| 403 | `business_account_required` | A personal account. `admin_required` for a dashboard seat that is not an admin. |
| 422 | `minimum_not_accepted` | `acceptMinimum` was not `true`. |

## What the charge covers

Any paying business account can turn the Map API on. Turning it on charges the $300 minimum that day, and it covers the rest of that calendar month.

The $300 monthly minimum is applied toward everything the account uses that month, search included. A month with $110 of usage is billed $300; a month with $1,100 is billed $1,100.

- **It is a floor, not a fee on top.** The $300 is applied toward everything the account uses that month: map requests, record opens and search.
- **The first month.** The $300 is paid on the day you turn it on. At month end that month is invoiced as `max(total usage, 300) − 300 already paid`. With $150 of usage the month-end invoice adds nothing. With $1,500 of usage it adds $1,200.
- **Later months.** From the next month the minimum is settled on the month-end invoice: you pay your usage, or the minimum if usage is lower.
- **The day of the month does not change the amount.** The minimum covers the calendar month it is paid in, whichever day that is.

See [Billing](https://offendersearch.app/docs/map/billing.md) for the rates and worked months.

## If the card is declined

Nothing is turned on and nothing is charged. The call answers `402 payment_failed`. Fix or replace the card and call it again.

## Turning it off

Turn it off at any time and the map routes stop within seconds. A minimum already paid is not refunded, and turning it back on in the same month costs nothing more.

POST /v1/map/disable

```bash
curl -X POST https://api.offendersearch.app/v1/map/disable \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

- Within seconds the map routes answer `403 map_not_enabled`. Test mode still works.
- The month it was on in stays a minimum month.
- Calling `enable` on an account that is already on changes nothing and charges nothing.

## Sex offender map API: POST /v1/map/disable response fields

`POST /v1/map/disable` · auth: `X-API-Key`

Turn the Map API off. Free. The map routes stop within seconds; test mode keeps working. The month stays a minimum month, a minimum already paid is not refunded, and turning it on again in the same month charges nothing more.

### Request

No parameters and no body.

**Headers**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | A secret API key. A dashboard session of an account admin can send `Authorization: Bearer <token>` instead. A map browser key is refused here. |

### Response

**200** (application/json) — The plan after the call.

Every field of [the `MapStatus` object](https://offendersearch.app/docs/map/turn-it-on#reference-get-v1-map-status), plus:

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `changed` | `boolean` |  | `true` when this call turned the Map API off. `false` when it was already off. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | The key is missing or invalid. The body is the plain `detail` string. |
| 403 | `admin_required` | A dashboard seat that is not an admin. |

## Checking the plan

`GET /v1/map/status` is free and works whether the plan is on or off. It is how a client finds out which.

GET /v1/map/status

```bash
curl https://api.offendersearch.app/v1/map/status \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

## Sex offender map API: GET /v1/map/status response fields

`GET /v1/map/status` · auth: `X-API-Key`

The Map API plan, rates, limits and this month's usage. Free, and it answers for an account whose Map API is off: it is how a client finds out that it is.

### Request

No parameters and no body.

**Headers**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | A secret API key (from your server), or a map browser key `os_map_pk_…` sent from a site on that key's list. A dashboard session can send `Authorization: Bearer <token>` instead. |

### Response

**200** (application/json) — The plan and this month's usage.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` |  | `true` when the Map API is on for this account. |
| `paused` | `boolean` |  | `true` when we have paused the Map API for this account; the map routes then answer `403 map_paused`. |
| `sandbox` | `boolean` |  | `true` when this account's plan answers every request in test mode. |
| `everEnabled` | `boolean` |  | `true` when the Map API has been turned on for this account at any time. |
| `enabledAt` | `string (date-time)` or null |  | When the Map API was last turned on. `null` when it never has been. |
| `disabledAt` | `string (date-time)` or null |  | When the Map API was last turned off. `null` while it is on (turning it on clears it) and when it has never been turned off. |
| `pinMinZoom` | `integer` |  | Pins start at this zoom; below it a view is clusters. Example `13`. |
| `nameMinZoom` | `integer` |  | Names start at this zoom; below it a pin carries no `label`. Example `15`. |
| `maxMarkers` | `integer` |  | The most pins one answer carries. A denser view is answered as clusters. Example `300`. |
| `maxTilesPerRequest` | `integer` |  | The most map tiles one view may cover at its zoom; a wider box is answered at a lower zoom. Example `64`. |
| `markerIdTtlSeconds` | `integer` |  | How long marker ids and group ids work, in seconds. Example `86400`. |
| `markerFields` | `string` — one of `"basic"`, `"named"` |  | How labels read on this plan: `basic` gives `J. Smith`, `named` gives `John Smith`. |
| `rates` | `object` |  | This account's two rates. |
| `rates.requestUsdPer1k` | `number` |  | Dollars per 1,000 map requests. Example `2`. |
| `rates.recordOpenUsd` | `number` |  | Dollars per record opened: this account's rate. $0.035 by default; accounts that had the Map API before 2026-10-18 keep $0.015, and a volume account may have its own rate. Example `0.035`. |
| `minimumUsd` | `number` |  | The monthly minimum, in dollars. Example `300`. |
| `minimumWaivedUntil` | `string (date)` or null |  | The date a negotiated waiver of the minimum ends. `null` when the minimum is not waived. |
| `minimumAppliesThisMonth` | `boolean` |  | `true` when this calendar month is a minimum month for the account. |
| `minimumPaidThisMonthUsd` | `number` |  | What was charged toward this month's minimum when the Map API was turned on, in dollars. `0` when nothing was. |
| `chargeAtTurnOnUsd` | `number` |  | What `POST /v1/map/enable` would charge right now, in dollars: the minimum, or `0` when the Map API is already on, this month's minimum is already paid, or the account owes no minimum this month (a waiver, a plan in test mode, or an account we do not bill). Example `300`. |
| `minimumBilledOn` | `string` — one of `"card"`, `"invoice"` |  | How the monthly minimum is collected: `card` (charged when the Map API is turned on) or `invoice` (a line on the month-end invoice, for accounts billed by invoice; nothing is charged at turn-on). Example `"card"`. |
| `invoiceNetDays` | `integer` or null |  | Days the month-end invoice is due after it is issued, for an account billed by invoice. `null` otherwise. |
| `usageThisMonth` | `object` |  | Map usage since the start of this calendar month (UTC), before the minimum. Search usage is on the account's billing, not here. |
| `usageThisMonth.periodStart` | `string (date-time)` |  | The start of the month counted. |
| `usageThisMonth.requests` | `integer` |  | Map requests so far this month. |
| `usageThisMonth.recordOpens` | `integer` |  | Record opens so far this month. |
| `usageThisMonth.requestsUsd` | `number` |  | The map-request line in dollars, rounded down to the cent. |
| `usageThisMonth.recordOpensUsd` | `number` |  | The record-open line in dollars, rounded to the nearest cent. |
| `usageThisMonth.estimatedUsd` | `number` |  | The two lines together, before the minimum. |
| `sweep` | `object` |  | The daily area budget for pins. It resets at 00:00 UTC; clusters and test mode never count. |
| `sweep.tilesToday` | `integer` |  | Distinct areas (zoom-14 map tiles) pins were returned for today. |
| `sweep.tilesPerDay` | `integer` |  | The account's daily budget of distinct areas. Past it pin views answer `429 map_sweep_limit`. Example `20000`. |
| `sweep.endUserTilesPerDay` | `integer` |  | The daily budget for each end user named in `X-End-User`. Past it that user's pin views answer `429 map_end_user_limit`. Example `2000`. |
| `limits` | `object` |  | Daily limits set for this account. `0` means no limit. See [Daily limits](https://offendersearch.app/docs/map/limits-and-errors#daily-limits). |
| `limits.recordOpensPerDay` | `integer` |  | Distinct records the account may open in a UTC day. `0`, the default, is no limit. Past it, `GET /v1/map/records` answers `429 map_open_limit`; a record already opened that day still opens. Example `0`. |
| `limits.endUserOpensPerDay` | `integer` |  | The same, for each end user named in `X-End-User`. `0`, the default, is no limit. Example `0`. |
| `limits.endUserRoutesPerDay` | `integer` |  | Distinct routes one end user may check in a UTC day. Past it a new route answers `429 map_end_user_route_limit`. `0` is no limit. Example `20`. |
| `routes` | `object` |  | How routes are billed and limited. See [Routes, stops and corridors](https://offendersearch.app/docs/map/routes). |
| `routes.unitMiles` | `integer` |  | One route unit per started this-many miles of corridor. Example `1`. |
| `routes.stopsPerUnit` | `integer` |  | One route unit per started this-many stops measured with a radius. Example `10`. |
| `routes.maxRoutesPerCall` | `integer` |  | The most routes in one `POST /v1/map/routes`. Example `50`. |
| `routes.profiles` | `object` |  | The limits of each profile. |
| `routes.profiles.walk` | [MapRouteProfile](https://offendersearch.app/docs/map/routes#object-maprouteprofile) |  | The `walk` profile. |
| `routes.profiles.bus` | [MapRouteProfile](https://offendersearch.app/docs/map/routes#object-maprouteprofile) |  | The `bus` profile. |
| `terms` | `array of string` |  | The rates and the minimum, as sentences to show a person. |
| `links` | `object` |  | Where to manage and read about the Map API. |
| `links.dashboard` | `string` |  | The Map API page of the dashboard. |
| `links.docs` | `string` |  | The Map API documentation. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | The key is missing or invalid. The body is the plain `detail` string. |
| 403 | `origin_not_allowed` | A browser key used from a site not on its list. |

## Negotiated accounts

An account with a negotiated plan, such as a trial period with the minimum waived, is turned on by us. Nothing is charged at that point, and the minimum starts the month after the waiver ends. Contact us to arrange one.

## Errors in this flow

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| `minimum_not_accepted` | 422 | `acceptMinimum` was not `true`. | Send `{"acceptMinimum": true}`. |
| `payment_method_required` | 402 | There is no card on the account, or it is on the free trial. | Add a payment method in the dashboard under Billing, then try again. |
| `payment_failed` | 402 | The card was declined. Nothing was turned on and nothing was charged. | Fix or replace the card, then try again. |
| `business_account_required` | 403 | The account is a personal account. | Use a business account. |
| `admin_required` | 403 | The dashboard seat is not an admin of the account. | Ask an account admin to turn it on, or use a secret key. |

---

# Access & keys

> Which key to use for the Map API: a secret key from your server with a small proxy, or a browser key limited to your own sites. Plus how phone apps call it.

HTML: https://offendersearch.app/docs/map/access · Markdown: https://offendersearch.app/docs/map/access.md

## Which key to use

| Where the call is made | Key | Notes |
| --- | --- | --- |
| Your server | Your secret key | Works on every route. The browser talks to your server; your server talks to us. |
| The browser, on your own site | A map browser key, `os_map_pk_…` | Works only on the map routes listed below, and only from the sites on its list. |
| A phone app | Your secret key, on your server | A phone app has no web origin, so it calls through your server. |

Every request sends the key in the `X-API-Key` header. A secret key must never be in a web page or an app bundle.

## From your server

A small proxy keeps the secret key on your server. It passes the map read routes and the area route through, with the caching headers, and nothing else. Both samples below are complete.

server.js — Node

```javascript
// server.js — Node 18+, Express
import express from 'express';

const app = express();
const API = 'https://api.offendersearch.app';
// Only the map read routes are passed through.
const READ = /^\/(viewport|groups\/[^/]+|records\/[^/]+)$/;

app.use('/v1/map', express.json(), async (req, res) => {
  const isRead = req.method === 'GET' && READ.test(req.path);
  const isArea = req.method === 'POST' && req.path === '/area';
  if (!isRead && !isArea) return res.sendStatus(404);
  // Check here that the caller is signed in to YOUR app.

  const headers = { 'X-API-Key': process.env.OFFENDERSEARCH_KEY };
  const etag = req.get('If-None-Match');
  if (etag) headers['If-None-Match'] = etag;
  // Optional: your own id for the signed-in user.
  if (req.user) headers['X-End-User'] = String(req.user.id);
  const init = { method: req.method, headers };
  if (isArea) {
    headers['Content-Type'] = 'application/json';
    init.body = JSON.stringify(req.body);
  }

  const query = req.originalUrl.split('?')[1];
  const url = API + '/v1/map' + req.path + (query ? '?' + query : '');
  const upstream = await fetch(url, init);

  res.status(upstream.status);
  for (const name of ['content-type', 'etag', 'cache-control', 'retry-after']) {
    const value = upstream.headers.get(name);
    if (value) res.set(name, value);
  }
  res.send(Buffer.from(await upstream.arrayBuffer()));
});

app.listen(3000);
```

server.py — Python

```python
# server.py — Flask
import os

import requests
from flask import Flask, Response, abort, request

app = Flask(__name__)
API = "https://api.offendersearch.app"
# Only the map read routes are passed through.
READ = ("viewport", "groups/", "records/")
KEEP = ("Content-Type", "ETag", "Cache-Control", "Retry-After")


@app.route("/v1/map/<path:rest>", methods=["GET", "POST"])
def map_proxy(rest):
    is_read = request.method == "GET" and rest.startswith(READ)
    is_area = request.method == "POST" and rest == "area"
    if not (is_read or is_area):
        abort(404)
    # Check here that the caller is signed in to YOUR app.

    headers = {"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]}
    etag = request.headers.get("If-None-Match")
    if etag:
        headers["If-None-Match"] = etag

    upstream = requests.request(
        request.method,
        f"{API}/v1/map/{rest}",
        params=request.args,
        json=request.get_json() if is_area else None,
        headers=headers,
        timeout=20,
    )
    passed = {h: upstream.headers[h] for h in KEEP if h in upstream.headers}
    return Response(
        upstream.content, status=upstream.status_code, headers=passed
    )
```

- **Check your own sign-in first.** An open proxy lets anyone spend your map requests.
- **Pass `If-None-Match` up and `ETag` down.** An unchanged view then answers `304` and is not billed.
- **Optionally send `X-End-User`.** It is any id you choose for the signed-in person. The daily area limit is then also counted for each end user, so one heavy user is limited before the whole account is. See [Limits & errors](https://offendersearch.app/docs/map/limits-and-errors.md).

## From the browser: map browser keys

A map browser key starts with `os_map_pk_`. It is safe to put in a web page because it only works from the sites you list, and only on the map routes below.

### Create one

Create a map browser key in the dashboard, on the Map API page, and list the sites it may be used from. To change the list of sites later, edit the key on the same page; the new list replaces the old one.

### Allowed sites

- Each entry is a scheme and a host, such as `https://example.com`. Include the port when there is one.
- `https://*.example.com` matches every subdomain of `example.com`. It does not match `example.com` itself, so list both.
- `http://` is for localhost only, such as `http://localhost:8000`.
- The browser sends the page’s site in the `Origin` header. A request from a site that is not on the list answers `403 origin_not_allowed`.

### Where a browser key works

| Route | Browser key |
| --- | --- |
| `GET /v1/map/viewport` | Yes |
| `POST /v1/map/area` | Yes |
| `POST /v1/map/route` | Yes, with an `X-End-User` header |
| `POST /v1/map/routes` | Yes, with an `X-End-User` header |
| `GET /v1/map/groups/{groupId}` | Yes |
| `GET /v1/map/records/{markerId}` | Yes |
| `GET /v1/map/status` | Yes |
| Every other route in the API | No. It answers `401`. |

A browser key on the route routes must say which of your users is asking, in `X-End-User`: any stable id of yours, such as a hashed user id. Without it the call answers `422 end_user_required`. Each end user then has their own daily limits, so one user cannot spend the account’s. See [Platform integration](https://offendersearch.app/docs/map/platform-integration.md).

The map routes answer browser preflight requests and name the calling site in `Access-Control-Allow-Origin`. That only lets the browser read the answer: which sites may call is decided by the browser key’s list. The `ETag` and `Retry-After` response headers are readable from the page.

## Phone apps

A phone app has no web origin, so a browser key cannot be checked against a site. Call from your server with the secret key, using the same proxy as above. Your app signs its own users in to your server.

---

# Test mode

> Add sandbox=true to a map request and get made-up people in the real response shape. Test mode needs no plan, is never billed and counts against no limit.

HTML: https://offendersearch.app/docs/map/test-mode · Markdown: https://offendersearch.app/docs/map/test-mode.md

## What test mode is

Test mode returns made-up people in the real response shape. Add sandbox=true to any map request. It needs no plan and is never charged.

To build with a test key and no live key at all, use the [Map API sandbox server](https://offendersearch.app/docs/map/sandbox.md) instead: every request there is test mode.

- Every person is made up. Every name label starts with “Sample”.
- The response is in the real shape, with the same fields.
- It needs no plan. Any valid key works, on an account with a subscription or a free trial.
- It is never billed and counts against no limit.
- Every test-mode response carries `"sandbox": true`.

## Turn it on for a request

Add `sandbox=true` to the query of `GET /v1/map/viewport`, or `"sandbox": true` to the body of `POST /v1/map/area`, `POST /v1/map/route` or `POST /v1/map/routes`.

GET /v1/map/viewport

```bash
curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d bbox=-83.2,39.8,-82.8,40.1 \
  -d zoom=10 \
  -d sandbox=true
```

## Where the sample people are

The sample people are around eight US cities: Columbus, Cleveland, Cincinnati, Chicago, New York, Los Angeles, Houston and Seattle. A view anywhere else comes back empty. The examples in these docs open on Columbus.

They do not change from day to day, so a view you built against yesterday looks the same today.

## Groups and records

Group ids and marker ids from a test-mode response open test-mode data. You do not add `sandbox=true` to the groups or records routes: the id already carries it.

- A test-mode record is in the same record shape as a real one, with a made-up name, address and offense.
- Its `billing` block says `"billed": false` with `"reason": "sandbox"`.
- Columbus has a pin that stands for several people at one address, and an approximate-area pin, so you can build the list view.

## Going live

1. Turn the Map API on. See [Turn it on](https://offendersearch.app/docs/map/turn-it-on.md).
2. Remove `sandbox=true` from your requests.
3. Nothing else changes. The routes, fields and ids are the same.

## Test the Map API on the sandbox server

Every Map API route also runs on the sandbox server with a test key (os_test_…): no plan, nothing metered, and no sandbox=true needed, because every request there is test mode. It draws the same made-up people as test mode on the live API. Use it to build with a test key and no live key at all; use sandbox=true when you already call the live API.

- **Sandbox base URL:** `https://sandbox.offendersearch.app`. Not `https://api.offendersearch.app`, which takes live keys only.
- **Test key:** in the dashboard, Developers → API keys (https://offendersearch.app/dashboard/keys) → **Create test key**. Test keys begin `os_test_`; send yours in the `X-API-Key` header.

GET /v1/map/viewport

```bash
curl "https://sandbox.offendersearch.app/v1/map/viewport?bbox=-83.20,39.85,-82.80,40.10" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
```

- Every test name: [Map API sandbox scenarios](https://offendersearch.app/docs/map/sandbox#map)
- Postman collection: https://offendersearch.app/downloads/offendersearch-sandbox.postman_collection.json
- Map API sandbox server: https://offendersearch.app/docs/map/sandbox.md
- Run any scenario from the browser: the HTML page's "Try it here" explorer (sandbox host and `os_test_` keys only, never billed).

---

# Map API sandbox server & test keys

> Build on the Map API sandbox server with an os_test_ key and no live key: every route, fixed made-up people, no plan to turn on and nothing metered.

HTML: https://offendersearch.app/docs/map/sandbox · Markdown: https://offendersearch.app/docs/map/sandbox.md

## Map API test keys: create one in the dashboard

The Map API sandbox server is the Map API on a separate server, for building with a test key and no live key at all. Every route runs there with no plan to turn on and nothing metered, and every request is in test mode. Already calling the live API? Add `sandbox=true` instead: see [Test mode](https://offendersearch.app/docs/map/test-mode.md). Both draw the same made-up people.

1. In the dashboard, open [Developers → API keys](https://offendersearch.app/dashboard/keys).
2. Choose **Create test key**. Test keys are for business accounts and need no subscription.
3. Copy the key. Test keys begin `os_test_`. Like a live key, the full secret is shown once, so put it in your secrets store straight away.

One test key works on every API’s sandbox. Each key in the list is labelled **Test · Sandbox** or **Live**, with the base URL it works on. Send it in the same `X-API-Key` header the live API uses. An account holds up to 3 active test keys and can create 10 a day. A revoked test key stops working on the sandbox within a few minutes.

## Map API sandbox base URL vs live and test mode

|  | Sandbox | Production |
| --- | --- | --- |
| Base URL | `https://sandbox.offendersearch.app` | `https://api.offendersearch.app` |
| Key prefix | `os_test_` | `os_live_` |
| Data | The made-up test-mode people, around eight cities | Live registry data, once the Map API is turned on |
| Billing | Never billed, never recorded to usage | Your plan’s prices |
| `sandbox` field | `true` on every response | Absent |
| `X-Offendersearch-Environment` | `sandbox` on every response | Absent |

Each key works on its own server only, so a mixed-up configuration is caught on the first request rather than after a release. A test key sent to the live API, or a live key sent to the sandbox, is refused with `401` and a `detail` message that says so:

401 — a test key sent to the live API

```json
{
  "detail": "This is a test key (os_test_…). Test keys work only on the sandbox at https://sandbox.offendersearch.app, which answers with sample data. To search real records, send a live key (os_live_…) to https://api.offendersearch.app."
}
```

401 — a live key sent to the sandbox

```json
{
  "detail": "This is a live key. The sandbox at https://sandbox.offendersearch.app accepts test keys (os_test_…) only — create one in the dashboard under Developers → API keys. Send live keys to https://api.offendersearch.app."
}
```

A revoked test key gets `401` with: “This test key has been revoked. Create a new test key in the dashboard under Developers → API keys.”

Each test key can send 60 requests a minute and 2,000 a day, and one IP address 120 a minute, across every API on the sandbox. Past a limit the sandbox answers `429` with a `Retry-After` header. See [Sandbox rate limits](https://offendersearch.app/docs/sandbox.md#rate-limits).

## Map API sandbox scenarios

Every [Map API](https://offendersearch.app/docs/map.md) route runs on the sandbox, with no plan to turn on and nothing metered. It draws the same made-up people as [test mode](https://offendersearch.app/docs/map/test-mode.md) on the live API, around Columbus, Cleveland, Cincinnati, Chicago, New York, Los Angeles, Houston and Seattle. You do not add `sandbox=true` on the sandbox: every request there is test mode. The counts below are fixed.

| Send | What comes back |
| --- | --- |
| `GET /v1/map/viewport`, `bbox=-83.20,39.85,-82.80,40.10` | **A city view: clusters.** Zoom 11 over Columbus, OH: mode clusters, 529 people in 63 clusters. |
| `GET /v1/map/viewport`, `center=39.9612,-82.9988`, `radiusMiles=1` | **One mile around a point: named pins.** Zoom 15: mode markers, exactly 37 people within the mile (withinRadius 37), 15 named pins plus one approximate-area pin, each with distanceMiles. |
| `GET /v1/map/viewport`, `bbox=-83.0060,39.9665,-83.0025,39.9690` | **Many people at one address.** One street-precision pin holding 14 people (a groupId, no inline members); GET /v1/map/groups/{groupId} pages through the 14, all housingStatus homeless. |
| `GET /v1/map/viewport`, `bbox=-83.0130,39.9680,-83.0100,39.9700` | **An approximate-area pin.** No street pins; one area pin (precision zip, label 43215) for 9 people whose registry gives only a ZIP code. |
| `POST /v1/map/area` | **A drawn polygon.** shape polygon: 22 people inside, 9 pins (the 14-person address is one of them). |
| `GET /v1/map/records/{markerId}`, `markerId from any named pin of map-radius-pins` | **Open the record behind a pin.** The complete record (a 'Sample …' person, Sampleville, ZIP 00000), a recordRef that opens it again later, and billing {billed: false, reason: sandbox}. |
| `POST /v1/map/route` | **A walking route with a stop.** 2.118 miles, 4 route units: 2 people within the 0.1-mile corridor, 1 within 1,000 feet of stop s1, and 9 more placed only to a ZIP (approximate.count 9). |
| `POST /v1/map/routes` | **Two routes in one call.** shape routes, 8 units, uniquePeople 2 (the same two people on both routes). |
| `GET /v1/map/status` | **Plan and usage.** enabled true, sandbox true, the published rates, and zero usage this month. |
| `POST /v1/map/enable` | **Turning it on or off.** The sandbox map is always on: enable and disable both answer the status with changed false, and nothing is charged. |

GET /v1/map/viewport — sandbox

```bash
curl "https://sandbox.offendersearch.app/v1/map/viewport?bbox=-83.20,39.85,-82.80,40.10" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
```

## Map API sandbox examples in cURL, Node and Python

A city view: clusters, called three ways. No sandbox=true is needed on the sandbox server. Export your test key as `OFFENDERSEARCH_TEST_KEY` first.

GET /v1/map/viewport — sandbox — cURL

```bash
curl "https://sandbox.offendersearch.app/v1/map/viewport?bbox=-83.20,39.85,-82.80,40.10" \
  -H "X-API-Key: $OFFENDERSEARCH_TEST_KEY"
```

GET /v1/map/viewport — sandbox — Node

```javascript
// Node 18+ — fetch is built in.
const BASE = process.env.OFFENDERSEARCH_BASE ?? 'https://sandbox.offendersearch.app';

const res = await fetch(`${BASE}/v1/map/viewport?${new URLSearchParams({"bbox":"-83.20,39.85,-82.80,40.10"})}`, {
  headers: {
    'X-API-Key': process.env.OFFENDERSEARCH_TEST_KEY,
  },
});
const body = await res.json();

console.log(res.headers.get('X-Offendersearch-Environment')); // "sandbox"
console.log(body.sandbox);  // true
console.log(body.mode);
```

GET /v1/map/viewport — sandbox — Python

```python
import os
import requests

BASE = os.environ.get("OFFENDERSEARCH_BASE", "https://sandbox.offendersearch.app")

r = requests.get(
    f"{BASE}/v1/map/viewport",
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_TEST_KEY"]},
    params={
        "bbox": "-83.20,39.85,-82.80,40.10",
    },
    timeout=30,
)
body = r.json()

print(r.headers["X-Offendersearch-Environment"])  # sandbox
print(body["sandbox"])  # True
print(body["mode"])
```

## Postman collection for the Map API sandbox

One collection covers every API’s sandbox, with a folder per API, ready to import into Postman or Insomnia: https://offendersearch.app/downloads/offendersearch-sandbox.postman_collection.json. The **Offender Map API** folder has one request for every scenario on this page. Set `{{apiKey}}` to your test key; `{{baseUrl}}` is preset to the sandbox. Ids from earlier responses go in `{{markerId}}`.

## Move the Map API from the sandbox to production

The sandbox and the live API differ only in the base URL and the key. Keep both in configuration, not in code, and going live is a configuration change:

.env

```bash
# Sandbox
OFFENDERSEARCH_BASE=https://sandbox.offendersearch.app
OFFENDERSEARCH_KEY=os_test_…

# Production — change these two values and nothing else
OFFENDERSEARCH_BASE=https://api.offendersearch.app
OFFENDERSEARCH_KEY=os_live_…
```

- Create a live key on the same [Developers → API keys](https://offendersearch.app/dashboard/keys) page.
- Remove any branch that reads `sandbox`. The field does not exist on live.
- Turn the Map API on for the live account: see [Turn it on](https://offendersearch.app/docs/map/turn-it-on.md). Live map requests and record opens are billed; see [Billing](https://offendersearch.app/docs/map/billing.md).
- For a browser map, create a map browser key for your sites: see [Access & keys](https://offendersearch.app/docs/map/access.md).

---

# The viewport request

> Every parameter of the viewport route: a box or a centre and radius, how the zoom picks clusters, pins or named pins, the pin cap, and the caching headers.

HTML: https://offendersearch.app/docs/map/viewport · Markdown: https://offendersearch.app/docs/map/viewport.md

## The request

`GET /v1/map/viewport` answers one map view. Call it when the map first loads and each time it stops moving. One call is one map request.

GET /v1/map/viewport

```bash
curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d bbox=-83.2,39.8,-82.8,40.1 \
  -d zoom=10
```

## Parameters

Every query parameter and header, with its type, default and an example, is in the [request table](https://offendersearch.app/docs/map/viewport.md#reference-get-v1-map-viewport) below. The ones a map sends on every call are `bbox` and `zoom`; `center` with `radiusMiles` answers a circle instead (see [Near an address](https://offendersearch.app/docs/map/near-an-address.md)), and `sandbox=true` puts the request in [test mode](https://offendersearch.app/docs/map/test-mode.md).

Send `bbox`, or `center` with `radiusMiles`. Not both.

The optional `X-End-User` header carries any id you choose for the person using the map. It is read on this route and on `POST /v1/map/area`. See [Limits & errors](https://offendersearch.app/docs/map/limits-and-errors.md).

## How the zoom picks the detail level

| Effective zoom | `mode` | Returned |
| --- | --- | --- |
| 3–12 | `clusters` | A count for each grid cell, with its extent and a breakdown. |
| 13–14 | `pins` | One pin for each address: a position, with no name. |
| 15+ | `markers` | Pins with a name label and basic fields. |

The effective zoom is worked out like this:

1. It starts as the `zoom` you sent. If you sent none, it is the zoom at which the box is about four map tiles wide.
2. If the box covers more than 64 map tiles at that zoom, the zoom is lowered until it fits.
3. It is never lower than 3.

The response states the zoom that was used, in `zoom`, and the level, in `mode`. Draw from `mode`, not from your own zoom.

## Any size of area

There is no largest box. A view of a whole country is answered as clusters, in one map request, at the same price as a view of one street. A wide box is simply answered at a lower zoom.

## The 300-pin rule

A response holds at most 300 pins. A view at zoom 13 or closer with more than that is answered as clusters on a finer grid, with `mode: "clusters"`. Each cluster’s `expansionZoom` says how far to zoom for it to come apart.

## The response

200 OK

```json
{
  "mode": "clusters",
  "zoom": 10,
  "bbox": [-83.2, 39.8, -82.8, 40.1],
  "datasetVersion": "2026-10-12",
  "total": 508,
  "breakdown": {
    "street": 508, "housed": 247, "homeless": 96, "incarcerated": 0
  },
  "clusters": [
    {
      "id": "c10_1103_1551",
      "lat": 39.9612, "lng": -82.9988,
      "count": 412,
      "bbox": [-83.06, 39.91, -82.97, 39.98],
      "expansionZoom": 11,
      "breakdown": {
        "street": 412, "housed": 201, "homeless": 77, "incarcerated": 0
      }
    }
  ],
  "markers": [],
  "areas": [],
  "unplaced": {"count": 96, "states": ["OH"]},
  "notice": "A pin shows a registered address, not a person's current location.",
  "sandbox": true
}
```

Every response field is in the [response table](https://offendersearch.app/docs/map/viewport.md#reference-get-v1-map-viewport) below. What to draw is in `clusters`, `markers` and `areas`, and each of those objects is defined once, in [Clusters & markers](https://offendersearch.app/docs/map/clusters-and-markers.md).

Markers are sorted by distance from the centre of the view.

## Sex offender map API: GET /v1/map/viewport request and response fields

`GET /v1/map/viewport` · auth: `X-API-Key`

What to draw for one map view: clusters, pins or named pins of registered sex offenders. Send the view as `bbox`, or as `center` with `radiusMiles`. One map request per answer, whatever the view holds; a `304`, test mode and errors are free.

### Request

**Headers**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | A secret API key (from your server), or a map browser key `os_map_pk_…` sent from a site on that key's list. A dashboard session can send `Authorization: Bearer <token>` instead. |
| `X-End-User` | `string` | optional | Any id you use for the person behind this request. Their pin views are then also counted against the per-end-user daily area budget (2,000 distinct areas), so one heavy user is stopped before the account is. |
| `If-None-Match` | `string` | optional | The `ETag` of an earlier answer for the same view. An unchanged view answers `304` with no body and is not billed. |

**Query parameters**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `bbox` | `string` | optional | The view, `west,south,east,north` in degrees. Any size: a wide box is answered at a lower zoom, never refused. A box crossing the date line has west greater than east. Send `bbox`, or `center` with `radiusMiles`, not both. Example `"-83.5,39.6,-82.4,40.3"`. |
| `center` | `string` | optional | Instead of `bbox`: `lat,lng` in degrees. Requires `radiusMiles`. Only what is inside the circle comes back. Example `"39.9612,-82.9988"`. |
| `radiusMiles` | `number` | optional | With `center`: the radius in miles, 0.1 to 100. Example `0.5`. |
| `zoom` | `number` | optional | The map's current zoom, 0 to 22; a fraction is floored. Leave it out and the zoom at which the box is about four tiles wide is used. Example `13`. |
| `format` | `string` — one of `"json"`, `"geojson"` | optional · default `"json"` | `json` for the response below, `geojson` for a [MapGeoJson](https://offendersearch.app/docs/map/clusters-and-markers#object-mapgeojson) FeatureCollection (`application/geo+json`). |
| `detail` | `string` — one of `"full"`, `"count"` | optional · default `"full"` | `full` for what to draw, `count` for numbers only (`mode: "count"`, no ids). |
| `housing` | `string` | optional | Keep only people with these housing statuses: one or more of `housed`, `homeless`, `incarcerated`, comma-separated. Leave it out to keep everyone. Example `"homeless,incarcerated"`. |
| `precision` | `string` — one of `"street"`, `"any"` | optional | `street` keeps only people with an exact street address. `any`, or leaving it out, keeps everyone. |
| `sandbox` | `boolean` | optional · default `false` | `true` answers in test mode: made-up people around eight US cities, in the real response shape, never billed, no plan needed. |

### Response

**200** (application/json) — The answer for this view. Every answer carries `Cache-Control: private, max-age=300` and an `ETag`.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | `string` — one of `"clusters"`, `"pins"`, `"markers"`, `"count"` |  | What this answer holds. `clusters` at zoom 3 to 12, or for a closer view holding more than 300 pins. `pins` at zoom 13 and 14. `markers` (pins with names) at zoom 15 and closer. `count` with `detail=count`: numbers only, and `clusters`, `markers` and `areas` are empty. |
| `zoom` | `integer` |  | The effective zoom this view was answered at: your `zoom` (or the one derived from the box), lowered until the box fits in 64 map tiles, never below 3. |
| `bbox` | `array of number` |  | The box answered, `[west, south, east, north]` in degrees. For a radius request it is the box around the circle; for a drawn shape, the shape's bounding box. |
| `datasetVersion` | `string (date)` |  | The date of the map data that answered. Example `"2026-10-12"`. |
| `total` | `integer` |  | People in the view, each counted once. In `clusters` mode: the people in the clusters returned. On a radius request: the people inside the circle, equal to `withinRadius`. |
| `breakdown` | [MapBreakdown](https://offendersearch.app/docs/map/clusters-and-markers#object-mapbreakdown) |  | The four counts for `total`. |
| `clusters` | array of [MapCluster](https://offendersearch.app/docs/map/clusters-and-markers#object-mapcluster) |  | The clusters to draw, largest first. `[]` outside `clusters` mode. |
| `markers` | `array of object` |  | The pins to draw, nearest the centre of the view first. Each entry is one person's pin (a [MapMarker](https://offendersearch.app/docs/map/clusters-and-markers#object-mapmarker)) or a many-people pin (a [MapGroupPin](https://offendersearch.app/docs/map/clusters-and-markers#object-mapgrouppin), `id: null` with a `groupId`). `[]` in `clusters` and `count` mode. |
| `areas` | array of [MapArea](https://offendersearch.app/docs/map/clusters-and-markers#object-maparea) |  | Approximate-area pins. `[]` in `clusters` and `count` mode. |
| `unplaced` | [MapUnplaced](https://offendersearch.app/docs/map/clusters-and-markers#object-mapunplaced) |  | People in the states this view touches who have no mappable address. |
| `center` | `array of number` |  | `[lat, lng]` of the circle answered. Present only on a `center` + `radiusMiles` request. |
| `radiusMiles` | `number` |  | The radius answered, in miles. Present only on a radius request. |
| `withinRadius` | `integer` |  | The exact number of people inside the circle, at every detail level. Present only on a radius request. |
| `shape` | `string` — one of `"polygon"`, `"route"` |  | `polygon` or `route`: the shape that was drawn. Present only on a `POST /v1/map/area` answer. |
| `totalIsEstimate` | `boolean` |  | Present, and `true`, only on a `POST /v1/map/area` answer counted from clusters (`clusters` mode, or `count` mode at a cluster zoom): a cluster is counted when its centre is inside the shape. Zoom in or draw a smaller shape for an exact `total`. |
| `sandbox` | `boolean` |  | Present, and `true`, only on a test-mode answer. |
| `notice` | `string` |  | A sentence to show on the map, on every answer. Example `"A pin shows a registered address, not a person's current location."`. |

**304** (no body) — The view has not changed since the `ETag` sent in `If-None-Match`. No body, not billed: draw the body you kept.

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | The key is missing or invalid. The body is the plain `detail` string. |
| 403 | `map_not_enabled` | The Map API is off for this account (`map_paused` when we paused it, `origin_not_allowed` for a browser key used from a site not on its list). Send `sandbox=true` to build first. |
| 422 | `invalid_bbox` | A parameter cannot be read. The code names it: `invalid_bbox`, `invalid_center`, `invalid_radius`, `invalid_zoom`, `invalid_filter`, `invalid_format`, `invalid_detail`. |
| 429 | `map_busy` | Too many map requests at the same moment (`Retry-After: 1`). `map_sweep_limit` and `map_end_user_limit` when a daily area budget is used (clusters still work); `overloaded` when the service is at capacity. |
| 503 | `map_unavailable` | The map could not be read just now (`map_index_not_ready`, `map_not_configured` likewise). Nothing was charged; retry after `Retry-After`. |

## Caching

Every answer carries `Cache-Control: private, max-age=300` and an `ETag`.

- Send the `ETag` back in `If-None-Match`. An unchanged view answers `304`, with no body. **A `304` is not billed.**
- The `ETag` changes when the map data changes, and at least every 12 hours, so marker ids are never kept past their life.
- Keep the body with its `ETag`. On a `304`, draw the body you kept.
- The answer is private to your account. Do not put it in a shared cache.

## Errors

A bad parameter answers `422` with a code that names it: `invalid_bbox`, `invalid_center`, `invalid_radius`, `invalid_zoom`, `invalid_filter`, `invalid_format`, `invalid_detail`. All codes are in [Limits & errors](https://offendersearch.app/docs/map/limits-and-errors.md).

---

# Clusters & markers

> Field reference for everything a map view returns: clusters, pins, named pins, many-people pins, approximate areas, the breakdown, unplaced people and GeoJSON.

HTML: https://offendersearch.app/docs/map/clusters-and-markers · Markdown: https://offendersearch.app/docs/map/clusters-and-markers.md

## What a view can contain

| Array | Object | When |
| --- | --- | --- |
| `clusters` | A cluster: a count for a grid cell. [MapCluster](https://offendersearch.app/docs/map/clusters-and-markers.md#object-mapcluster) | Zoom 3–12, and closer views that are too dense for pins. |
| `markers` | A pin: one person at one address. [MapMarker](https://offendersearch.app/docs/map/clusters-and-markers.md#object-mapmarker) | Zoom 13–14, with no name. |
| `markers` | A named pin. [MapMarker](https://offendersearch.app/docs/map/clusters-and-markers.md#object-mapmarker) | Zoom 15+. |
| `markers` | A many-people pin: several people at one address. [MapGroupPin](https://offendersearch.app/docs/map/clusters-and-markers.md#object-mapgrouppin) | From zoom 13. |
| `areas` | An approximate-area pin: people known only to an area. [MapArea](https://offendersearch.app/docs/map/clusters-and-markers.md#object-maparea) | From zoom 13. |

## Cluster

A cluster never traps the user. People who share one address never split, so at its `expansionZoom` such a cluster becomes one pin with a count and a `groupId`.

## Cluster fields: the MapCluster object

A count for one grid cell, returned in `clusters` mode (zoom 3 to 12, and closer views holding more than 300 pins). Draw it as a circle with `count` inside.

Returned or accepted by [`GET /v1/map/viewport`](https://offendersearch.app/docs/map/viewport#reference-get-v1-map-viewport), [`POST /v1/map/area`](https://offendersearch.app/docs/map/drawn-areas#reference-post-v1-map-area).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | The cluster's id, stable for the same cell at the same zoom. Use it as the key when you redraw. Example `"c9_551_775"`. |
| `lat` | `number` |  | Latitude to draw the cluster at, in degrees, rounded to 5 decimals. |
| `lng` | `number` |  | Longitude to draw the cluster at, in degrees, rounded to 5 decimals. |
| `count` | `integer` |  | People in the cluster after your `housing` and `precision` filters. Always 1 or more: an empty cell is never returned. |
| `bbox` | `array of number` |  | The extent of the people in the cluster, `[west, south, east, north]` in degrees. |
| `expansionZoom` | `integer` |  | Fit the map to `bbox` at this zoom and the cluster comes apart into smaller clusters, pins, or one many-people pin. |
| `breakdown` | [MapBreakdown](https://offendersearch.app/docs/map/clusters-and-markers#object-mapbreakdown) |  | The four counts for this cluster. |

## Pin

At zoom 13–14 a pin is a position and nothing that identifies anyone.

mode: pins

```json
"mode": "pins",
"markers": [
  {
    "id": "mk_Qa8…",
    "lat": 39.96123, "lng": -82.99881,
    "precision": "street",
    "addressType": "home"
  }
]
```

A person with two mapped addresses, such as home and work, has two pins. `total` counts that person once.

## Named pin

From zoom 15 each pin also carries a label and basic fields.

mode: markers

```json
"mode": "markers",
"markers": [
  {
    "id": "mk_Zb3…",
    "lat": 39.96123, "lng": -82.99881,
    "precision": "street",
    "addressType": "home",
    "label": "Sample J. Tester",
    "housingStatus": "housed",
    "hasPhoto": true,
    "registryState": "OH"
  },
  {
    "id": null,
    "lat": 39.9678, "lng": -83.0042,
    "precision": "street",
    "count": 6,
    "groupId": "mg_8Uo…",
    "members": [
      {
        "id": "mk_…",
        "addressType": "home",
        "label": "Sample S. Demo",
        "housingStatus": null,
        "hasPhoto": false
      }
    ]
  }
],
"areas": [
  {
    "lat": 39.969, "lng": -83.0114,
    "count": 9,
    "precision": "zip",
    "label": "43215",
    "groupId": "mg_X1G…"
  }
]
```

These fields are enough for a popup. They cost nothing beyond the map request that returned them.

## Pin and named pin fields: the MapMarker object

One person at one exact street address, in `markers[]`. In `pins` mode (zoom 13 and 14) it is a position and nothing that identifies anyone. In `markers` mode (zoom 15 and closer) it also carries `label`, `housingStatus`, `hasPhoto` and `registryState`. A person with two mapped addresses has two pins; `total` counts that person once.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | The marker id, `mk_…`. Pass it to `GET /v1/map/records/{markerId}` to open the record. It works for 24 hours, only for the account it was issued to; do not store it, store the `recordRef` of the opened record instead. |
| `lat` | `number` |  | Latitude of the registered address, in degrees. |
| `lng` | `number` |  | Longitude of the registered address, in degrees. |
| `precision` | `string` — one of `"street"` |  | Always `street`: an exact pin is drawn only for an exact street address. People known only to an area are in `areas[]`. |
| `addressType` | `string` — one of `"home"`, `"work"`, `"school"`, `"other"` |  | Which registered address this pin is. |
| `label` | `string` |  | First initial and last name, for example `J. Smith` (`John Smith` on a plan with full names, see `markerFields` on the status route). Present only in `markers` mode; absent in `pins` mode. |
| `housingStatus` | `string` or null — one of `"housed"`, `"homeless"`, `"incarcerated"` |  | Housing status as the registry states it. `null` when the registry does not state one; `null` does not mean housed. Present only in `markers` mode. |
| `hasPhoto` | `boolean` |  | `true` when the record has a photo. Present only in `markers` mode. |
| `registryState` | `string` or null |  | Two-letter code of the state whose registry lists the person; for a registry that is not a state registry, the state of residence on file. `null` when neither is known. Present only in `markers` mode. Example `"OH"`. |
| `distanceMiles` | `number` |  | Miles from `center` to the pin, rounded to 2 decimals. Present only on a `center` + `radiusMiles` request. |

## Many-people pin

Several people at one address are one pin. It has `id: null`, a `count` and a `groupId`.

## Many-people pin fields: the MapGroupPin object

Several people at one exact street address, drawn as one pin in `markers[]`. Tell it apart from a single pin by `id: null` and a `groupId`. List the people with `GET /v1/map/groups/{groupId}`.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `null` |  | Always `null`: there is no single record behind this pin. |
| `lat` | `number` |  | Latitude of the address, in degrees. |
| `lng` | `number` |  | Longitude of the address, in degrees. |
| `precision` | `string` — one of `"street"` |  | Always `street`. |
| `count` | `integer` |  | Distinct people at this address. |
| `groupId` | `string` |  | The group id, `mg_…`. Pass it to `GET /v1/map/groups/{groupId}`. Works for 24 hours, for the account it was issued to. |
| `members` | array of [MapMember](https://offendersearch.app/docs/map/clusters-and-markers#object-mapmember) |  | The people, inline, when the view is at zoom 15 or closer and the address holds 10 people or fewer. Absent otherwise; call the groups route. |
| `distanceMiles` | `number` |  | Miles from `center`, rounded to 2 decimals. Present only on a `center` + `radiusMiles` request. |

## Approximate-area pin

Some people are known only to the middle of a ZIP, a city or a county. People in custody are always here, as are people without a fixed address whose registered address has no street number. They come back in `areas`, never in `markers`, so they are never drawn as an exact pin.

Draw an area pin so it does not look like an address: a dashed circle, a different colour, and the word “approximate”.

## Approximate-area pin fields: the MapArea object

People known only to the middle of a ZIP code, a city or a county, in `areas[]`. People in custody are always here, as are people without a fixed address whose registered address has no street number. An area is never drawn as an exact pin: draw it so it does not look like an address.

Returned or accepted by [`GET /v1/map/viewport`](https://offendersearch.app/docs/map/viewport#reference-get-v1-map-viewport), [`POST /v1/map/area`](https://offendersearch.app/docs/map/drawn-areas#reference-post-v1-map-area).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `lat` | `number` |  | Latitude of the middle of the area, in degrees. |
| `lng` | `number` |  | Longitude of the middle of the area, in degrees. |
| `count` | `integer` |  | Distinct people in the area. |
| `precision` | `string` — one of `"zip"`, `"city"`, `"county"` |  | What the coordinate is the middle of. |
| `label` | `string` |  | The area's name: a ZIP code, a city or a county. Falls back to the state code, or `Approximate area`, when the area has no name on file. Example `"43215"`. |
| `groupId` | `string` |  | The group id, `mg_…`. Pass it to `GET /v1/map/groups/{groupId}` for the people. |
| `distanceMiles` | `number` |  | Miles from `center`, rounded to 2 decimals. Present only on a `center` + `radiusMiles` request. |

## The groups route

`GET /v1/map/groups/{groupId}` lists the people behind a many-people pin or an approximate-area pin. Each page is one map request.

GET /v1/map/groups/{groupId}

```bash
curl "https://api.offendersearch.app/v1/map/groups/mg_8Uo…?page=1&perPage=50" \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

200 OK

```json
{
  "total": 6,
  "page": 1,
  "perPage": 50,
  "totalPages": 1,
  "kind": "address",
  "named": true,
  "members": [
    {
      "id": "mk_…",
      "addressType": "home",
      "label": "Sample S. Demo",
      "housingStatus": null,
      "hasPhoto": false
    }
  ],
  "notice": "A pin shows a registered address, not a person's current location."
}
```

## Sex offender map API: GET /v1/map/groups/{groupId} request and response fields

`GET /v1/map/groups/{groupId}` · auth: `X-API-Key`

One page of the people behind a many-people pin or an approximate-area pin. One map request per page; test mode and errors are free.

### Request

**Headers**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | A secret API key (from your server), or a map browser key `os_map_pk_…` sent from a site on that key's list. A dashboard session can send `Authorization: Bearer <token>` instead. |

**Path parameters**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `groupId` | `string` | required | A `groupId` (`mg_…`) from a viewport or area answer. Works for 24 hours, only for the account it was issued to. |

**Query parameters**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `page` | `integer` | optional · default `1` | The page, starting at 1. |
| `perPage` | `integer` | optional · default `50` | People per page, 1 to 100. |

### Response

**200** (application/json) — One page of the group.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `total` | `integer` |  | People in the group. |
| `page` | `integer` |  | The page returned, as you sent it. |
| `perPage` | `integer` |  | The page size applied, as you sent it. |
| `totalPages` | `integer` |  | Pages at this `perPage`. At least 1. |
| `kind` | `string` — one of `"address"`, `"area"` |  | `address` for several people at one address (a many-people pin), `area` for an approximate-area pin. |
| `named` | `boolean` |  | `true` when the group id came from a view at zoom 15 or closer. Members carry `label`, `housingStatus` and `hasPhoto` only then. |
| `members` | array of [MapMember](https://offendersearch.app/docs/map/clusters-and-markers#object-mapmember) |  | The people on this page. Each `id` opens with `GET /v1/map/records/{markerId}`. |
| `sandbox` | `boolean` |  | Present, and `true`, only on a test-mode answer. |
| `notice` | `string` |  | A sentence to show with the list, on every answer. Example `"A pin shows a registered address, not a person's current location."`. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | The key is missing or invalid. The body is the plain `detail` string. |
| 403 | `map_not_enabled` | The Map API is off for this account (`map_paused` when we paused it, `origin_not_allowed` for a browser key used from a site not on its list). Send `sandbox=true` to build first. |
| 404 | `unknown_group` | Not a group id this account was given. |
| 410 | `group_expired` | The group id is older than 24 hours. Request the map view again for a new one. |
| 422 | `invalid_page` | `page` is below 1, or `perPage` is not 1 to 100. |
| 429 | `map_busy` | Too many map requests at the same moment (`Retry-After: 1`). This route never counts against the daily area budget. |
| 503 | `map_unavailable` | The map could not be read just now (`map_index_not_ready`, `map_not_configured` likewise). Nothing was charged; retry after `Retry-After`. |

## Group member fields: the MapMember object

One person behind a many-people pin or an approximate-area pin: in `members[]` of a groups response, and inline on a many-people pin at zoom 15 and closer. `label`, `housingStatus` and `hasPhoto` are present only when the group came from a view at zoom 15 or closer (`named: true`); otherwise a member is `id` and `addressType` only.

Returned or accepted by [`GET /v1/map/groups/{groupId}`](https://offendersearch.app/docs/map/clusters-and-markers#reference-get-v1-map-groups).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | The marker id, `mk_…`. Pass it to `GET /v1/map/records/{markerId}`. Works for 24 hours, for the account it was issued to. |
| `addressType` | `string` — one of `"home"`, `"work"`, `"school"`, `"other"` |  | Which registered address put this person in the group. |
| `label` | `string` |  | First initial and last name, for example `S. Jones`. Present only on a named group. |
| `housingStatus` | `string` or null — one of `"housed"`, `"homeless"`, `"incarcerated"` |  | Housing status as the registry states it; `null` when the registry does not state one. Present only on a named group. |
| `hasPhoto` | `boolean` |  | `true` when the record has a photo. Present only on a named group. |

## The breakdown

`breakdown` is on the view and on every cluster. Its four numbers are not a split of the total: one person can count in two of them.

People whose registry does not state a housing status are in `total` and in none of the three housing numbers.

## The breakdown object: MapBreakdown fields

Four counts of the people in a view or a cluster. They are not a split of the total: one person counts in `street` and in a housing number. A person whose registry states no housing status is in `total` and in none of the three housing counts.

Returned or accepted by [`GET /v1/map/viewport`](https://offendersearch.app/docs/map/viewport#reference-get-v1-map-viewport), [`POST /v1/map/area`](https://offendersearch.app/docs/map/drawn-areas#reference-post-v1-map-area), [`POST /v1/map/route`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route), [`POST /v1/map/routes`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-routes).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `street` | `integer` |  | People with an exact street address. |
| `housed` | `integer` |  | People the registry lists as housed. |
| `homeless` | `integer` |  | People the registry lists as homeless. |
| `incarcerated` | `integer` |  | People the registry lists as in custody. |

## Unplaced people

`unplaced` counts people in the states the view touches who have no mappable address. They are on a registry, and they are not on the map.

unplaced

```json
"unplaced": {"count": 96, "states": ["OH"]}
```

Say so next to the map, so a short list of pins is not read as the whole list. For example: “96 more people are registered in Ohio with no mappable address. Search by name to find them.” The count is for whole states, not for the box.

## Unplaced people: the MapUnplaced object

People in the states this view touches who have no mappable address. They are on a registry and they are not on the map: show the count next to the map so a short list of pins is not read as the whole list. The count is for whole states, not for the box.

Returned or accepted by [`GET /v1/map/viewport`](https://offendersearch.app/docs/map/viewport#reference-get-v1-map-viewport), [`POST /v1/map/area`](https://offendersearch.app/docs/map/drawn-areas#reference-post-v1-map-area), [`POST /v1/map/route`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route), [`POST /v1/map/routes`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-routes).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `count` | `integer` |  | People with no mappable address in `states`. |
| `states` | `array of string` |  | Two-letter codes of the states the view touches. Example `["OH"]`. |

## GeoJSON

With `format=geojson` the answer is a `FeatureCollection` of points, with content type `application/geo+json`. It can go straight into a map library’s GeoJSON source.

format=geojson

```json
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {"type": "Point", "coordinates": [-82.9988, 39.9612]},
      "properties": {
        "cluster": true,
        "cluster_id": "c10_1103_1551",
        "point_count": 412,
        "point_count_abbreviated": "412",
        "expansion_zoom": 11,
        "bbox": [-83.06, 39.91, -82.97, 39.98],
        "breakdown_street": 412,
        "breakdown_housed": 201,
        "breakdown_homeless": 77,
        "breakdown_incarcerated": 0
      }
    }
  ],
  "meta": {"mode": "clusters", "zoom": 10, "total": 508}
}
```

Everything that is not a feature, such as `mode`, `zoom`, `total`, `unplaced` and `notice`, is under `meta`. For a heat map, use the cluster features with `point_count` as the weight.

## GeoJSON response fields: the MapGeoJson object

The same answer as a GeoJSON `FeatureCollection` of points, returned with `format=geojson` and content type `application/geo+json`. It goes straight into a map library's GeoJSON source. A route answer with `format: "geojson"` has the same shape: one point per marker (`kind: "marker"`), group (`kind: "group"`) and approximate area (`kind: "area"`), with the rest of the route answer under `meta`.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | `string` — one of `"FeatureCollection"` |  | Always `FeatureCollection`. |
| `features` | `array of object` |  | One point feature per cluster, pin, many-people pin and area. |
| `features[].type` | `string` — one of `"Feature"` |  | Always `Feature`. |
| `features[].geometry` | `object` |  | The point. |
| `features[].geometry.type` | `string` — one of `"Point"` |  | Always `Point`. |
| `features[].geometry.coordinates` | `array of number` |  | `[lng, lat]`, longitude first. |
| `features[].properties` | `object` |  | A cluster carries `cluster: true`, `cluster_id`, `point_count`, `point_count_abbreviated`, `expansion_zoom`, `bbox` and `breakdown_street`, `breakdown_housed`, `breakdown_homeless`, `breakdown_incarcerated` (the names MapLibre and Mapbox GL cluster layers style). A pin carries its [MapMarker](https://offendersearch.app/docs/map/clusters-and-markers#object-mapmarker) fields except `lat`/`lng`, plus `kind: "marker"`. A many-people pin carries its [MapGroupPin](https://offendersearch.app/docs/map/clusters-and-markers#object-mapgrouppin) fields plus `kind: "group"`. An area carries its [MapArea](https://offendersearch.app/docs/map/clusters-and-markers#object-maparea) fields plus `kind: "area"`. |
| `meta` | `object` |  | Every other field of the JSON answer (`mode`, `zoom`, `bbox`, `total`, `breakdown`, `unplaced`, `notice` and the rest): the [viewport response](https://offendersearch.app/docs/map/viewport#reference-get-v1-map-viewport) without `clusters`, `markers` and `areas`. |

---

# The click sequence

> What your app calls at each step of using a map, from the first view to an opened record, with what comes back and what is charged at every one of the steps.

HTML: https://offendersearch.app/docs/map/click-sequence · Markdown: https://offendersearch.app/docs/map/click-sequence.md

## The sequence

Each row is one thing a person does on your map: what your app calls, what comes back, and what it costs.

| # | The person | Your app calls | We return | Charge |
| --- | --- | --- | --- | --- |
| 1 | Opens the map: a default view, their location, or a typed address. | `viewport` with the box | Clusters, pins or named pins for that zoom. | 1 map request |
| 2 | Pans or zooms. | `viewport` again, once the map settles | The new view. | 1 map request |
| 3 | Hovers a cluster. | Nothing | The count and breakdown are already on the cluster. | Free |
| 4 | Clicks a cluster. | Nothing. Fit the map to the cluster’s `bbox` at its `expansionZoom`. That moves the map, which is step 2. | — | Step 2 |
| 5 | Clicks a pin that has no name yet (zoom 13–14). | Your choice: zoom in (step 2), or open the record (step 8). | — | Step 2 or step 8 |
| 6 | Clicks a named pin. | Nothing. The popup uses the label, housing status and photo flag already on the pin. | — | Free |
| 7 | Clicks a pin that stands for several people at one address, or an approximate-area pin. | `groups/{groupId}` | The people in it, a page at a time: an id each, and a label when the pin came from a named view. | 1 map request per page |
| 8 | Clicks “View record”. | `records/{markerId}` | The complete record, with the photo. | 1 record open |
| 9 | Closes the record and opens it again. | `records/{markerId}` with the same id | The same record. | Free |
| 10 | Moves the map, comes back, and opens the same person. | `viewport`, then `records/{newId}` | New ids, and the record. | 1 map request + 1 record open |
| 11 | Shares or bookmarks a record. | Store the `recordRef` from step 8. Later: `records/{recordRef}`. | The record. | 1 record open, then free for 24 hours |
| 12 | Asks to be told when someone moves into this area. | The Monitoring API: `POST /v1/monitors` with a location. | — | The monitoring price |

## The states

From a view to a record: the call and the charge on each step.

```text
[ Map view ]
     |  viewport                        1 map request
     v
[ Clusters ]      zoom 3–12
     |  click: fit bbox at expansionZoom, then viewport
     |                                  1 map request
     v
[ Pins ]          zoom 13–14
     |  zoom in, then viewport          1 map request
     v
[ Named pins ]    zoom 15+
     |  many-people or area pin: groups/{groupId}
     |                                  1 map request per page
     |  pin or list row: records/{markerId}
     |                                  1 record open
     v
[ Record ]        the same marker id again: free
```

## Notes on the steps

- **Step 2: wait for the map to settle.** Call on move end, after a short pause, and cancel the request you no longer need. A drag is then one map request, not one for every frame.
- **Step 2: send `If-None-Match`.** Coming back to a view you already have answers `304`, which is free. See [The viewport request](https://offendersearch.app/docs/map/viewport.md).
- **Step 4: a cluster never traps the user.** `expansionZoom` is the zoom at which it becomes smaller clusters, pins, or one many-people pin.
- **Step 7: the list is paged.** Each page is one map request. Members have labels when the pin came from a named view.
- **Steps 8 to 10: one charge for each person in each map response.** See [Open a record](https://offendersearch.app/docs/map/open-a-record.md).
- **Step 12: an area watch is a different product.** See [location monitoring](https://offendersearch.app/docs/monitoring/location-monitoring.md).

## In code

The three library examples run this whole sequence: [MapLibre](https://offendersearch.app/docs/map/maplibre.md), [Leaflet](https://offendersearch.app/docs/map/leaflet.md), [Google Maps](https://offendersearch.app/docs/map/google-maps.md).

---

# Open a record

> Open the complete record behind a marker: when a record open is charged, when it is free, how long marker ids last, and saved references for shared links.

HTML: https://offendersearch.app/docs/map/open-a-record · Markdown: https://offendersearch.app/docs/map/open-a-record.md

## The request

`GET /v1/map/records/{markerId}` returns the complete record behind a marker. It accepts a marker id, `mk_…`, or a saved reference, `mr_…`.

GET /v1/map/records/{markerId}

```bash
curl https://api.offendersearch.app/v1/map/records/mk_Zb3… \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

200 OK

```json
{
  "record": { "…the same record object POST /v1/search returns…" },
  "recordRef": "mr_k2P…",
  "billing": {
    "billed": true,
    "unit": "map_record_open",
    "reason": "first_open_of_this_marker"
  },
  "notice": "A pin shows a registered address, not a person's current location."
}
```

The body is `record` — the same record object `POST /v1/search` returns, defined once in [The Record object](https://offendersearch.app/docs/record-object.md) — plus a lasting `recordRef`, a `billing` block and a `notice`. Every field is in the [response table](https://offendersearch.app/docs/map/open-a-record.md#reference-get-v1-map-records) below.

## Sex offender map API: GET /v1/map/records/{markerId} request and response fields

`GET /v1/map/records/{markerId}` · auth: `X-API-Key`

The complete record behind a marker or a saved reference. One record open per person per map response, at the account's rate ($0.035 by default): the same marker id again is free, and so is a reference within 24 hours of an open. `record_no_longer_listed`, test mode and errors are not billed.

### Request

**Headers**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | A secret API key (from your server), or a map browser key `os_map_pk_…` sent from a site on that key's list. A dashboard session can send `Authorization: Bearer <token>` instead. |
| `X-End-User` | `string` | optional | Any id you use for the person opening the record. Read when a daily open limit for each end user is set on the account (`limits.endUserOpensPerDay`); otherwise optional. |

**Path parameters**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `markerId` | `string` | required | A marker id (`mk_…`) from a viewport, area or groups answer, or a saved reference (`mr_…`) from an opened record. |

### Response

**200** (application/json) — The record, its lasting reference and the billing outcome.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `record` | [Record](https://offendersearch.app/docs/record-object#record-fields) |  | The complete record: the same object `POST /v1/search` returns. Photo links to hosted photos are signed and work in an `<img>` tag for one hour. |
| `recordRef` | `string` |  | A lasting reference, `mr_…`, for bookmarks, shared links and your own database. It does not expire, and the same account and person always give the same string. Pass it to this route in place of a marker id. |
| `billing` | [MapBilling](https://offendersearch.app/docs/map/open-a-record#object-mapbilling) |  | Whether this open was charged, and why. |
| `sandbox` | `boolean` |  | Present, and `true`, only on a test-mode answer. |
| `notice` | `string` |  | A sentence to show with the record, on every answer. Example `"A pin shows a registered address, not a person's current location."`. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | The key is missing or invalid. The body is the plain `detail` string. |
| 403 | `map_not_enabled` | The Map API is off for this account (`map_paused` when we paused it, `origin_not_allowed` for a browser key used from a site not on its list). Send `sandbox=true` to build first. |
| 404 | `unknown_marker` | Not a marker id or reference this account was given. |
| 410 | `marker_expired` | The marker id is older than 24 hours. Request the view again, or open by the `recordRef`. |
| 410 | `record_no_longer_listed` | The registry no longer lists the person. Not billed: remove the pin. |
| 429 | `map_open_limit` | A daily limit on record opens is set for the account or this end user, and it is reached. A record already opened that day still opens; nothing was charged. It resets at 00:00 UTC. |
| 429 | `map_busy` | Too many map requests at the same moment (`Retry-After: 1`). This route never counts against the daily area budget. |
| 503 | `map_unavailable` | The record could not be read just now (`map_not_configured` likewise). Nothing was charged; retry after `Retry-After`. |

## When a record open is charged

A record open is the complete record behind one marker. Opening the same marker again is free. A new map request returns new marker ids, so opening that person from the new result is a new charge.

- **One charge for each person in each map response.** Opening a marker for the first time is one record open, $0.035.
- **The same marker id again is free.** Close the panel, open it again, reload the page: no charge. The response says `"reason": "marker_already_opened"`.
- **Two pins of one person are one charge.** A person with a home pin and a work pin in the same response is charged once.
- **A new map response gives new ids.** Every viewport or groups response carries new marker ids. Opening the same person from the new response is a new charge.
- **A `304` gives no new ids.** An unchanged view keeps the ids you already have, so re-opening from it stays free.

To keep opens free while a person browses, keep the marker ids from the response you drew and reuse them. Do not call the viewport route again just to open a record.

Marker ids from a route answer open the same way, at the same rate. In a `POST /v1/map/routes` answer every route shares one response, so a person near several routes is one record open. An account can also have a daily limit on record opens; see [Daily limits](https://offendersearch.app/docs/map/limits-and-errors.md#daily-limits).

## Record open billing: the MapBilling object

Whether this record open was charged, and why. Every `GET /v1/map/records/{markerId}` answer carries it.

Returned or accepted by [`GET /v1/map/records/{markerId}`](https://offendersearch.app/docs/map/open-a-record#reference-get-v1-map-records).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `billed` | `boolean` |  | `true` when this call was charged as one record open. |
| `unit` | `string` — one of `"map_record_open"` |  | The billing unit: always `map_record_open`. |
| `reason` | `string` — one of `"first_open_of_this_marker"`, `"marker_already_opened"`, `"saved_reference"`, `"opened_in_the_last_24_hours"`, `"sandbox"` |  | Why. `first_open_of_this_marker`: billed, the first open of this marker id. `marker_already_opened`: free, this marker id was opened before. `saved_reference`: billed, opened by `recordRef`. `opened_in_the_last_24_hours`: free, the reference was opened, or the record was opened from a marker, in the last 24 hours. `sandbox`: free, test mode. |

## How long ids last

Marker ids and group ids work for 24 hours, and only for the account they were issued to. After that the route answers `410 marker_expired` or `410 group_expired`. Request the view again for new ids.

Do not store a marker id in a database or a link. Store the `recordRef`.

## Saved links: recordRef

Every opened record comes with a `recordRef`. It does not expire. Put it in a bookmark, a shared link or your own database, and open it later with the same route. A saved reference follows the person: when a registry republishes the listing under a new number, the same `recordRef` still opens that person’s current record.

GET /v1/map/records/{markerId}

```bash
curl https://api.offendersearch.app/v1/map/records/mr_k2P… \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

- Opening by reference is one record open. It is then free for 24 hours.
- A record you just opened from a marker is also free by its reference for 24 hours.
- A reference works only for the account it was issued to.

## Photos

Photo links on hosted photos are signed. A link works in an `<img>` tag for one hour. Use it as it is, and do not store it: open the record again for a new link.

## Reports

The record carries the `recordId` that the report route takes. See [Verification reports](https://offendersearch.app/docs/reports.md).

## Errors

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| `unknown_marker` | 404 | The id is not one this account was given. | Use an id from your own map responses, with the key of the same account. |
| `marker_expired` | 410 | The marker id is older than 24 hours. | Request the view again for a new id, or use the `recordRef`. |
| `record_no_longer_listed` | 410 | The registry no longer lists the person. Not billed. | Remove the pin and tell the user the record is no longer listed. |
| `map_open_limit` | 429 | The account, or this end user, has reached a daily limit on record opens. Not billed. | Records already opened today still open. The limit resets at 00:00 UTC. |

---

# Near an address

> Find everyone within a radius of a point with a centre and a distance in miles, read the distance to each pin, and show a plain count badge with no pins at all.

HTML: https://offendersearch.app/docs/map/near-an-address · Markdown: https://offendersearch.app/docs/map/near-an-address.md

## A centre and a radius

For “who is near this address”, send a point and a distance instead of a box: `center=lat,lng` and `radiusMiles`. The radius is 0.1 to 100 miles.

GET /v1/map/viewport

```bash
curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d center=39.9612,-82.9988 \
  -d radiusMiles=0.5 \
  -d sandbox=true
```

You supply the coordinates. Turn a typed address into a latitude and longitude with the geocoder you already use.

## The response

200 OK

```json
{
  "mode": "markers",
  "center": [39.9612, -82.9988],
  "radiusMiles": 0.5,
  "withinRadius": 3,
  "total": 3,
  "markers": [
    {
      "id": "mk_…",
      "lat": 39.96301, "lng": -82.99512,
      "precision": "street",
      "addressType": "home",
      "label": "Sample J. Tester",
      "distanceMiles": 0.24
    }
  ]
}
```

A radius answer is the [viewport response](https://offendersearch.app/docs/map/viewport.md#reference-get-v1-map-viewport) with four more fields: `center` and `radiusMiles` (the circle that was answered), `withinRadius` (the exact number of people inside the circle, at every detail level, equal to `total`), and `distanceMiles` on every pin, many-people pin and area pin.

- The detail level follows the same zoom rules as a box. A small radius is answered with pins or named pins; a large one with clusters.
- Only what is inside the circle comes back, not everything in the box around it.
- Markers are sorted nearest first, so the array is already a “nearest to you” list.
- Send `zoom` if you want a particular level. Without it, the radius picks one.

## A count badge

For a line such as “3 within half a mile” on a listing page, you need a number and no pins. Add `detail=count`.

GET /v1/map/viewport

```bash
curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d center=39.9612,-82.9988 \
  -d radiusMiles=0.5 \
  -d detail=count \
  -d sandbox=true
```

200 OK

```json
{
  "mode": "count",
  "center": [39.9612, -82.9988],
  "radiusMiles": 0.5,
  "withinRadius": 3,
  "total": 3,
  "breakdown": {
    "street": 3, "housed": 2, "homeless": 0, "incarcerated": 0
  },
  "clusters": [],
  "markers": [],
  "areas": []
}
```

- The answer has `mode: "count"`, the totals and the `breakdown`. `clusters`, `markers` and `areas` are empty.
- It is one map request.
- It returns no marker ids, so nothing can be opened from it.

## Keeping a count

A count for a fixed address changes slowly. You may keep it on your side for up to 24 hours and show it to every visitor of that page, instead of asking again on each page view. Use the address as the key.

This is for counts only. Pins and names carry marker ids that belong to one response. Do not keep them for other visitors.

## Near me, on a phone

Use the device’s location as `center`. A phone app calls through your server. See [Access & keys](https://offendersearch.app/docs/map/access.md).

---

# Drawn areas & routes

> Send a polygon, or a route with a buffer either side, and get everyone inside the shape: a school zone, a delivery area or a walking route, in one map request.

HTML: https://offendersearch.app/docs/map/drawn-areas · Markdown: https://offendersearch.app/docs/map/drawn-areas.md

## The request

`POST /v1/map/area` returns everyone inside a shape you draw: a school zone, a delivery area, a walking route. It is one map request. Send one of `polygon` or `route`.

## A polygon

POST /v1/map/area

```bash
curl -X POST https://api.offendersearch.app/v1/map/area \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "polygon": [
      [-83.01, 39.96], [-82.99, 39.96],
      [-82.99, 39.98], [-83.01, 39.98]
    ],
    "sandbox": true
  }'
```

- 3 to 200 points.
- Each point is `[lng, lat]`: longitude first, as in GeoJSON.
- The ring is closed for you. You may repeat the first point at the end, or leave it out.
- A shape may not cross the date line or span half the globe.

## A route with a buffer

POST /v1/map/area

```bash
curl -X POST https://api.offendersearch.app/v1/map/area \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "route": {
      "points": [
        [-83.01, 39.96], [-83.00, 39.97], [-82.99, 39.98]
      ],
      "bufferMiles": 0.25
    },
    "sandbox": true
  }'
```

- `points`: 2 to 200 points along the route, each `[lng, lat]`.
- `bufferMiles`: the distance either side of the line, 0.05 to 5 miles.
- The answer is everyone within that distance of any part of the route.
- A route may not cross the date line or span half the globe.

> For an exact answer along a route, use `POST /v1/map/route` instead: it reads only the strip along the line, counts each leg and each stop, and is never answered as estimated clusters. See [Routes, stops and corridors](https://offendersearch.app/docs/map/routes.md).

## Body fields

Every body field, with its type, default and an example, is in the [request table](https://offendersearch.app/docs/map/drawn-areas.md#reference-post-v1-map-area) below. Send one of `polygon` or `route`; `zoom`, `format`, `detail`, `housing` and `precision` work as on the viewport route, and `sandbox: true` puts the request in [test mode](https://offendersearch.app/docs/map/test-mode.md).

## The response

The response is the viewport response for the shape’s bounding box, limited to what is inside the shape. It has one more field: `"shape": "polygon"` or `"shape": "route"`.

- The detail level follows the same zoom rules as a view. Send `zoom` to ask for a level, or leave it out and the size of the shape picks one.
- In `pins` and `markers` mode, `total` is exact.
- In `clusters` mode, a cluster is included when its centre is inside the shape, and the response carries `"totalIsEstimate": true`. Zoom in, or draw a smaller shape, for an exact number.
- Marker ids, group ids and the records route work exactly as they do from a view.

## Sex offender map API: POST /v1/map/area request and response fields

`POST /v1/map/area` · auth: `X-API-Key`

Everyone inside a drawn shape: a polygon, or a route with a buffer either side. The answer is the viewport answer for the shape's bounding box, limited to what is inside the shape, plus `shape`. One map request per answer; test mode and errors are free. An area answer carries no `ETag`.

### Request

**Headers**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | A secret API key (from your server), or a map browser key `os_map_pk_…` sent from a site on that key's list. A dashboard session can send `Authorization: Bearer <token>` instead. |
| `X-End-User` | `string` | optional | Any id you use for the person behind this request. Their pin views are then also counted against the per-end-user daily area budget (2,000 distinct areas), so one heavy user is stopped before the account is. |

**Body** (application/json)

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `polygon` | `array of array` | optional | A ring of 3 to 200 points, each `[lng, lat]` in degrees: longitude first, as in GeoJSON. Repeating the first point at the end is accepted; the ring is closed either way. Send this or `route`, not both. Example `[[-83.01,39.96],[-82.99,39.96],[-82.99,39.98],[-83.01,39.98]]`. |
| `route` | `object` | optional | A line and a distance either side of it: everyone within `bufferMiles` of any part of the route. Send this or `polygon`, not both. |
| `route.points` | `array of array` | required | 2 to 200 points along the route, each `[lng, lat]` in degrees. |
| `route.bufferMiles` | `number` | required | The distance either side of the line, 0.05 to 5 miles. Example `0.25`. |
| `zoom` | `number` | optional | As on `GET /v1/map/viewport`: 0 to 22, a fraction is floored. Leave it out and the size of the shape picks the zoom. |
| `format` | `string` — one of `"json"`, `"geojson"` | optional · default `"json"` | `json` for the [MapViewportResponse](https://offendersearch.app/docs/map/viewport#reference-get-v1-map-viewport) shape, `geojson` for a [MapGeoJson](https://offendersearch.app/docs/map/clusters-and-markers#object-mapgeojson) FeatureCollection. |
| `detail` | `string` — one of `"full"`, `"count"` | optional · default `"full"` | `full` for what to draw, `count` for numbers only. |
| `housing` | `string or array of string` | optional | Keep only people with these housing statuses: a comma-separated string or a list of `housed`, `homeless`, `incarcerated`. Leave it out to keep everyone. Example `["homeless","incarcerated"]`. |
| `precision` | `string` — one of `"street"`, `"any"` | optional | `street` keeps only people with an exact street address. `any`, or leaving it out, keeps everyone. |
| `sandbox` | `boolean` | optional · default `false` | `true` answers in test mode: made-up people, never billed. |

### Response

**200** (application/json) — The answer for this shape: the viewport response fields, with `shape` set and, in clusters mode, `totalIsEstimate: true`. With `format: "geojson"` the body is a [MapGeoJson](https://offendersearch.app/docs/map/clusters-and-markers#object-mapgeojson) FeatureCollection.

The body is [the `MapViewportResponse` object](https://offendersearch.app/docs/map/viewport#reference-get-v1-map-viewport). What to draw for one view (`format=json`). Returned by `GET /v1/map/viewport` and `POST /v1/map/area`. Draw from `mode`, not from your own zoom.

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | The key is missing or invalid. The body is the plain `detail` string. |
| 403 | `map_not_enabled` | The Map API is off for this account (`map_paused` when we paused it, `origin_not_allowed` for a browser key used from a site not on its list). Send `sandbox=true` to build first. |
| 422 | `invalid_shape` | Both or neither of `polygon` and `route`, too few or too many points, a point that is not `[lng, lat]`, a buffer outside 0.05 to 5 miles, or a shape that spans half the globe or crosses the date line. `invalid_zoom`, `invalid_filter`, `invalid_format` and `invalid_detail` as on the viewport route. |
| 429 | `map_busy` | Too many map requests at the same moment (`Retry-After: 1`). `map_sweep_limit` and `map_end_user_limit` when a daily area budget is used (clusters still work); `overloaded` when the service is at capacity. |
| 503 | `map_unavailable` | The map could not be read just now (`map_index_not_ready`, `map_not_configured` likewise). Nothing was charged; retry after `Retry-After`. |

## Caching

An area answer carries `Cache-Control` and no `ETag`, so there is no `304` for a drawn shape. Keep the answer on your side while the shape is on screen.

## Errors

A shape that cannot be read answers `422 invalid_shape`: both or neither of `polygon` and `route`, too few or too many points, a point that is not `[lng, lat]`, a buffer outside the range, or a shape that crosses the date line. The other viewport errors apply too. See [Limits & errors](https://offendersearch.app/docs/map/limits-and-errors.md).

## Watching an area

This route answers once. To be told when someone new appears in an area, create a location monitor. See [location monitoring](https://offendersearch.app/docs/monitoring/location-monitoring.md).

---

# Routes, stops and corridors

> Send a route line and its stops; get exact counts per leg and per stop, with nameless pins: corridors, stop radii, polylines, batches, units and errors.

HTML: https://offendersearch.app/docs/map/routes · Markdown: https://offendersearch.app/docs/map/routes.md

## Route search: who is registered along a route

`POST /v1/map/route` answers who is registered along a route, exactly: within a corridor either side of a line, within a radius around each stop, or both. The answer counts each leg and each stop, and places every pin on the route. It shows registered addresses, not where anyone is.

POST /v1/map/route

```bash
curl -X POST https://api.offendersearch.app/v1/map/route \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profile": "bus",
    "polyline": "o|{rFnuqyN_v@oi@o}@g^",
    "stops": [
      {"id": "stop-1", "lng": -82.9988, "lat": 39.9612},
      {"id": "stop-2", "lng": -82.9920, "lat": 39.9700},
      {"id": "stop-3", "lng": -82.9870, "lat": 39.9800}
    ],
    "corridorMiles": 0.1,
    "stopRadiusFeet": 1000,
    "externalId": "route-7-AM",
    "sandbox": true
  }'
```

A route answer gives exact counts per leg and per stop, with pins and distances but no names. The full record behind a pin is opened, and billed, one at a time.

- **Exact, never estimated.** Only the map tiles the corridor and the stop radii touch are read, and every address in them is measured. A long diagonal route reads a strip, not its bounding box.
- **One call for many routes.** `POST /v1/map/routes` takes up to 50, all or nothing. See [the batch route](https://offendersearch.app/docs/map/routes.md#batch).
- **Test mode works here too.** Send `"sandbox": true`; the example above has people around it.

Who it is for, with pricing worked out for a district and a walking app: the [sex offender route API](https://offendersearch.app/sex-offender-route-api) page.

## Route path formats: GeoJSON, points and polylines

Send the line in the form your routing tool already gives you. Send at most one of `path`, `points` and `polyline`.

| Field | Format | Where it comes from |
| --- | --- | --- |
| `path` | A GeoJSON LineString, or a GeoJSON Feature holding one | A drawn line, a GeoJSON file, turf.js |
| `path` or `points` | `[[lng, lat], …]`, longitude first | Any list of coordinates |
| `polyline` | An encoded polyline, precision 5 (the default) | Google Directions: `routes[0].overview_polyline.points` |
| `polyline` with `"polylinePrecision": 6` | An encoded polyline, precision 6 | Mapbox Directions with `geometries=polyline6`, OSRM and Valhalla |
| `stops` only | Two or more stops and no line | The stops are joined by straight lines |

- A line may have up to 50,000 points. One with more than 2,000 is simplified to within about 5 metres first, so a turn-by-turn line from a directions service fits.
- A polyline read at the wrong precision lands far from where it was drawn. It is refused with `invalid_polyline`, never answered somewhere else.
- A route may not cross the date line or span half the globe.

## Route corridor and bus stop radius

| `measure` | Counts | Needs |
| --- | --- | --- |
| `corridor` | Everyone whose registered address is within `corridorMiles` of any part of the line (default 0.1 miles either side). | A line, or two or more stops. |
| `stops` | Everyone within `stopRadiusFeet` of each stop, plus any `parcelAllowanceFeet`. | One or more stops. |
| `both` | Both, with one `total` of distinct people. | A line or two stops, and one or more stops. |

Leave `measure` out and it follows what you sent: `both` when `stopRadiusFeet` is sent (`stops` when there is no line), otherwise `corridor`.

- **`profile`** is `walk` (the default) or `bus`. It sets the limits and the default stop radius: 500 feet walking, 1,000 feet by bus.
- **`parcelAllowanceFeet`** adds 0 to 500 feet to every stop radius. A registered address is one geocoded point; the allowance stands in for the distance from that point to the property line.
- **Stops are in route order.** The line is cut into legs at the stops, and each stop is placed along the line in that order, so a loop that ends where it starts puts its last stop at the end.
- **Stop ids are yours.** Answers name a stop by its `id` only. A `label` is accepted and never echoed.

## Route stop fields in a request: the MapRouteStopInput object

One stop in `stops[]` of a route request. A bare `[lng, lat]` pair is accepted too, and gets the id `s1`, `s2`, … by its position.

Returned or accepted by [`POST /v1/map/route`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route), [`POST /v1/map/routes`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-routes).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Your id for the stop, up to 64 characters, unique within the route. Left out: `s1`, `s2`, … by position. Answers name the stop by this id. Example `"stop-2"`. |
| `lng` | `number` |  | Longitude of the stop, in degrees. Example `-82.992`. |
| `lat` | `number` |  | Latitude of the stop, in degrees. Example `39.97`. |
| `label` | `string` |  | Accepted and never echoed. An answer refers to a stop by its `id` only, so keep your own labels on your side. |

## Route legs, stops and alongMiles

200 OK

```json
{
  "shape": "route",
  "profile": "bus",
  "measure": "both",
  "detail": "full",
  "datasetVersion": "2026-10-18",
  "complete": true,
  "lengthMiles": 1.456,
  "corridorMiles": 0.1,
  "stopRadiusFeet": 1000.0,
  "parcelAllowanceFeet": 0.0,
  "units": 3,
  "tilesRead": 4,
  "total": 9,
  "corridorTotal": 8,
  "stopsTotal": 4,
  "breakdown": {"street": 9, "housed": 3, "homeless": 0, "incarcerated": 0},
  "legs": [
    {"leg": 1, "fromStop": "stop-1", "toStop": "stop-2", "lengthMiles": 0.687, "count": 5},
    {"leg": 2, "fromStop": "stop-2", "toStop": "stop-3", "lengthMiles": 0.769, "count": 3}
  ],
  "stops": [
    {"stopId": "stop-1", "alongMiles": 0.0, "count": 2, "nearestFeet": 412,
     "markerIds": ["mk_Qa8…", "mk_Rt2…"]},
    {"stopId": "stop-2", "alongMiles": 0.687, "count": 1, "nearestFeet": 194,
     "markerIds": ["mk_Hn4…"]},
    {"stopId": "stop-3", "alongMiles": 1.456, "count": 1, "nearestFeet": 830,
     "markerIds": ["mg_8Uo…"]}
  ],
  "markers": [
    {"id": "mk_Qa8…", "lat": 39.96133, "lng": -82.99863,
     "precision": "street", "addressType": "home",
     "distanceMiles": 0.002, "leg": 1, "alongMiles": 0.078,
     "nearestStop": "stop-1", "stopDistanceFeet": 412}
  ],
  "groups": [
    {"groupId": "mg_8Uo…", "lat": 39.97901, "lng": -82.98822,
     "precision": "street", "count": 2,
     "distanceMiles": 0.041, "leg": 2, "alongMiles": 1.302,
     "nearestStop": "stop-3", "stopDistanceFeet": 830}
  ],
  "approximate": {
    "count": 5,
    "areas": [
      {"groupId": "mg_X1G…", "lat": 39.969, "lng": -83.0114,
       "precision": "zip", "place": "43215", "count": 5}
    ]
  },
  "unplaced": {"count": 96, "states": ["OH"]},
  "externalId": "route-7-AM",
  "notice": "A pin shows a registered address, not a person's current location.",
  "routeNotice": "Distances are straight-line from each geocoded registered address. …",
  "sandbox": true
}
```

- **`total`** is distinct people with a street address in the corridor or within a stop’s reach. `corridorTotal` and `stopsTotal` split it; a person can be in both.
- **`legs[]`** counts the corridor leg by leg. Each person is counted on the leg of their nearest point of the line, so the leg counts add up to `corridorTotal`.
- **`stops[]`** gives each stop’s `count`, the distance to the closest address in `nearestFeet`, and the pins in reach in `markerIds`.
- **Pins** are in `markers` (one person) and `groups` (several people at one address), ordered along the line. In the corridor a pin has `distanceMiles`, `leg` and `alongMiles`; on a route with stops it has `nearestStop` and `stopDistanceFeet`.
- **`routeNotice`** says how the route was measured. Show it next to the counts.
- **`units`** is what the route is billed as. See [route units](https://offendersearch.app/docs/map/routes.md#route-units).

## Route leg fields: the MapRouteLeg object

The line between two stops, in `legs[]`. With fewer than two stops the whole line is one leg. Each person in the corridor counts on the leg of their nearest point of the line, so the leg counts add up to `corridorTotal`.

Returned or accepted by [`POST /v1/map/route`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route), [`POST /v1/map/routes`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-routes).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `leg` | `integer` |  | The leg number, from 1. Example `1`. |
| `fromStop` | `string` or null |  | The `id` of the stop the leg starts at. `null` when the route has fewer than two stops. |
| `toStop` | `string` or null |  | The `id` of the stop the leg ends at. `null` when the route has fewer than two stops. |
| `lengthMiles` | `number` |  | The length of the leg, in miles. Line before the first stop and after the last belongs to the first and last leg. |
| `count` | `integer` |  | People in the corridor whose nearest point of the line is on this leg. |

## Route stop fields in an answer: the MapRouteStop object

One stop, as answered, in `stops[]`, in the order sent.

Returned or accepted by [`POST /v1/map/route`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route), [`POST /v1/map/routes`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-routes).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `stopId` | `string` |  | The stop's `id`. Example `"stop-2"`. |
| `alongMiles` | `number` or null |  | Miles from the start of the line to the stop. `null` when there is no line. |
| `count` | `integer` or null |  | People within the stop's reach (`stopRadiusFeet` plus `parcelAllowanceFeet`). `null` when stops are not measured. |
| `nearestFeet` | `integer` or null |  | Feet from the stop to the closest of them. `null` when there is none. |
| `markerIds` | `array of string` |  | The marker and group ids within the stop's reach. Present when stops are measured; empty with `detail=count`. |

## Route pin fields: the MapRouteMarker object

One person at one exact street address near the route, in `markers[]`. A position, its place on the route and an id. Never a name, on any plan.

Returned or accepted by [`POST /v1/map/route`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route), [`POST /v1/map/routes`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-routes).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | The marker id, `mk_…`. Pass it to `GET /v1/map/records/{markerId}` to open the record (one record open). It works for 24 hours, only for the account it was issued to. |
| `lat` | `number` |  | Latitude of the registered address, in degrees. |
| `lng` | `number` |  | Longitude of the registered address, in degrees. |
| `precision` | `string` — one of `"street"` |  | Always `street`. |
| `addressType` | `string` — one of `"home"`, `"work"`, `"school"`, `"other"` |  | Which registered address this pin is. |
| `distanceMiles` | `number` |  | Straight-line miles from the registered address to the nearest point of the line. Present when the pin is in the corridor. Example `0.04`. |
| `leg` | `integer` |  | The leg (from 1) the nearest point of the line is on. Present when the pin is in the corridor. Example `1`. |
| `alongMiles` | `number` |  | Miles from the start of the line to its nearest point. Present when the pin is in the corridor. Example `0.62`. |
| `nearestStop` | `string` |  | The `id` of the closest stop. Present when the route has stops, whether or not the pin is within that stop's radius. Example `"stop-2"`. |
| `stopDistanceFeet` | `integer` |  | Straight-line feet from the registered address to that stop. Present when the route has stops. Example `412`. |

## Route group pin fields: the MapRouteGroup object

Several people registered at one exact street address near the route, as one pin in `groups[]`. The people behind it open with `GET /v1/map/groups/{groupId}` (one map request per page, no names).

Returned or accepted by [`POST /v1/map/route`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route), [`POST /v1/map/routes`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-routes).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `groupId` | `string` |  | The group id, `mg_…`, for `GET /v1/map/groups/{groupId}`. Works for 24 hours. |
| `lat` | `number` |  | Latitude of the address, in degrees. |
| `lng` | `number` |  | Longitude of the address, in degrees. |
| `precision` | `string` — one of `"street"` |  | Always `street`. |
| `count` | `integer` |  | People registered at this address. |
| `distanceMiles` | `number` |  | Straight-line miles from the registered address to the nearest point of the line. Present when the pin is in the corridor. Example `0.04`. |
| `leg` | `integer` |  | The leg (from 1) the nearest point of the line is on. Present when the pin is in the corridor. Example `1`. |
| `alongMiles` | `number` |  | Miles from the start of the line to its nearest point. Present when the pin is in the corridor. Example `0.62`. |
| `nearestStop` | `string` |  | The `id` of the closest stop. Present when the route has stops, whether or not the pin is within that stop's radius. Example `"stop-2"`. |
| `stopDistanceFeet` | `integer` |  | Straight-line feet from the registered address to that stop. Present when the route has stops. Example `412`. |

## Approximate and unplaced people near a route

Some people are mapped only to the centre of a ZIP code, a city or a county. Their distance to the route is not known, so they are never counted in `total`. Those whose area centre falls in the map tiles the route touches are listed under `approximate`, with a count and one pin per area. Draw them so they do not look like an address.

`unplaced` counts people in the states the route touches who have no mappable address at all, as on a map view. Say so next to the counts, so a short list is not read as the whole list.

## Route approximate-area fields: the MapRouteArea object

People mapped only to the centre of a ZIP code, city or county, in `approximate.areas[]`. Their distance to the route is not known, so they are listed here and never counted in `total`.

Returned or accepted by [`POST /v1/map/route`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route), [`POST /v1/map/routes`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-routes).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `groupId` | `string` |  | The group id, `mg_…`, for `GET /v1/map/groups/{groupId}`. |
| `lat` | `number` |  | Latitude of the area's centre. |
| `lng` | `number` |  | Longitude of the area's centre. |
| `precision` | `string` — one of `"zip"`, `"city"`, `"county"` |  | What kind of area. |
| `place` | `string` |  | The area's name. Example `"43215"`. |
| `count` | `integer` |  | People mapped to this area. |

## Never partial: exact route counts or a refusal

A route is answered whole, or refused with nothing charged. There is no estimated count and no cut-off list.

| When | Answer | What to do |
| --- | --- | --- |
| More than 300 pins with `detail=full` | `422 route_too_many_people`, with the exact counts in `counts` | Show the counts, send `detail=count`, or split the route. |
| More than 50,000 registered addresses to read | `422 route_too_dense` | Split the route, or narrow the corridor. |
| A read fails | `503 map_unavailable`, with no result | Retry after `Retry-After`. |
| One route in a batch is refused | The batch is refused, with its `routeIndex` | Fix or split that route and send the batch again. |

422 Unprocessable Entity

```json
{
  "detail": {
    "code": "route_too_many_people",
    "message": "This route has 412 places to pin, more than the 300 one response carries. …",
    "counts": {
      "total": 398,
      "corridorTotal": 371,
      "stopsTotal": 64,
      "breakdown": {"street": 398, "housed": 120, "homeless": 9, "incarcerated": 0},
      "legs": [{"leg": 1, "fromStop": "stop-1", "toStop": "stop-2", "lengthMiles": 0.61, "count": 12}],
      "stops": [{"stopId": "stop-1", "alongMiles": 0.0, "count": 3, "nearestFeet": 288}],
      "approximate": {"count": 14},
      "units": 22
    },
    "maxMarkers": 300
  }
}
```

## Route units: route corridor pricing

A route is $2.00 per 1,000 route units: 1 unit per started mile of corridor, plus 1 per 10 stops.

| Part of the route | Units | Note |
| --- | --- | --- |
| The corridor, when it is measured | 1 unit for each started mile of line | Every corridor accepted, up to 0.25 miles either side, counts ×1. |
| The stops, when a stop radius is measured | 1 unit for each started 10 stops | Stops sent but not measured add nothing. |
| Every route | At least 1 unit | A short walk with no stops is 1 unit. |

**Worked example.** A 17.42-mile bus route with a 0.1-mile corridor and 35 stops at 1,000 feet is 18 + 4 = 22 units, $0.044. Checking 300 such routes in each of 3 terms is 19,800 units, $39.60 a year in map requests.

- `detail=count` costs the same as a route with pins.
- A batch costs the sum of its routes, and nothing unless the whole batch answers.
- The same route asked again with `If-None-Match` answers `304`, free. Test mode and errors are free.
- Opening a record behind a pin is one record open, $0.035 by default. See [Billing](https://offendersearch.app/docs/map/billing.md).

## No names on a route answer

A route answer carries positions, distances, counts and ids. It never carries a name, on any plan, and the `markerFields: named` plan setting does not apply to it.

The reason is the shape. A map view is bounded by a screen, and names start only from zoom 15, a few streets across. A route can run for 60 miles, so a named answer would be a list of names along a whole county. The record behind any pin is still one call away: pass its `id` to `GET /v1/map/records/{markerId}`, one record open.

For a map with name labels, request `GET /v1/map/viewport` at zoom 15 or closer for the part of the route on screen. That is an ordinary map view, one map request, with named pins.

## Route limits for walk and bus profiles

| Limit | `walk` | `bus` | Past it |
| --- | --- | --- | --- |
| Longest line | 15 miles | 60 miles | `422 route_too_long` |
| Stops | Up to 50 | Up to 150 | `422 route_too_many_stops` |
| Corridor (`corridorMiles`) | 0.02 to 0.25 miles, default 0.1 | 0.02 to 0.25 miles, default 0.1 | `422 invalid_corridor` |
| Stop radius (`stopRadiusFeet`) | 100 to 2,640 feet, default 500 | 100 to 2,640 feet, default 1,000 | `422 invalid_radius` |
| Parcel allowance | 0 to 500 feet | 0 to 500 feet | `422 invalid_radius` |
| Points in the line | 50,000 sent; 2,000 after simplifying to about 5 metres | The same | `422 route_too_many_points` |
| Pins in a `detail=full` answer | 300, counting markers, groups and approximate areas | The same | `422 route_too_many_people`, with the exact counts |
| Registered addresses one route reads | 50,000 | The same | `422 route_too_dense` |
| Routes in one batch | 50 | 50 | `422 route_batch_too_large` |

`GET /v1/map/status` returns these under `routes.profiles`. An end user may check 20 different routes a day by default; see [Daily limits](https://offendersearch.app/docs/map/limits-and-errors.md#daily-limits).

## Route profile limits: the MapRouteProfile object

The limits of one route profile, as `GET /v1/map/status` reports them under `routes.profiles`.

Returned or accepted by [`GET /v1/map/status`](https://offendersearch.app/docs/map/turn-it-on#reference-get-v1-map-status).

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `maxMiles` | `number` |  | The longest line, in miles. Example `60`. |
| `maxStops` | `integer` |  | The most stops. Example `150`. |
| `corridorMiles` | `array of number` |  | The narrowest and widest corridor, in miles either side of the line. Example `[0.02,0.25]`. |
| `stopRadiusFeet` | `array of number` |  | The smallest and largest stop radius, in feet. Example `[100,2640]`. |

## Route API samples: curl, Node and Python

curl

```bash
curl -X POST https://api.offendersearch.app/v1/map/route \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profile": "bus",
    "polyline": "o|{rFnuqyN_v@oi@o}@g^",
    "stops": [
      {"id": "stop-1", "lng": -82.9988, "lat": 39.9612},
      {"id": "stop-2", "lng": -82.9920, "lat": 39.9700},
      {"id": "stop-3", "lng": -82.9870, "lat": 39.9800}
    ],
    "corridorMiles": 0.1,
    "stopRadiusFeet": 1000,
    "externalId": "route-7-AM",
    "sandbox": true
  }'
```

Node

```javascript
// Node 18+. The line straight from Google Directions.
const directions = await getDirections();  // your Directions API call
const res = await fetch('https://api.offendersearch.app/v1/map/route', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.OFFENDERSEARCH_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    profile: 'bus',
    polyline: directions.routes[0].overview_polyline.points,  // precision 5
    stops: [
      { id: 'stop-1', lng: -82.9988, lat: 39.9612 },
      { id: 'stop-2', lng: -82.9920, lat: 39.9700 },
    ],
    stopRadiusFeet: 1000,
    detail: 'count',
  }),
});
if (!res.ok) {
  const { detail } = await res.json();
  throw new Error(detail.code + ': ' + detail.message);
}
const route = await res.json();
console.log(route.total, 'near the route');
for (const leg of route.legs) console.log('leg', leg.leg, leg.count);
for (const stop of route.stops) console.log(stop.stopId, stop.count);
```

Python

```python
# Python. The line straight from Mapbox Directions (geometries=polyline6).
import os

import requests

body = {
    "profile": "walk",
    "polyline": mapbox_route["geometry"],
    "polylinePrecision": 6,
    "corridorMiles": 0.1,
}
r = requests.post(
    "https://api.offendersearch.app/v1/map/route",
    json=body,
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    timeout=30,
)
if r.status_code == 422 and r.json()["detail"]["code"] == "route_too_many_people":
    counts = r.json()["detail"]["counts"]  # exact; nothing was charged
    print(counts["total"], "near the route: send detail=count or split it")
else:
    r.raise_for_status()
    route = r.json()
    for marker in route["markers"]:
        print(marker["id"], marker["distanceMiles"], marker["alongMiles"])
```

## Sex offender map API: POST /v1/map/route request and response fields

`POST /v1/map/route` · auth: `X-API-Key`

Exact counts and nameless pins along one route: within a corridor either side of a line, within a radius of each stop, or both, per leg and per stop. Billed as route units, each one map request; `detail=count` costs the same; a `304`, test mode and errors are free. Never partial: a route is answered whole or refused with nothing charged.

### Request

**Headers**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | A secret API key (from your server), or a map browser key `os_map_pk_…` sent from a site on that key's list. A dashboard session can send `Authorization: Bearer <token>` instead. |
| `X-End-User` | `string` | optional | Any stable id you use for the person behind this request, such as a hashed user id. **Required with a map browser key** (`422 end_user_required`); optional with a secret key. Pins are counted against that user's daily area budget (2,000 distinct areas), and each different route against their daily route limit (20 by default). |
| `If-None-Match` | `string` | optional | The `ETag` of an earlier answer for the same route. Unchanged, it answers `304` with no body and is not billed. |

**Body** (application/json)

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `path` | `object or array` | optional | The line: a GeoJSON LineString, a GeoJSON Feature holding one, or a list of `[lng, lat]` points. Up to 50,000 points; a line with more than 2,000 is simplified to within about 5 metres first. Example `{"type":"LineString","coordinates":[[-83.0006,39.9601],[-82.9951,39.9608]]}`. |
| `points` | `array of array` | optional | The line as `[[lng, lat], …]`, longitude first: the same list `POST /v1/map/area` takes. Example `[[-83.0006,39.9601],[-82.9951,39.9608]]`. |
| `polyline` | `string` | optional | The line as an encoded polyline, exactly as a directions service returns it: Google Directions `overview_polyline.points` (precision 5), or a Mapbox Directions `geometries=polyline6` geometry, OSRM or Valhalla (precision 6). Example `"o\|{rFnuqyN_v@oi@o}@g^"`. |
| `polylinePrecision` | `integer` — one of `5`, `6` | optional · default `5` | The precision of `polyline`: `5` for Google, `6` for Mapbox, OSRM and Valhalla. Read at the wrong precision a line lands far away and is refused with `invalid_polyline`. |
| `stops` | array of [MapRouteStopInput](https://offendersearch.app/docs/map/routes#object-maproutestopinput) | optional | The stops, in route order: `{id, lng, lat}` objects, or `[lng, lat]` pairs. Walk: up to 50. Bus: up to 150. The line is cut into legs at the stops. |
| `profile` | `string` — one of `"walk"`, `"bus"` | optional · default `"walk"` | Sets the limits and the default stop radius. `walk`: up to 15 miles, 50 stops, 500 feet. `bus`: up to 60 miles, 150 stops, 1,000 feet. |
| `measure` | `string` — one of `"corridor"`, `"stops"`, `"both"` | optional | What is counted: the corridor along the line, a radius around each stop, or both. Left out: `both` when `stopRadiusFeet` is sent (`stops` when there is no line), otherwise `corridor`. |
| `corridorMiles` | `number` | optional · default `0.1` | The distance either side of the line, 0.02 to 0.25 miles. |
| `stopRadiusFeet` | `number` | optional | The radius around each stop, 100 to 2,640 feet. Left out: 500 for `walk`, 1,000 for `bus`. Example `1000`. |
| `parcelAllowanceFeet` | `number` | optional · default `0` | 0 to 500 feet added to the stop radius. A registered address is a geocoded point; an allowance stands in for the distance from that point to the property line. |
| `detail` | `string` — one of `"full"`, `"count"` | optional · default `"full"` | `full` for counts and nameless pins, `count` for counts only (no pins and no ids). The same price. |
| `format` | `string` — one of `"json"`, `"geojson"` | optional · default `"json"` | `json` for the [MapRouteResponse](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route) shape, `geojson` for a [MapGeoJson](https://offendersearch.app/docs/map/clusters-and-markers#object-mapgeojson) FeatureCollection. A batch answers in `json` only. |
| `housing` | `string or array of string` | optional | Keep only people with these housing statuses: a comma-separated string or a list of `housed`, `homeless`, `incarcerated`. Example `["homeless"]`. |
| `precision` | `string` — one of `"street"`, `"any"` | optional | `street` leaves out the people counted under `approximate`. `any`, or leaving it out, keeps everyone. |
| `sandbox` | `boolean` | optional · default `false` | `true` answers in test mode: made-up people, never billed. |
| `externalId` | `string` | optional | Your own id for the route, up to 100 characters, echoed in the answer. Example `"route-7-AM"`. |

### Response

**200** (application/json) — The answer for this route, with `Cache-Control: private, max-age=300` and an `ETag`. With `format: "geojson"` the body is a [MapGeoJson](https://offendersearch.app/docs/map/clusters-and-markers#object-mapgeojson) FeatureCollection.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `routeIndex` | `integer` |  | In a `POST /v1/map/routes` answer only: the route's position in the request, from 0. |
| `shape` | `string` — one of `"route"` |  | Always `route`. |
| `profile` | `string` — one of `"walk"`, `"bus"` |  | The profile the route was answered with. |
| `measure` | `string` — one of `"corridor"`, `"stops"`, `"both"` |  | What was counted. |
| `detail` | `string` — one of `"full"`, `"count"` |  | `count` answers have empty `markers`, `groups` and `approximate.areas`. |
| `datasetVersion` | `string (date)` |  | The version of the data that answered. Example `"2026-10-18"`. |
| `complete` | `boolean` |  | Always `true`. A route is answered whole or refused. |
| `lengthMiles` | `number` |  | The length of the line, in miles. `0` with no line. Example `17.42`. |
| `corridorMiles` | `number` or null |  | The corridor answered, in miles either side of the line. `null` when the corridor is not measured. Example `0.1`. |
| `stopRadiusFeet` | `number` or null |  | The stop radius answered, in feet. `null` when stops are not measured. Example `1000`. |
| `parcelAllowanceFeet` | `number` or null |  | The allowance added to the stop radius, in feet. `null` when stops are not measured. |
| `units` | `integer` |  | Route units: the number of map requests this route is billed as. See [Route units](https://offendersearch.app/docs/map/routes#route-units). Example `22`. |
| `tilesRead` | `integer` |  | The zoom-14 map tiles the corridor and the stop radii touch. |
| `total` | `integer` |  | Distinct people with a street address in the corridor or within a stop's reach. People under `approximate` are never in it. |
| `corridorTotal` | `integer` or null |  | People in the corridor. The leg counts add up to it. `null` when the corridor is not measured. |
| `stopsTotal` | `integer` or null |  | Distinct people within reach of any stop. `null` when stops are not measured. |
| `breakdown` | [MapBreakdown](https://offendersearch.app/docs/map/clusters-and-markers#object-mapbreakdown) |  | Of `total`: street addresses, and housing status where the registry states it. |
| `legs` | array of [MapRouteLeg](https://offendersearch.app/docs/map/routes#object-maprouteleg) |  | One entry per leg. Empty when the corridor is not measured. |
| `stops` | array of [MapRouteStop](https://offendersearch.app/docs/map/routes#object-maproutestop) |  | One entry per stop, in the order sent. |
| `markers` | array of [MapRouteMarker](https://offendersearch.app/docs/map/routes#object-maproutemarker) |  | One nameless pin per person at a street address, ordered along the line. Empty with `detail=count`. |
| `groups` | array of [MapRouteGroup](https://offendersearch.app/docs/map/routes#object-maproutegroup) |  | One pin per address where several people are registered. Empty with `detail=count`. |
| `approximate` | `object` |  | People mapped only to the centre of a ZIP code, city or county inside the map tiles the route touches. Their distance to the route is not known, so they are never in `total`. |
| `approximate.count` | `integer` |  | People mapped only to an area. |
| `approximate.areas` | array of [MapRouteArea](https://offendersearch.app/docs/map/routes#object-maproutearea) |  | One pin per area. Empty with `detail=count`. |
| `unplaced` | [MapUnplaced](https://offendersearch.app/docs/map/clusters-and-markers#object-mapunplaced) |  | People in the states the route touches who have no mappable address. |
| `externalId` | `string` |  | Your `externalId`, when you sent one. |
| `sandbox` | `boolean` |  | Present and `true` on a test-mode answer. |
| `notice` | `string` |  | A sentence to show with the answer. Example `"A pin shows a registered address, not a person's current location."`. |
| `routeNotice` | `string` |  | How a route is measured, as a sentence to show next to the counts: distances are straight-line from each geocoded registered address, and a count shows registered addresses, not where anyone is. |

**304** (no body) — The same route was asked within the `ETag`'s life. No body, not billed.

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | The key is missing or invalid. The body is the plain `detail` string. |
| 403 | `map_not_enabled` | The Map API is off for this account (`map_paused` when we paused it, `origin_not_allowed` for a browser key used from a site not on its list). Send `sandbox: true` to build first. |
| 422 | `invalid_path` | The route could not be read: `invalid_path`, `invalid_polyline`, `invalid_stops`, `invalid_profile`, `invalid_measure`, `invalid_corridor`, `invalid_radius`, `invalid_detail`, `invalid_format`, `invalid_filter`. Nothing was charged. |
| 422 | `route_too_long` | Longer than the profile allows (`route_too_many_stops` for too many stops, `route_too_many_points` for a line still over 2,000 points after simplifying). Split the route. |
| 422 | `route_too_many_people` | More than 300 pins with `detail=full`. `counts` holds the exact counts; nothing was charged. Send `detail=count`, or split the route. |
| 422 | `route_too_dense` | The route passes through more registered addresses than one call counts exactly. Split it, or narrow the corridor. Nothing was charged. |
| 422 | `end_user_required` | A map browser key sent no `X-End-User` header. |
| 429 | `map_end_user_route_limit` | This end user has checked their daily number of different routes. A route already checked today can be checked again. `map_sweep_limit` and `map_end_user_limit` when a daily area budget is used by pins (`detail=count` is never refused by them); `map_busy` when too many map requests arrive at once. |
| 503 | `map_unavailable` | The route could not be read just now: no result and nothing charged. Retry after `Retry-After` (`map_index_not_ready`, `map_not_configured` likewise). |

## Route GeoJSON output

With `"format": "geojson"` a single route answers as a `FeatureCollection` of points, content type `application/geo+json`: one feature per marker (`kind: "marker"`), group (`kind: "group"`) and approximate area (`kind: "area"`). Everything else, the counts, legs and stops included, is under `meta`. A batch answers in JSON only.

POST /v1/map/route

```bash
curl -X POST https://api.offendersearch.app/v1/map/route \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "path": {
      "type": "LineString",
      "coordinates": [[-82.9988, 39.9612], [-82.9920, 39.9700], [-82.9870, 39.9800]]
    },
    "format": "geojson",
    "sandbox": true
  }'
```

format: geojson

```json
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {"type": "Point", "coordinates": [-82.99863, 39.96133]},
      "properties": {
        "kind": "marker",
        "id": "mk_Qa8…",
        "precision": "street",
        "addressType": "home",
        "distanceMiles": 0.002,
        "leg": 1,
        "alongMiles": 0.078
      }
    },
    {
      "type": "Feature",
      "geometry": {"type": "Point", "coordinates": [-83.0114, 39.969]},
      "properties": {
        "kind": "area",
        "groupId": "mg_X1G…",
        "precision": "zip",
        "place": "43215",
        "count": 5
      }
    }
  ],
  "meta": {
    "shape": "route", "measure": "corridor", "units": 2, "total": 8,
    "legs": [{"leg": 1, "fromStop": null, "toStop": null, "lengthMiles": 1.456, "count": 8}],
    "approximate": {"count": 5, "areas": ["…"]},
    "notice": "A pin shows a registered address, not a person's current location."
  }
}
```

## Batch route search: up to 50 routes

`POST /v1/map/routes` takes up to 50 routes, each shaped like a `POST /v1/map/route` body, and answers them in the order sent, each with its `routeIndex`.

- **All or nothing.** One refused route refuses the batch, with `routeIndex` naming it, and nothing is billed.
- **One response for every route.** A person near three routes is one record open when opened from this answer, and is counted once in `uniquePeople`.
- **Billing** is the sum of the routes’ `units`, in `units`.

curl

```bash
curl -X POST https://api.offendersearch.app/v1/map/routes \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "routes": [
      {"externalId": "route-7-AM", "profile": "bus", "polyline": "o|{rFnuqyN_v@oi@o}@g^",
       "stops": [[-82.9988, 39.9612], [-82.9870, 39.9800]], "stopRadiusFeet": 1000},
      {"externalId": "route-7-PM", "profile": "bus",
       "points": [[-82.9870, 39.9800], [-82.9988, 39.9612]], "detail": "count"}
    ],
    "sandbox": true
  }'
```

Node

```javascript
// Node 18+. Every route of a district in calls of 50.
async function checkRoutes(allRoutes) {
  const answers = [];
  for (let i = 0; i < allRoutes.length; i += 50) {
    const res = await fetch('https://api.offendersearch.app/v1/map/routes', {
      method: 'POST',
      headers: {
        'X-API-Key': process.env.OFFENDERSEARCH_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ routes: allRoutes.slice(i, i + 50) }),
    });
    const body = await res.json();
    if (!res.ok) {
      // All or nothing: routeIndex names the route that refused the batch.
      const d = body.detail;
      throw new Error('route ' + (i + d.routeIndex) + ': ' + d.code);
    }
    answers.push(...body.routes);
  }
  return answers;
}
```

Python

```python
# Python. One batch; nothing is billed unless every route answers.
import os

import requests

r = requests.post(
    "https://api.offendersearch.app/v1/map/routes",
    json={"routes": routes},  # up to 50 route bodies
    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
    timeout=120,
)
body = r.json()
if not r.ok:
    detail = body["detail"]
    raise SystemExit(f"route {detail.get('routeIndex')}: {detail['code']}")
print(body["units"], "units,", body["uniquePeople"], "people across all routes")
for answer in body["routes"]:
    print(answer["externalId"], answer["total"])
```

200 OK

```json
{
  "shape": "routes",
  "complete": true,
  "datasetVersion": "2026-10-18",
  "units": 4,
  "uniquePeople": 9,
  "routes": [
    {"routeIndex": 0, "shape": "route", "externalId": "route-7-AM", "total": 9, "…": "…"},
    {"routeIndex": 1, "shape": "route", "externalId": "route-7-PM", "total": 8, "…": "…"}
  ],
  "notice": "A pin shows a registered address, not a person's current location.",
  "sandbox": true
}
```

## Sex offender map API: POST /v1/map/routes request and response fields

`POST /v1/map/routes` · auth: `X-API-Key`

Up to 50 routes in one call, answered all or nothing: any refused route refuses the batch, with its `routeIndex`, and nothing is billed. Billed as the sum of the routes' units. Every marker shares one response, so a person near several routes is one record open.

### Request

**Headers**

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `X-API-Key` | `string` | required | A secret API key (from your server), or a map browser key `os_map_pk_…` sent from a site on that key's list. A dashboard session can send `Authorization: Bearer <token>` instead. |
| `X-End-User` | `string` | optional | Any stable id you use for the person behind this request, such as a hashed user id. **Required with a map browser key** (`422 end_user_required`); optional with a secret key. Pins are counted against that user's daily area budget (2,000 distinct areas), and each different route against their daily route limit (20 by default). |

**Body** (application/json)

| Field | Type | Required / default | Description |
| --- | --- | --- | --- |
| `routes` | array of [MapRouteRequest](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route) | required | 1 to 50 routes, each a [MapRouteRequest](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route). `format` must be `json` or left out. |
| `sandbox` | `boolean` | optional · default `false` | `true` answers every route in test mode. |

### Response

**200** (application/json) — Every route's answer, in the order sent.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `shape` | `string` — one of `"routes"` |  | Always `routes`. |
| `complete` | `boolean` |  | Always `true`. A batch is answered whole or refused. |
| `datasetVersion` | `string (date)` |  | The version of the data that answered. |
| `units` | `integer` |  | The sum of the routes' units: the map requests this call is billed as. |
| `uniquePeople` | `integer` |  | Distinct people across every route. A person near three routes counts once. |
| `routes` | array of [MapRouteResponse](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route) |  | One answer per route, each with its `routeIndex`. |
| `sandbox` | `boolean` |  | Present and `true` on a test-mode answer. |
| `notice` | `string` |  | A sentence to show with the answer. |

**Errors**

| Status | Code | When |
| --- | --- | --- |
| 401 | — | The key is missing or invalid. The body is the plain `detail` string. |
| 403 | `map_not_enabled` | As on `POST /v1/map/route`. |
| 422 | `route_batch_too_large` | More than 50 routes (`invalid_route` for none, `invalid_format` for a route asking for GeoJSON). Any `POST /v1/map/route` refusal, with `routeIndex` naming the route. Nothing was charged. |
| 429 | `map_end_user_route_limit` | As on `POST /v1/map/route`. |
| 503 | `map_unavailable` | A route could not be read just now: no result and nothing charged. Retry after `Retry-After`. |

## Route caching and end users

- A single route answer carries `Cache-Control: private, max-age=300` and an `ETag`. Send it back in `If-None-Match`: the same route answers `304`, with no body, free.
- Marker and group ids work for 24 hours, for your account only.
- A map browser key must send `X-End-User` on both route routes. See [Platform integration](https://offendersearch.app/docs/map/platform-integration.md).
- Pins from a route count toward the daily area limit like pins from a view (`detail=count` never does), and each end user may check 20 different routes a day by default. See [Limits & errors](https://offendersearch.app/docs/map/limits-and-errors.md).

## Route search errors

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| `invalid_route` | 422 | The route is not a JSON object, or a batch has no routes. | Send a route object; send 1 to 50 in a batch. |
| `invalid_path` | 422 | More than one of `path`, `points` and `polyline`; a point that is not `[lng, lat]`; a line of one point; a corridor with no line; or a route that crosses the date line. | Send one line, longitude first. |
| `invalid_polyline` | 422 | The polyline does not decode, or decodes off the globe. | Check `polylinePrecision`: 5 for Google, 6 for Mapbox, OSRM and Valhalla. |
| `invalid_profile` | 422 | `profile` is not `walk` or `bus`. | Send one of the two. |
| `invalid_measure` | 422 | `measure` is not `corridor`, `stops` or `both`. | Send one of the three, or leave it out. |
| `invalid_corridor` | 422 | `corridorMiles` is outside 0.02 to 0.25. | Send a corridor inside the range. |
| `invalid_radius` | 422 | `stopRadiusFeet` or `parcelAllowanceFeet` is outside its range. | See the limits above. |
| `invalid_stops` | 422 | A stop is not `{id, lng, lat}` or `[lng, lat]`, an id is used twice, or stops are measured with no stops. | Fix the stop, or measure the corridor only. |
| `invalid_detail`, `invalid_format`, `invalid_filter` | 422 | As on the viewport route. A batch answers in `json` only. | Send an accepted value. |
| `route_too_long` | 422 | Longer than the profile allows, or touching more map area than one call reads. | Split the route into shorter routes. |
| `route_too_many_points` | 422 | More than 50,000 points, or more than 2,000 after simplifying. | Split the route. |
| `route_too_many_stops` | 422 | More stops than the profile allows. | Split the route, or use `bus`. |
| `route_batch_too_large` | 422 | More than 50 routes in a batch. | Send at most 50 per call. |
| `route_too_dense` | 422 | The route passes through more registered addresses than one call counts exactly. | Split the route, or narrow the corridor. |
| `route_too_many_people` | 422 | More than 300 pins with `detail=full`. `counts` holds the exact counts. | Show the counts, send `detail=count`, or split the route. |
| `end_user_required` | 422 | A map browser key sent no `X-End-User`. | Send any stable id for your user, such as a hashed user id. |
| `map_end_user_route_limit` | 429 | This end user has checked 20 different routes today (the default). | A route already checked today can be checked again. Other users are not affected. It resets at 00:00 UTC. |
| `map_unavailable` | 503 | The route could not be read just now. No result, nothing charged. | Retry after the `Retry-After` header. |

Access errors (`401`, `403`) and service errors are as on every map route. See [Limits & errors](https://offendersearch.app/docs/map/limits-and-errors.md).

---

# Filters & lists

> Filter a map by housing status or exact addresses, read the breakdown on a view and on each cluster, and build a results list that stays in step with the pins.

HTML: https://offendersearch.app/docs/map/filters-and-lists · Markdown: https://offendersearch.app/docs/map/filters-and-lists.md

## Filters

Two filters work on the viewport and area routes, at every zoom.

| Parameter | Values | Effect |
| --- | --- | --- |
| `housing` | `housed`, `homeless`, `incarcerated` | Only people whose registry states one of the statuses you list. Separate several with commas. |
| `precision` | `street`, `any` | `street`: only people with an exact street address, so approximate-area pins drop out. `any` is the default. |

GET /v1/map/viewport

```bash
curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d bbox=-83.2,39.8,-82.8,40.1 \
  -d zoom=10 \
  -d housing=homeless,incarcerated \
  -d precision=street
```

- Counts, clusters and pins all follow the filter, so a filtered cluster shows the filtered count.
- A person whose registry does not state a housing status is not matched by any `housing` value.
- An unknown value answers `422 invalid_filter`.

## Reading the breakdown

Every view and every cluster carries a `breakdown`. It lets you show filter counts before the user picks one, with no extra call.

breakdown

```json
"total": 508,
"breakdown": {
  "street": 508, "housed": 247, "homeless": 96, "incarcerated": 0
}
```

- Label a filter with its number: “Homeless (96)”.
- The numbers are of the view as filtered. With no filter they are of everyone in the view.
- The housing numbers do not add up to `total`, because many registries do not state a status.

## A list beside the map

From zoom 15 the `markers` array is the list. There is no second call.

- **Order.** Markers are sorted by distance from the centre of the view, so the order is stable for a view.
- **Same ids.** A list row and its pin share one marker `id`. Highlight one when the other is hovered.
- **Heading.** Use `total` for “42 people in this view”.
- **Many-people pins.** A pin with a `groupId` is one row with a count. Its `members` are inline for up to 10 people; past that, load them with the groups route.
- **Approximate areas.** List `areas` under their own heading, such as “Approximate location”.
- **Opening a row.** Call the records route with the row’s marker `id`. Opening the same id again is free.

Below zoom 15 there are no names to list. Show the count and ask the user to zoom in.

## Home, work and school

A person can be mapped at more than one address. Each address is its own pin, with `addressType` set to `home`, `work`, `school` or `other`. Mark the type on the pin. `total` counts the person once.

## Finding a named person

The Map API answers “who is here”. To answer “where is this person”, use the search API: `POST /v1/search` with the name.

1. Search by name. This is billed as a search, not as a map request.
2. Each record’s `addresses` carry `lat` and `lng`. Plot them.
3. Fit the map to the results.

See [Search](https://offendersearch.app/docs/search.md) for the request.

---

# Bus stop notices

> Check every school bus stop for registered addresses within 1,000 feet in one batch, open records for the notice letters, and word the notice correctly.

HTML: https://offendersearch.app/docs/map/bus-stop-notices · Markdown: https://offendersearch.app/docs/map/bus-stop-notices.md

## Bus stop notice check: what it answers

A district that tells families about registered addresses near their child’s bus stop needs, for each stop, the people registered within a set distance. One batch call answers every route of a run, stop by stop; a record is opened only for the people a letter will name.

> This check supports a district’s notice process; it is not a legal determination. Distances are from the geocoded registered address to the stop point. It shows registered addresses, not where anyone is.

## Bus stop radius: 1,000 feet on the bus profile

- `"profile": "bus"`: routes up to 60 miles with up to 150 stops.
- `"stopRadiusFeet": 1000`, the bus default. Use the distance your policy names, from 100 to 2,640 feet.
- `"measure": "stops"` counts the stops only. Use `"both"` to count a corridor along the line as well.
- Use your own stop numbers as stop `id`s and your route number as `externalId`: the answer comes back in your own terms.
- Send the route line from your routing system as a `polyline` or `points`, or leave it out: with stops only, each stop is answered on its own.

## Check every bus route in one batch

POST /v1/map/routes

```bash
curl -X POST https://api.offendersearch.app/v1/map/routes \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "routes": [
      {
        "externalId": "bus-12-AM",
        "profile": "bus",
        "polyline": "o|{rFnuqyN_v@oi@o}@g^",
        "stops": [
          {"id": "12-01", "lng": -82.9988, "lat": 39.9612},
          {"id": "12-02", "lng": -82.9920, "lat": 39.9700},
          {"id": "12-03", "lng": -82.9870, "lat": 39.9800}
        ],
        "measure": "stops",
        "stopRadiusFeet": 1000,
        "parcelAllowanceFeet": 50
      }
    ],
    "sandbox": true
  }'
```

- Up to 50 routes a call. A district with more sends several calls.
- All or nothing: if one route is refused, the call says which in `routeIndex`, and nothing is billed. Fix it and send the call again.
- `uniquePeople` counts each person once across the whole call, however many stops they are near.

## Parcel allowance for a bus stop radius

A registered address is one geocoded point, usually inside the property rather than on its edge. When a policy measures from the property line, add an allowance with `parcelAllowanceFeet`, 0 to 500 feet. It is added to every stop radius, and the answer states it in `parcelAllowanceFeet`, so a letter can say exactly what was measured.

## Bus stop notice letters: opening records

Each stop lists the pins within its reach in `markerIds`. Open each one through `GET /v1/map/records/{markerId}` for the details a letter needs. Each person is one record open, $0.035 by default, and a person near two stops in the same answer is opened once.

letters.py

```python
# For each stop with people in reach, open each record once for the letter.
opened = {}  # marker id -> record, so a person near two stops is opened once
for answer in body["routes"]:
    for stop in answer["stops"]:
        for marker_id in stop.get("markerIds", []):
            if marker_id.startswith("mg_"):
                continue  # several people at one address: page GET /v1/map/groups/{id}
            if marker_id not in opened:
                r = requests.get(
                    f"https://api.offendersearch.app/v1/map/records/{marker_id}",
                    headers={"X-API-Key": os.environ["OFFENDERSEARCH_KEY"]},
                    timeout=30,
                )
                r.raise_for_status()
                opened[marker_id] = r.json()["record"]
            write_letter_line(answer["externalId"], stop["stopId"], opened[marker_id])
```

- An id starting `mg_` is several people registered at one address. List them with the groups route, then open each.
- Marker ids work for 24 hours. Keep the `recordRef` of each opened record for your file, not the marker id.
- People under `approximate` are mapped only to a ZIP code, city or county, so their distance to a stop is not known. Say so in the notice rather than leaving them out silently.

## Wording for a bus stop notice

- “This check supports the district’s notice process. It is not a legal determination.”
- “Distances are measured from the geocoded registered address to the bus stop point.”
- “It shows registered addresses, not where anyone is.”
- Show the `routeNotice` sentence from the answer next to the counts.

## Bus route check cost

A 17.42-mile bus route with a 0.1-mile corridor and 35 stops at 1,000 feet is 18 + 4 = 22 units, $0.044. Checking 300 such routes in each of 3 terms is 19,800 units, $39.60 a year in map requests.

Records opened for letters are billed at the account’s record-open rate, $0.035 by default. See [Billing](https://offendersearch.app/docs/map/billing.md).

---

# Walking routes

> Let a family draw a walking loop, count registered addresses along it, draw nameless pins and open a record on tap, with X-End-User for each person.

HTML: https://offendersearch.app/docs/map/walking-routes · Markdown: https://offendersearch.app/docs/map/walking-routes.md

## Trick-or-treat route map: what it builds

A family plans a walk, such as a trick-or-treat loop, on your map. Your app shows how many registered addresses are near the walk, draws them as pins without names, and opens the record behind a pin when it is tapped. It shows registered addresses, not where anyone is, and a count is not a rating of the walk.

## Draw the walking route

- Let the user draw a line with your map library’s drawing tool (Leaflet.draw, Mapbox GL Draw, the Google Maps drawing tools). Each gives the line as `[lng, lat]` points.
- A loop that ends where it starts is fine.
- The `walk` profile takes up to 15 miles and 50 stops. The corridor is 0.1 miles either side by default; up to 0.25.
- Planned stops along the way can be sent as `stops`, with a radius around each.

## Walking route request with the walk profile

walk.js

```javascript
// The family draws a loop on your map. Send it, draw nameless pins,
// and open a record only when one is tapped.
const API = 'https://api.offendersearch.app';
const KEY = 'os_map_pk_your_key';  // a map browser key for your site

async function checkWalk(loop, userId) {
  const res = await fetch(API + '/v1/map/route', {
    method: 'POST',
    headers: {
      'X-API-Key': KEY,
      'X-End-User': userId,  // required with a browser key
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      profile: 'walk',
      points: loop,  // [[lng, lat], ...] as drawn
      corridorMiles: 0.1,
      sandbox: true,  // TEST MODE: remove to show real data
    }),
  });
  if (!res.ok) return showError(await res.json());
  const route = await res.json();
  showCount(route.total + ' registered addresses within 0.1 miles of this walk');
  showNotice(route.routeNotice);
  route.markers.forEach((m) => drawPin(m.lat, m.lng, () => openRecord(m.id, userId)));
  route.groups.forEach((g) => drawPin(g.lat, g.lng, () => openGroup(g.groupId), g.count));
}

async function openRecord(markerId, userId) {
  const res = await fetch(API + '/v1/map/records/' + markerId, {
    headers: { 'X-API-Key': KEY, 'X-End-User': userId },
  });
  if (res.ok) showRecord((await res.json()).record);  // one record open
}
```

## Nameless pins on the walking route map

A route answer never carries a name. Draw each marker as a plain dot and each group as a dot with its count. When a dot is tapped, open the record behind it: that is one record open, and opening the same marker again is free.

Draw `approximate` areas differently, such as a dashed circle labelled “approximate”: those people are known only to a ZIP code, city or county.

## Name labels on a walking map: a zoom-15 view

For a map with name labels, request `GET /v1/map/viewport` for the part of the map on screen at zoom 15 or closer. That is one ordinary map request, with named pins, and it follows the viewport rules. Keep the route answer for the counts along the whole walk.

## X-End-User on a walking route app

- A map browser key must send `X-End-User` with each route call: any stable id for the signed-in person, such as a hashed user id. Without it the call answers `422 end_user_required`.
- Each person may check 20 different walks a day by default. Asking about the same walk again does not count.
- Each person may read pins for 2,000 different map squares a day. Counts are never limited by it.
- Send the same `X-End-User` on the records route, so a daily open limit for each person can apply if you have one.

## Walking route app cost

A 2-mile walk with no stops is 2 route units, $0.004. Each record opened is one record open at the account’s rate, $0.035 by default. See [Billing](https://offendersearch.app/docs/map/billing.md).

---

# Platform integration

> Build the Map API into a platform: secret or browser keys, X-End-User and limits for each of your users, ETag caching, and counting first, opening on tap.

HTML: https://offendersearch.app/docs/map/platform-integration · Markdown: https://offendersearch.app/docs/map/platform-integration.md

## Map API platform integration: which key

A platform puts the Map API in front of many users of its own. The questions are which key makes the call, how each user is told apart, and how to keep calls few.

| Where the call is made | Key | Notes |
| --- | --- | --- |
| Your server (server to server) | Your secret key | Works on every route. Send `X-End-User` if you want per-user limits. |
| The browser, on your own site | A map browser key, `os_map_pk_…`, with its list of allowed sites | `X-End-User` is required on `POST /v1/map/route` and `POST /v1/map/routes`. |
| A phone app | Your secret key, on your server | A phone app has no web origin, so it calls through your server. |

See [Access & keys](https://offendersearch.app/docs/map/access.md) for creating a browser key and its allowed sites.

## X-End-User and limits for each end user

`X-End-User` is any stable id of yours for the person behind a call, such as a hashed user id. It is never shown to anyone. With it, each of your users has a share of the account, and one heavy user is stopped before the account is.

| Limit for each end user | Default | Past it |
| --- | --- | --- |
| Different map squares read for pins, each day | 2,000 | `429 map_end_user_limit` |
| Different routes checked, each day | 20 | `429 map_end_user_route_limit` |
| Different records opened, each day | Off; set on request | `429 map_open_limit` |

Other users of the account are not affected when one user reaches a limit. The account’s own limits are in [Daily limits](https://offendersearch.app/docs/map/limits-and-errors.md#daily-limits).

## Map API caching: ETag, If-None-Match and 304

- **Send `If-None-Match`.** Views and single routes carry an `ETag`. The same request with it answers `304`, with no body, and a `304` is free.
- **`Cache-Control: private, max-age=300`.** Keep an answer for 5 minutes for the user who asked. Do not put it in a shared cache.
- **Marker ids live 24 hours,** for your account only. Keep the ids of the answer you drew and reuse them: re-opening the same marker is free.
- **Counts for a fixed place** may be kept for 24 hours and shown to everyone. Pins and ids belong to one answer.

cache.js

```javascript
// Keep each answer with its ETag; ask again with If-None-Match.
const kept = new Map();  // route body -> { etag, answer }

async function callRoute(body) {
  const key = JSON.stringify(body);
  const seen = kept.get(key);
  const headers = { 'X-API-Key': KEY, 'X-End-User': userId, 'Content-Type': 'application/json' };
  if (seen) headers['If-None-Match'] = seen.etag;
  const res = await fetch(API + '/v1/map/route', { method: 'POST', headers, body: key });
  if (res.status === 304) return seen.answer;  // unchanged: free
  if (!res.ok) throw await res.json();
  const answer = await res.json();
  kept.set(key, { etag: res.headers.get('ETag'), answer });
  return answer;
}
```

## Count-first map API design: open on tap

- **Count first.** `detail=count` costs the same as a full answer, returns no pins and no ids, and is never refused by the area limits. It is the right call for a badge or a summary line.
- **Pins when asked.** Ask for `detail=full` when the user wants to see the map.
- **Open on tap.** Open a record only when a user taps a pin. Each person in each answer is one record open.

count-first.js

```javascript
// Count first: one route call with detail=count, no pins, the same price.
const count = await callRoute({ ...route, detail: 'count' });
showBadge(count.total);

// Pins only when the user asks to see them; records only when one is tapped.
showPinsButton.onclick = async () => {
  const full = await callRoute({ ...route, detail: 'full' });
  full.markers.forEach((m) => drawPin(m, () => openRecord(m.id)));
};
```

## Platform integration billing

Everything your users do is billed to your account: map requests and route units at $2.00 per 1,000 map requests, and record opens at your account’s rate, $0.035 by default. Daily limits for the account and for each end user can be set on request. See [Billing](https://offendersearch.app/docs/map/billing.md).

---

# MapLibre

> A complete MapLibre GL map to copy and paste: clusters with counts, pins, name labels, a list for many-people pins and a record panel. Runs in test mode as is.

HTML: https://offendersearch.app/docs/map/maplibre · Markdown: https://offendersearch.app/docs/map/maplibre.md

## What this page builds

A complete map with MapLibre GL JS: one HTML page and one script. The same code fits Mapbox GL JS: load that library instead, replace `maplibregl` with `mapboxgl`, and set your access token and style.

- Loads the view when the map first appears, and again each time it stops moving.
- Waits a quarter of a second after the last move, cancels the request it no longer needs, and sends `If-None-Match` so an unchanged view is free.
- Draws clusters with their counts. A click on a cluster fits the map to its `bbox` at its `expansionZoom`.
- Draws pins from zoom 13, and name labels from zoom 15. It reads `mode` and `label` from the response, so it holds no zoom numbers of its own.
- Opens a list from the groups route for a pin that stands for several people, and for an approximate-area pin.
- Opens a record panel from the records route when a pin or a list row is clicked.
- Shows the `notice` sentence on the map.

> The sample runs in **test mode**: the request carries `sandbox=true`, so it works before the plan is on and is never billed. To show real data, remove the line marked `TEST MODE` in `map.js`.

## Run it

1. Save the two files below as `index.html` and `map.js` in one folder.
2. Create a map browser key in the dashboard and add `http://localhost:8000` to its allowed sites. Put the key in `KEY` at the top of `map.js`. See [Access & keys](https://offendersearch.app/docs/map/access.md).
3. Serve the folder: `python3 -m http.server 8000`. Open `http://localhost:8000`.
4. Zoom in on the clusters. Pins appear, then names. Click a pin with a number on it for the list, and a pin for the record.

## index.html

index.html

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Map — MapLibre</title>
<link rel="stylesheet"
  href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
<style>
  html, body { margin: 0; height: 100%; }
  body { font: 14px/1.4 system-ui, sans-serif; }
  #map { position: absolute; inset: 0 320px 0 0; }
  #panel {
    position: absolute; top: 0; right: 0; bottom: 0; width: 320px;
    box-sizing: border-box; padding: 16px; overflow: auto;
    border-left: 1px solid #d0d5dd; background: #fff;
  }
  #notice {
    position: absolute; left: 12px; bottom: 32px; z-index: 1000;
    margin: 0; padding: 6px 10px; border-radius: 6px;
    background: #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
  }
  .dot {
    display: grid; place-items: center; box-sizing: border-box;
    border: 2px solid #fff; border-radius: 999px; cursor: pointer;
    color: #fff; font-weight: 600; white-space: nowrap;
  }
  .cluster { min-width: 36px; height: 36px; padding: 0 6px; background: #3b5bdb; }
  .pin { width: 16px; height: 16px; background: #e03131; }
  .group { min-width: 26px; height: 26px; padding: 0 4px; background: #e03131; }
  .area {
    min-width: 28px; height: 28px; padding: 0 4px;
    background: #868e96; border-style: dashed;
  }
  .label {
    position: absolute; left: 100%; margin-left: 4px; padding: 1px 6px;
    border-radius: 4px; background: #fff; color: #111; font-weight: 500;
  }
  .row { display: block; width: 100%; margin: 4px 0; padding: 8px; text-align: left; }
  #panel img { max-width: 100%; border-radius: 6px; }
</style>
</head>
<body>
  <div id="map"></div>
  <p id="notice"></p>
  <aside id="panel">
    <p id="summary"></p>
    <div id="detail"></div>
  </aside>
  <script
    src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
  <script src="map.js"></script>
</body>
</html>
```

## map.js

map.js

```javascript
// ── Settings ───────────────────────────────────────────────
// Calling from the browser: a map browser key (os_map_pk_…)
// whose allowed sites include this page.
// Calling through your own server: set API to '' and KEY to ''.
const API = 'https://api.offendersearch.app';
const KEY = 'os_map_pk_your_key';

// ── MapLibre ───────────────────────────────────────────────
const map = new maplibregl.Map({
  container: 'map',
  style: 'https://tiles.openfreemap.org/styles/liberty',
  center: [-82.9988, 39.9612],  // test mode has people here
  zoom: 10,
});
let placed = [];

function currentView() {
  const b = map.getBounds();
  return {
    west: b.getWest(), south: b.getSouth(),
    east: b.getEast(), north: b.getNorth(),
    zoom: map.getZoom(),
  };
}

function place(lat, lng, dot, onClick) {
  dot.addEventListener('click', onClick);
  const marker = new maplibregl.Marker({ element: dot });
  placed.push(marker.setLngLat([lng, lat]).addTo(map));
}

function clearMarkers() {
  placed.forEach((marker) => marker.remove());
  placed = [];
}

function zoomTo(lat, lng, zoom) {
  map.easeTo({ center: [lng, lat], zoom });
}

// ── Load: one map request per settled view ─────────────────
const cache = new Map();  // request path → { etag, view }
let timer = null;
let pending = null;

function viewChanged() {
  clearTimeout(timer);
  timer = setTimeout(loadView, 250);  // wait until the map settles
}

function call(path, signal, etag) {
  const headers = {};
  if (KEY) headers['X-API-Key'] = KEY;
  if (etag) headers['If-None-Match'] = etag;
  return fetch(API + path, { headers, signal });
}

function lng180(x) {
  return ((((x + 180) % 360) + 360) % 360) - 180;
}

async function loadView() {
  if (pending) pending.abort();  // the previous answer is no longer wanted
  pending = new AbortController();
  const v = currentView();
  const whole = v.east - v.west >= 360;
  const box = [
    whole ? -180 : lng180(v.west),
    Math.max(v.south, -85),
    whole ? 180 : lng180(v.east),
    Math.min(v.north, 85),
  ];
  const query = new URLSearchParams({
    bbox: box.map((n) => n.toFixed(5)).join(','),
    zoom: String(Math.floor(v.zoom)),
    sandbox: 'true',  // TEST MODE: remove this line to show real data
  });
  const path = '/v1/map/viewport?' + query;
  const seen = cache.get(path);
  try {
    const res = await call(path, pending.signal, seen && seen.etag);
    if (res.status === 304) return draw(seen.view);  // unchanged: free
    if (!res.ok) return showError(res);
    const view = await res.json();
    if (cache.size > 50) cache.clear();
    cache.set(path, { etag: res.headers.get('ETag'), view });
    draw(view);
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

// ── Draw: clusters, pins, named pins, approximate areas ────
function el(tag, className, text) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text) node.textContent = text;
  return node;
}

function draw(view) {
  clearMarkers();
  document.getElementById('notice').textContent = view.notice;
  let summary = view.total + ' people in this view';
  if (view.unplaced.count) {
    summary += '. ' + view.unplaced.count + ' more in ' +
      view.unplaced.states.join(', ') + ' have no mappable address';
  }
  document.getElementById('summary').textContent = summary + '.';
  view.clusters.forEach(drawCluster);  // zoom 3–12
  view.markers.forEach(drawPin);       // from zoom 13
  view.areas.forEach(drawArea);
}

function drawCluster(c) {
  const dot = el('div', 'dot cluster', String(c.count));
  // The breakdown is already on the cluster: no call on hover.
  dot.title = c.breakdown.street + ' of ' + c.count + ' at a street address';
  place(c.lat, c.lng, dot, () => {
    // Fit the cluster's extent at its expansionZoom: it comes apart.
    const [west, south, east, north] = c.bbox;
    zoomTo((south + north) / 2, (west + east) / 2, c.expansionZoom);
  });
}

function drawPin(m) {
  if (m.groupId) {  // several people at one address
    const dot = el('div', 'dot group', String(m.count));
    const title = m.count + ' people at this address';
    place(m.lat, m.lng, dot, () => openGroup(m.groupId, title, 1));
    return;
  }
  const dot = el('div', 'dot pin');
  // A label is on the pin from zoom 15.
  if (m.label) dot.appendChild(el('span', 'label', m.label));
  place(m.lat, m.lng, dot, () => openRecord(m.id));
}

function drawArea(a) {
  const dot = el('div', 'dot area', String(a.count));
  const title = a.count + ' people, approximate area ' + a.label;
  place(a.lat, a.lng, dot, () => openGroup(a.groupId, title, 1));
}

// ── The list behind a many-people or approximate-area pin ──
async function openGroup(groupId, title, page) {
  const res = await call('/v1/map/groups/' + groupId + '?page=' + page);
  if (!res.ok) return showError(res);
  const group = await res.json();
  const detail = document.getElementById('detail');
  if (page === 1) detail.replaceChildren(el('h2', '', title));
  for (const person of group.members) {
    const row = el('button', 'row', person.label || 'View record');
    row.onclick = () => openRecord(person.id);
    detail.appendChild(row);
  }
  if (group.page < group.totalPages) {  // each page: one map request
    const more = el('button', 'row', 'Show more');
    more.onclick = () => {
      more.remove();
      openGroup(groupId, title, page + 1);
    };
    detail.appendChild(more);
  }
}

// ── The record panel: one record open ──────────────────────
async function openRecord(markerId) {
  const res = await call('/v1/map/records/' + markerId);
  if (!res.ok) return showError(res);
  const { record, notice } = await res.json();
  const home = record.addresses[0] || {};
  const lines = [home.line1, home.city, home.state, home.zipcode];
  const detail = document.getElementById('detail');
  detail.replaceChildren(
    el('h2', '', record.name.full),
    el('p', '', lines.filter(Boolean).join(', ')),
    el('p', '', record.offense ? record.offense.crime : ''),
    el('p', '', notice),
  );
  const photo = record.images[0];
  if (photo) {
    const img = el('img');
    img.src = photo.url;  // a signed link
    img.alt = '';
    detail.appendChild(img);
  }
}

async function showError(res) {
  const body = await res.json().catch(() => ({}));
  const d = body.detail || {};
  const text = typeof d === 'string' ? d : d.message;
  document.getElementById('detail').replaceChildren(
    el('p', '', text || 'The map could not be loaded.'),
  );
}

// ── Start ──────────────────────────────────────────────────
map.on('load', viewChanged);
map.on('moveend', viewChanged);
```

## How it works

The first part of `map.js` is the only part that knows about MapLibre. Everything from “Load” down is plain JavaScript and is the same in all three library examples.

| Function | What it does with MapLibre |
| --- | --- |
| `currentView()` | Reads the box from `map.getBounds()` and the zoom from `map.getZoom()`. |
| `place()` | Adds a `maplibregl.Marker` with the element, and listens for its click. |
| `clearMarkers()` | Removes every marker placed for the last view. |
| `zoomTo()` | `map.easeTo()` to a centre and a zoom. |
| Start | `load` and `moveend` both call `viewChanged()`. |

| Function | What it does |
| --- | --- |
| `viewChanged()` | Called on every move end. Restarts a short timer so a drag or a zoom makes one request, not twenty. |
| `loadView()` | Cancels the previous request, sends the box and the zoom, and remembers each answer with its `ETag`. A `304` redraws the remembered answer. |
| `draw(view)` | Clears the map, then draws `clusters`, `markers` and `areas`, the count line and the notice. |
| `drawCluster(c)` | A circle with `count`. On click it moves to the middle of `bbox` at `expansionZoom`, which loads the next view. |
| `drawPin(m)` | A pin. With a `groupId` it is a many-people pin and opens the list. With a `label` it shows the name. |
| `drawArea(a)` | A dashed circle for people known only to a ZIP, city or county. It opens the list. |
| `openGroup()` | One page of the groups route. “Show more” loads the next page. |
| `openRecord()` | The records route. Fills the panel with the name, address, offense, notice and photo. |

## Going live

- Turn the Map API on. See [Turn it on](https://offendersearch.app/docs/map/turn-it-on.md).
- Remove the `sandbox` line marked `TEST MODE`.
- Use a browser key whose allowed sites include your site, or point `API` at your own server and clear `KEY`. See [Access & keys](https://offendersearch.app/docs/map/access.md).
- The base map style in the sample is a public one. Use your own style in production.
- For thousands of points, draw from a GeoJSON source and layers instead of HTML markers. Request `format=geojson` and pass the answer to `source.setData()`.
- A click on a pin opens the record at once, which is one record open. If you would rather show a small popup first, use the `label`, `housingStatus` and `hasPhoto` already on a named pin, and call the records route from a “View record” button.

---

# Leaflet

> A complete Leaflet map to copy and paste: clusters with counts, pins, name labels, a list for many-people pins and a record panel. It runs in test mode as is.

HTML: https://offendersearch.app/docs/map/leaflet · Markdown: https://offendersearch.app/docs/map/leaflet.md

## What this page builds

A complete map with Leaflet: one HTML page and one script.

- Loads the view when the map first appears, and again each time it stops moving.
- Waits a quarter of a second after the last move, cancels the request it no longer needs, and sends `If-None-Match` so an unchanged view is free.
- Draws clusters with their counts. A click on a cluster fits the map to its `bbox` at its `expansionZoom`.
- Draws pins from zoom 13, and name labels from zoom 15. It reads `mode` and `label` from the response, so it holds no zoom numbers of its own.
- Opens a list from the groups route for a pin that stands for several people, and for an approximate-area pin.
- Opens a record panel from the records route when a pin or a list row is clicked.
- Shows the `notice` sentence on the map.

> The sample runs in **test mode**: the request carries `sandbox=true`, so it works before the plan is on and is never billed. To show real data, remove the line marked `TEST MODE` in `map.js`.

## Run it

1. Save the two files below as `index.html` and `map.js` in one folder.
2. Create a map browser key in the dashboard and add `http://localhost:8000` to its allowed sites. Put the key in `KEY` at the top of `map.js`. See [Access & keys](https://offendersearch.app/docs/map/access.md).
3. Serve the folder: `python3 -m http.server 8000`. Open `http://localhost:8000`.
4. Zoom in on the clusters. Pins appear, then names. Click a pin with a number on it for the list, and a pin for the record.

## index.html

index.html

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Map — Leaflet</title>
<link rel="stylesheet"
  href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
  html, body { margin: 0; height: 100%; }
  body { font: 14px/1.4 system-ui, sans-serif; }
  #map { position: absolute; inset: 0 320px 0 0; }
  #panel {
    position: absolute; top: 0; right: 0; bottom: 0; width: 320px;
    box-sizing: border-box; padding: 16px; overflow: auto;
    border-left: 1px solid #d0d5dd; background: #fff;
  }
  #notice {
    position: absolute; left: 12px; bottom: 32px; z-index: 1000;
    margin: 0; padding: 6px 10px; border-radius: 6px;
    background: #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
  }
  .dot {
    display: grid; place-items: center; box-sizing: border-box;
    border: 2px solid #fff; border-radius: 999px; cursor: pointer;
    color: #fff; font-weight: 600; white-space: nowrap;
  }
  .cluster { min-width: 36px; height: 36px; padding: 0 6px; background: #3b5bdb; }
  .pin { width: 16px; height: 16px; background: #e03131; }
  .group { min-width: 26px; height: 26px; padding: 0 4px; background: #e03131; }
  .area {
    min-width: 28px; height: 28px; padding: 0 4px;
    background: #868e96; border-style: dashed;
  }
  .label {
    position: absolute; left: 100%; margin-left: 4px; padding: 1px 6px;
    border-radius: 4px; background: #fff; color: #111; font-weight: 500;
  }
  .row { display: block; width: 100%; margin: 4px 0; padding: 8px; text-align: left; }
  #panel img { max-width: 100%; border-radius: 6px; }
</style>
</head>
<body>
  <div id="map"></div>
  <p id="notice"></p>
  <aside id="panel">
    <p id="summary"></p>
    <div id="detail"></div>
  </aside>
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
  <script src="map.js"></script>
</body>
</html>
```

## map.js

map.js

```javascript
// ── Settings ───────────────────────────────────────────────
// Calling from the browser: a map browser key (os_map_pk_…)
// whose allowed sites include this page.
// Calling through your own server: set API to '' and KEY to ''.
const API = 'https://api.offendersearch.app';
const KEY = 'os_map_pk_your_key';

// ── Leaflet ────────────────────────────────────────────────
// Test mode has people around this point.
const map = L.map('map').setView([39.9612, -82.9988], 10);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 19,
  attribution: '&copy; OpenStreetMap contributors',
}).addTo(map);
const layer = L.layerGroup().addTo(map);

function currentView() {
  const b = map.getBounds();
  return {
    west: b.getWest(), south: b.getSouth(),
    east: b.getEast(), north: b.getNorth(),
    zoom: map.getZoom(),
  };
}

function place(lat, lng, dot, onClick) {
  dot.style.position = 'absolute';
  dot.style.transform = 'translate(-50%, -50%)';  // centre on the point
  const icon = L.divIcon({ html: dot, className: '', iconSize: [0, 0] });
  L.marker([lat, lng], { icon }).on('click', onClick).addTo(layer);
}

function clearMarkers() {
  layer.clearLayers();
}

function zoomTo(lat, lng, zoom) {
  map.setView([lat, lng], zoom);
}

// ── Load: one map request per settled view ─────────────────
const cache = new Map();  // request path → { etag, view }
let timer = null;
let pending = null;

function viewChanged() {
  clearTimeout(timer);
  timer = setTimeout(loadView, 250);  // wait until the map settles
}

function call(path, signal, etag) {
  const headers = {};
  if (KEY) headers['X-API-Key'] = KEY;
  if (etag) headers['If-None-Match'] = etag;
  return fetch(API + path, { headers, signal });
}

function lng180(x) {
  return ((((x + 180) % 360) + 360) % 360) - 180;
}

async function loadView() {
  if (pending) pending.abort();  // the previous answer is no longer wanted
  pending = new AbortController();
  const v = currentView();
  const whole = v.east - v.west >= 360;
  const box = [
    whole ? -180 : lng180(v.west),
    Math.max(v.south, -85),
    whole ? 180 : lng180(v.east),
    Math.min(v.north, 85),
  ];
  const query = new URLSearchParams({
    bbox: box.map((n) => n.toFixed(5)).join(','),
    zoom: String(Math.floor(v.zoom)),
    sandbox: 'true',  // TEST MODE: remove this line to show real data
  });
  const path = '/v1/map/viewport?' + query;
  const seen = cache.get(path);
  try {
    const res = await call(path, pending.signal, seen && seen.etag);
    if (res.status === 304) return draw(seen.view);  // unchanged: free
    if (!res.ok) return showError(res);
    const view = await res.json();
    if (cache.size > 50) cache.clear();
    cache.set(path, { etag: res.headers.get('ETag'), view });
    draw(view);
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

// ── Draw: clusters, pins, named pins, approximate areas ────
function el(tag, className, text) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text) node.textContent = text;
  return node;
}

function draw(view) {
  clearMarkers();
  document.getElementById('notice').textContent = view.notice;
  let summary = view.total + ' people in this view';
  if (view.unplaced.count) {
    summary += '. ' + view.unplaced.count + ' more in ' +
      view.unplaced.states.join(', ') + ' have no mappable address';
  }
  document.getElementById('summary').textContent = summary + '.';
  view.clusters.forEach(drawCluster);  // zoom 3–12
  view.markers.forEach(drawPin);       // from zoom 13
  view.areas.forEach(drawArea);
}

function drawCluster(c) {
  const dot = el('div', 'dot cluster', String(c.count));
  // The breakdown is already on the cluster: no call on hover.
  dot.title = c.breakdown.street + ' of ' + c.count + ' at a street address';
  place(c.lat, c.lng, dot, () => {
    // Fit the cluster's extent at its expansionZoom: it comes apart.
    const [west, south, east, north] = c.bbox;
    zoomTo((south + north) / 2, (west + east) / 2, c.expansionZoom);
  });
}

function drawPin(m) {
  if (m.groupId) {  // several people at one address
    const dot = el('div', 'dot group', String(m.count));
    const title = m.count + ' people at this address';
    place(m.lat, m.lng, dot, () => openGroup(m.groupId, title, 1));
    return;
  }
  const dot = el('div', 'dot pin');
  // A label is on the pin from zoom 15.
  if (m.label) dot.appendChild(el('span', 'label', m.label));
  place(m.lat, m.lng, dot, () => openRecord(m.id));
}

function drawArea(a) {
  const dot = el('div', 'dot area', String(a.count));
  const title = a.count + ' people, approximate area ' + a.label;
  place(a.lat, a.lng, dot, () => openGroup(a.groupId, title, 1));
}

// ── The list behind a many-people or approximate-area pin ──
async function openGroup(groupId, title, page) {
  const res = await call('/v1/map/groups/' + groupId + '?page=' + page);
  if (!res.ok) return showError(res);
  const group = await res.json();
  const detail = document.getElementById('detail');
  if (page === 1) detail.replaceChildren(el('h2', '', title));
  for (const person of group.members) {
    const row = el('button', 'row', person.label || 'View record');
    row.onclick = () => openRecord(person.id);
    detail.appendChild(row);
  }
  if (group.page < group.totalPages) {  // each page: one map request
    const more = el('button', 'row', 'Show more');
    more.onclick = () => {
      more.remove();
      openGroup(groupId, title, page + 1);
    };
    detail.appendChild(more);
  }
}

// ── The record panel: one record open ──────────────────────
async function openRecord(markerId) {
  const res = await call('/v1/map/records/' + markerId);
  if (!res.ok) return showError(res);
  const { record, notice } = await res.json();
  const home = record.addresses[0] || {};
  const lines = [home.line1, home.city, home.state, home.zipcode];
  const detail = document.getElementById('detail');
  detail.replaceChildren(
    el('h2', '', record.name.full),
    el('p', '', lines.filter(Boolean).join(', ')),
    el('p', '', record.offense ? record.offense.crime : ''),
    el('p', '', notice),
  );
  const photo = record.images[0];
  if (photo) {
    const img = el('img');
    img.src = photo.url;  // a signed link
    img.alt = '';
    detail.appendChild(img);
  }
}

async function showError(res) {
  const body = await res.json().catch(() => ({}));
  const d = body.detail || {};
  const text = typeof d === 'string' ? d : d.message;
  document.getElementById('detail').replaceChildren(
    el('p', '', text || 'The map could not be loaded.'),
  );
}

// ── Start ──────────────────────────────────────────────────
map.on('moveend', viewChanged);
viewChanged();
```

## How it works

The first part of `map.js` is the only part that knows about Leaflet. Everything from “Load” down is plain JavaScript and is the same in all three library examples.

| Function | What it does with Leaflet |
| --- | --- |
| `currentView()` | Reads the box from `map.getBounds()` and the zoom from `map.getZoom()`. |
| `place()` | Adds an `L.marker` with an `L.divIcon` that holds the element, and listens for its click. |
| `clearMarkers()` | Clears the layer group every marker is added to. |
| `zoomTo()` | `map.setView()` to a centre and a zoom. |
| Start | `moveend` calls `viewChanged()`, and it is called once to load the first view. |

| Function | What it does |
| --- | --- |
| `viewChanged()` | Called on every move end. Restarts a short timer so a drag or a zoom makes one request, not twenty. |
| `loadView()` | Cancels the previous request, sends the box and the zoom, and remembers each answer with its `ETag`. A `304` redraws the remembered answer. |
| `draw(view)` | Clears the map, then draws `clusters`, `markers` and `areas`, the count line and the notice. |
| `drawCluster(c)` | A circle with `count`. On click it moves to the middle of `bbox` at `expansionZoom`, which loads the next view. |
| `drawPin(m)` | A pin. With a `groupId` it is a many-people pin and opens the list. With a `label` it shows the name. |
| `drawArea(a)` | A dashed circle for people known only to a ZIP, city or county. It opens the list. |
| `openGroup()` | One page of the groups route. “Show more” loads the next page. |
| `openRecord()` | The records route. Fills the panel with the name, address, offense, notice and photo. |

## Going live

- Turn the Map API on. See [Turn it on](https://offendersearch.app/docs/map/turn-it-on.md).
- Remove the `sandbox` line marked `TEST MODE`.
- Use a browser key whose allowed sites include your site, or point `API` at your own server and clear `KEY`. See [Access & keys](https://offendersearch.app/docs/map/access.md).
- The sample uses OpenStreetMap’s public tiles, which are for light use. Use your own tile provider in production.
- A click on a pin opens the record at once, which is one record open. If you would rather show a small popup first, use the `label`, `housingStatus` and `hasPhoto` already on a named pin, and call the records route from a “View record” button.

---

# Google Maps

> A complete Google Maps page to copy and paste: clusters with counts, pins, name labels, a list for many-people pins and a record panel. Starts in test mode.

HTML: https://offendersearch.app/docs/map/google-maps · Markdown: https://offendersearch.app/docs/map/google-maps.md

## What this page builds

A complete map with the Google Maps JavaScript API: one HTML page and one script. It uses advanced markers, so each marker is an ordinary HTML element.

- Loads the view when the map first appears, and again each time it stops moving.
- Waits a quarter of a second after the last move, cancels the request it no longer needs, and sends `If-None-Match` so an unchanged view is free.
- Draws clusters with their counts. A click on a cluster fits the map to its `bbox` at its `expansionZoom`.
- Draws pins from zoom 13, and name labels from zoom 15. It reads `mode` and `label` from the response, so it holds no zoom numbers of its own.
- Opens a list from the groups route for a pin that stands for several people, and for an approximate-area pin.
- Opens a record panel from the records route when a pin or a list row is clicked.
- Shows the `notice` sentence on the map.

> The sample runs in **test mode**: the request carries `sandbox=true`, so it works before the plan is on and is never billed. To show real data, remove the line marked `TEST MODE` in `map.js`.

## Run it

1. Save the two files below as `index.html` and `map.js` in one folder.
2. Create a map browser key in the dashboard and add `http://localhost:8000` to its allowed sites. Put the key in `KEY` at the top of `map.js`. See [Access & keys](https://offendersearch.app/docs/map/access.md).
3. Put your Google Maps key in place of `YOUR_GOOGLE_MAPS_KEY` in `index.html`.
4. Serve the folder: `python3 -m http.server 8000`. Open `http://localhost:8000`.
5. Zoom in on the clusters. Pins appear, then names. Click a pin with a number on it for the list, and a pin for the record.

## index.html

index.html

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Map — Google Maps</title>
<style>
  html, body { margin: 0; height: 100%; }
  body { font: 14px/1.4 system-ui, sans-serif; }
  #map { position: absolute; inset: 0 320px 0 0; }
  #panel {
    position: absolute; top: 0; right: 0; bottom: 0; width: 320px;
    box-sizing: border-box; padding: 16px; overflow: auto;
    border-left: 1px solid #d0d5dd; background: #fff;
  }
  #notice {
    position: absolute; left: 12px; bottom: 32px; z-index: 1000;
    margin: 0; padding: 6px 10px; border-radius: 6px;
    background: #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
  }
  .dot {
    display: grid; place-items: center; box-sizing: border-box;
    border: 2px solid #fff; border-radius: 999px; cursor: pointer;
    color: #fff; font-weight: 600; white-space: nowrap;
  }
  .cluster { min-width: 36px; height: 36px; padding: 0 6px; background: #3b5bdb; }
  .pin { width: 16px; height: 16px; background: #e03131; }
  .group { min-width: 26px; height: 26px; padding: 0 4px; background: #e03131; }
  .area {
    min-width: 28px; height: 28px; padding: 0 4px;
    background: #868e96; border-style: dashed;
  }
  .label {
    position: absolute; left: 100%; margin-left: 4px; padding: 1px 6px;
    border-radius: 4px; background: #fff; color: #111; font-weight: 500;
  }
  .row { display: block; width: 100%; margin: 4px 0; padding: 8px; text-align: left; }
  #panel img { max-width: 100%; border-radius: 6px; }
</style>
</head>
<body>
  <div id="map"></div>
  <p id="notice"></p>
  <aside id="panel">
    <p id="summary"></p>
    <div id="detail"></div>
  </aside>
  <script src="map.js"></script>
  <script async
    src="https://maps.googleapis.com/maps/api/js?key=YOUR_GOOGLE_MAPS_KEY&v=weekly&libraries=marker&callback=initMap"></script>
</body>
</html>
```

## map.js

map.js

```javascript
// ── Settings ───────────────────────────────────────────────
// Calling from the browser: a map browser key (os_map_pk_…)
// whose allowed sites include this page.
// Calling through your own server: set API to '' and KEY to ''.
const API = 'https://api.offendersearch.app';
const KEY = 'os_map_pk_your_key';

// ── Google Maps ────────────────────────────────────────────
let map;
let placed = [];

// The Google Maps script calls this when it has loaded.
function initMap() {
  map = new google.maps.Map(document.getElementById('map'), {
    center: { lat: 39.9612, lng: -82.9988 },  // test mode has people here
    zoom: 10,
    mapId: 'DEMO_MAP_ID',  // advanced markers need a map id: use your own
  });
  map.addListener('idle', viewChanged);  // fires when a move has finished
}

function currentView() {
  const b = map.getBounds().toJSON();  // { north, south, east, west }
  return { ...b, zoom: map.getZoom() };
}

function place(lat, lng, dot, onClick) {
  dot.style.position = 'relative';
  dot.style.transform = 'translateY(50%)';  // centre on the point
  const marker = new google.maps.marker.AdvancedMarkerElement({
    map,
    position: { lat, lng },
    content: dot,
    gmpClickable: true,
  });
  marker.addListener('click', onClick);
  placed.push(marker);
}

function clearMarkers() {
  placed.forEach((marker) => { marker.map = null; });
  placed = [];
}

function zoomTo(lat, lng, zoom) {
  map.setCenter({ lat, lng });
  map.setZoom(zoom);
}

// ── Load: one map request per settled view ─────────────────
const cache = new Map();  // request path → { etag, view }
let timer = null;
let pending = null;

function viewChanged() {
  clearTimeout(timer);
  timer = setTimeout(loadView, 250);  // wait until the map settles
}

function call(path, signal, etag) {
  const headers = {};
  if (KEY) headers['X-API-Key'] = KEY;
  if (etag) headers['If-None-Match'] = etag;
  return fetch(API + path, { headers, signal });
}

function lng180(x) {
  return ((((x + 180) % 360) + 360) % 360) - 180;
}

async function loadView() {
  if (pending) pending.abort();  // the previous answer is no longer wanted
  pending = new AbortController();
  const v = currentView();
  const whole = v.east - v.west >= 360;
  const box = [
    whole ? -180 : lng180(v.west),
    Math.max(v.south, -85),
    whole ? 180 : lng180(v.east),
    Math.min(v.north, 85),
  ];
  const query = new URLSearchParams({
    bbox: box.map((n) => n.toFixed(5)).join(','),
    zoom: String(Math.floor(v.zoom)),
    sandbox: 'true',  // TEST MODE: remove this line to show real data
  });
  const path = '/v1/map/viewport?' + query;
  const seen = cache.get(path);
  try {
    const res = await call(path, pending.signal, seen && seen.etag);
    if (res.status === 304) return draw(seen.view);  // unchanged: free
    if (!res.ok) return showError(res);
    const view = await res.json();
    if (cache.size > 50) cache.clear();
    cache.set(path, { etag: res.headers.get('ETag'), view });
    draw(view);
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

// ── Draw: clusters, pins, named pins, approximate areas ────
function el(tag, className, text) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text) node.textContent = text;
  return node;
}

function draw(view) {
  clearMarkers();
  document.getElementById('notice').textContent = view.notice;
  let summary = view.total + ' people in this view';
  if (view.unplaced.count) {
    summary += '. ' + view.unplaced.count + ' more in ' +
      view.unplaced.states.join(', ') + ' have no mappable address';
  }
  document.getElementById('summary').textContent = summary + '.';
  view.clusters.forEach(drawCluster);  // zoom 3–12
  view.markers.forEach(drawPin);       // from zoom 13
  view.areas.forEach(drawArea);
}

function drawCluster(c) {
  const dot = el('div', 'dot cluster', String(c.count));
  // The breakdown is already on the cluster: no call on hover.
  dot.title = c.breakdown.street + ' of ' + c.count + ' at a street address';
  place(c.lat, c.lng, dot, () => {
    // Fit the cluster's extent at its expansionZoom: it comes apart.
    const [west, south, east, north] = c.bbox;
    zoomTo((south + north) / 2, (west + east) / 2, c.expansionZoom);
  });
}

function drawPin(m) {
  if (m.groupId) {  // several people at one address
    const dot = el('div', 'dot group', String(m.count));
    const title = m.count + ' people at this address';
    place(m.lat, m.lng, dot, () => openGroup(m.groupId, title, 1));
    return;
  }
  const dot = el('div', 'dot pin');
  // A label is on the pin from zoom 15.
  if (m.label) dot.appendChild(el('span', 'label', m.label));
  place(m.lat, m.lng, dot, () => openRecord(m.id));
}

function drawArea(a) {
  const dot = el('div', 'dot area', String(a.count));
  const title = a.count + ' people, approximate area ' + a.label;
  place(a.lat, a.lng, dot, () => openGroup(a.groupId, title, 1));
}

// ── The list behind a many-people or approximate-area pin ──
async function openGroup(groupId, title, page) {
  const res = await call('/v1/map/groups/' + groupId + '?page=' + page);
  if (!res.ok) return showError(res);
  const group = await res.json();
  const detail = document.getElementById('detail');
  if (page === 1) detail.replaceChildren(el('h2', '', title));
  for (const person of group.members) {
    const row = el('button', 'row', person.label || 'View record');
    row.onclick = () => openRecord(person.id);
    detail.appendChild(row);
  }
  if (group.page < group.totalPages) {  // each page: one map request
    const more = el('button', 'row', 'Show more');
    more.onclick = () => {
      more.remove();
      openGroup(groupId, title, page + 1);
    };
    detail.appendChild(more);
  }
}

// ── The record panel: one record open ──────────────────────
async function openRecord(markerId) {
  const res = await call('/v1/map/records/' + markerId);
  if (!res.ok) return showError(res);
  const { record, notice } = await res.json();
  const home = record.addresses[0] || {};
  const lines = [home.line1, home.city, home.state, home.zipcode];
  const detail = document.getElementById('detail');
  detail.replaceChildren(
    el('h2', '', record.name.full),
    el('p', '', lines.filter(Boolean).join(', ')),
    el('p', '', record.offense ? record.offense.crime : ''),
    el('p', '', notice),
  );
  const photo = record.images[0];
  if (photo) {
    const img = el('img');
    img.src = photo.url;  // a signed link
    img.alt = '';
    detail.appendChild(img);
  }
}

async function showError(res) {
  const body = await res.json().catch(() => ({}));
  const d = body.detail || {};
  const text = typeof d === 'string' ? d : d.message;
  document.getElementById('detail').replaceChildren(
    el('p', '', text || 'The map could not be loaded.'),
  );
}
```

## How it works

The first part of `map.js` is the only part that knows about Google Maps. Everything from “Load” down is plain JavaScript and is the same in all three library examples.

| Function | What it does with Google Maps |
| --- | --- |
| `initMap()` | Called by the Google Maps script once it has loaded. Creates the map and listens for `idle`. |
| `currentView()` | Reads the box from `map.getBounds().toJSON()` and the zoom from `map.getZoom()`. |
| `place()` | Adds an `AdvancedMarkerElement` with the element as its content, and listens for its click. |
| `clearMarkers()` | Sets `map` to `null` on every marker placed for the last view. |
| `zoomTo()` | `map.setCenter()` and `map.setZoom()`. |

| Function | What it does |
| --- | --- |
| `viewChanged()` | Called on every move end. Restarts a short timer so a drag or a zoom makes one request, not twenty. |
| `loadView()` | Cancels the previous request, sends the box and the zoom, and remembers each answer with its `ETag`. A `304` redraws the remembered answer. |
| `draw(view)` | Clears the map, then draws `clusters`, `markers` and `areas`, the count line and the notice. |
| `drawCluster(c)` | A circle with `count`. On click it moves to the middle of `bbox` at `expansionZoom`, which loads the next view. |
| `drawPin(m)` | A pin. With a `groupId` it is a many-people pin and opens the list. With a `label` it shows the name. |
| `drawArea(a)` | A dashed circle for people known only to a ZIP, city or county. It opens the list. |
| `openGroup()` | One page of the groups route. “Show more” loads the next page. |
| `openRecord()` | The records route. Fills the panel with the name, address, offense, notice and photo. |

## Going live

- Turn the Map API on. See [Turn it on](https://offendersearch.app/docs/map/turn-it-on.md).
- Remove the `sandbox` line marked `TEST MODE`.
- Use a browser key whose allowed sites include your site, or point `API` at your own server and clear `KEY`. See [Access & keys](https://offendersearch.app/docs/map/access.md).
- Advanced markers need a map id. `DEMO_MAP_ID` is Google’s test value: create your own map id for production.
- `idle` fires when a pan or a zoom has finished, which is the move-end event for Google Maps.
- A click on a pin opens the record at once, which is one record open. If you would rather show a small popup first, use the `label`, `housingStatus` and `hasPhoto` already on a named pin, and call the records route from a “View record” button.

---

# Billing

> The two Map API rates, what is never charged, how the monthly minimum works in the first month and after, five worked months, and where to see your usage.

HTML: https://offendersearch.app/docs/map/billing · Markdown: https://offendersearch.app/docs/map/billing.md

## The two rates

| Unit | Price | What it is |
| --- | --- | --- |
| Map request | $2.00 per 1,000 map requests | One answer for one map view, one drawn shape, or one page of a group. |
| Record open | $0.035 per record opened | The complete record behind one marker. |
| Monthly minimum | $300 | A floor on the account’s whole bill for the month. Not a fee on top. |

A map request is one answer for one map view. It costs the same whether the view holds three people or three hundred.

A record open is the complete record behind one marker. Opening the same marker again is free. A new map request returns new marker ids, so opening that person from the new result is a new charge.

$0.035 per record opened is the published price. Each account has its own rate for a record open, set by us, and `GET /v1/map/status` returns it in `rates.recordOpenUsd`. Accounts that had the Map API before 2026-10-18 keep $0.015 per record opened. Volume accounts can be given their own per-record rate. Contact us.

## Route units: map API route pricing

A route is $2.00 per 1,000 route units: 1 unit per started mile of corridor, plus 1 per 10 stops.

| Part of the route | Units | Note |
| --- | --- | --- |
| The corridor, when it is measured | 1 unit for each started mile of line | Every corridor accepted, up to 0.25 miles either side, counts ×1. |
| The stops, when a stop radius is measured | 1 unit for each started 10 stops | Stops sent but not measured add nothing. |
| Every route | At least 1 unit | A short walk with no stops is 1 unit. |

A 17.42-mile bus route with a 0.1-mile corridor and 35 stops at 1,000 feet is 18 + 4 = 22 units, $0.044. Checking 300 such routes in each of 3 terms is 19,800 units, $39.60 a year in map requests.

- **`detail=count` costs the same** as a route with pins. It returns no pins and no ids.
- **A batch is the sum of its routes.** Nothing is billed unless the whole batch answers.
- **A `304` is free.** The same route asked again with `If-None-Match` is not billed.
- **Records opened from a route** are record opens at the account’s rate, one for each person in each response.

See [Routes, stops and corridors](https://offendersearch.app/docs/map/routes.md#route-units).

## What is free

Clusters and pins are never charged per person. A view that has not changed (HTTP 304), the status route and test mode are free.

- **Clusters and pins themselves.** A view with three hundred pins costs the same one map request as a view with three.
- **Opening the same marker again.** See [Open a record](https://offendersearch.app/docs/map/open-a-record.md).
- **A `304`.** An unchanged view, asked for with `If-None-Match`.
- **The status route.** `GET /v1/map/status`.
- **Test mode.** Anything sent with `sandbox=true`.
- **Any error response.** A request that fails is not charged.

## The monthly minimum

The $300 monthly minimum is applied toward everything the account uses that month, search included. A month with $110 of usage is billed $300; a month with $1,100 is billed $1,100.

- **What counts toward it.** Everything the account uses that month: map requests, record opens, and the searches, reports and monitors on the same account.
- **On its own it covers** 150,000 map requests, or any mix of requests, record opens and searches that adds up to $300.
- **It is not added to usage.** You pay the larger of the two: your usage, or the minimum.

## The first month and later months

| Month | When you pay | What you pay |
| --- | --- | --- |
| The month you turn it on | The day you turn it on | $300, charged to the card at that moment. It covers the current calendar month. |
| The same month, at month end | On the month-end invoice | `max(total usage, 300) − 300 already paid`. Nothing more if usage stayed under $300. |
| Every later month | On the month-end invoice | Your usage, or $300 if usage is lower. |

For example, turn it on and use $1,500 in that month: $300 is charged on the day, and the month-end invoice is $1,200. Use $150: $300 is charged on the day, and the month-end invoice adds nothing.

Turning it off stops the routes within seconds and refunds nothing. Turning it back on in the same month costs nothing more. See [Turn it on](https://offendersearch.app/docs/map/turn-it-on.md).

## Five worked months

| Month | Map requests | Record opens | Searches | Usage | Billed |
| --- | --- | --- | --- | --- | --- |
| Trial period with the minimum waived | 12,000 | 600 | 0 | $45.00 | **$45.00** |
| Light month | 40,000 | 2,000 | 0 | $150.00 | **$300.00** |
| Map plus search | 40,000 | 2,000 | 1,000 | $300.00 | **$300.00** |
| Exactly at the minimum | 115,000 | 2,000 | 0 | $300.00 | **$300.00** |
| Above the minimum | 400,000 | 20,000 | 0 | $1,500.00 | **$1,500.00** |

- Searches are priced here at $0.15 each, the published search price.
- The first row is a negotiated account in a period with the minimum waived, so it pays its usage only.
- The rows are later months. In the month you turn it on, the minimum is paid up front and the month-end invoice is the rest.

## Rounding

Each line is rounded once, on the month’s total, not on each call. Map requests are rounded down to the cent. Record opens are rounded to the nearest cent.

## Where to see usage

`GET /v1/map/status` returns this month’s map requests, record opens and their cost so far. It is free. The dashboard shows the same numbers under Map API.

GET /v1/map/status

```bash
curl https://api.offendersearch.app/v1/map/status \
  -H "X-API-Key: $OFFENDERSEARCH_KEY"
```

200 OK

```json
{
  "enabled": true,
  "paused": false,
  "pinMinZoom": 13,
  "nameMinZoom": 15,
  "maxMarkers": 300,
  "maxTilesPerRequest": 64,
  "markerIdTtlSeconds": 86400,
  "rates": {
    "requestUsdPer1k": 2,
    "recordOpenUsd": 0.035
  },
  "minimumUsd": 300,
  "minimumAppliesThisMonth": true,
  "minimumPaidThisMonthUsd": 300,
  "usageThisMonth": {
    "periodStart": "2026-10-01T00:00:00+00:00",
    "requests": 40000,
    "recordOpens": 2000,
    "requestsUsd": 80,
    "recordOpensUsd": 70,
    "estimatedUsd": 150
  },
  "sweep": {
    "tilesToday": 412,
    "tilesPerDay": 20000,
    "endUserTilesPerDay": 2000
  }
}
```

Every field of this answer, including `rates`, `minimumAppliesThisMonth`, `minimumPaidThisMonthUsd`, `usageThisMonth` and `sweep.tilesToday`, is defined once, in the [plan and usage response table](https://offendersearch.app/docs/map/turn-it-on.md#reference-get-v1-map-status). `sweep` is the daily area limit; see [Limits & errors](https://offendersearch.app/docs/map/limits-and-errors.md). Search usage is on the Billing page of the dashboard.

---

# Limits & errors

> Every Map API limit with its number, and every error code with its HTTP status, what causes it and what to do, including the daily area limit on pin views.

HTML: https://offendersearch.app/docs/map/limits-and-errors · Markdown: https://offendersearch.app/docs/map/limits-and-errors.md

## Limits

| Limit | Value | What happens at it |
| --- | --- | --- |
| Pins start at zoom | 13 | Below it a view is answered as clusters. |
| Names start at zoom | 15 | Below it a pin has no `label`. |
| Lowest zoom answered | 3 | A wider view is answered at this zoom. |
| `zoom` parameter | 0 to 22 | Up to 24 is accepted and answered at 22. Below 0 or above 24: `422 invalid_zoom`. |
| Map tiles in one view | 64 | A wider box is answered at a lower zoom. It is not refused. |
| Pins in one response | 300 | A denser view is answered as clusters on a finer grid. |
| People inline on a many-people pin | 10 | Past it, use the groups route. |
| Group page size | 1 to 100, default 50 | Outside it: `422 invalid_page`. |
| Marker id and group id life | 24 hours | `410 marker_expired` or `410 group_expired`. |
| Free re-open by `recordRef` | 24 hours | After it, opening by reference is one record open again. |
| Signed photo link | 1 hour | Open the record again for a new link. |
| Radius | 0.1 to 100 miles | Outside it: `422 invalid_radius`. |
| Polygon points | 3 to 200 | Outside it: `422 invalid_shape`. |
| Route points | 2 to 200 | Outside it: `422 invalid_shape`. |
| Route buffer | 0.05 to 5 miles | Outside it: `422 invalid_shape`. |
| Browser cache time | 300 seconds | The `max-age` on a view. |
| Longest life of an `ETag` | 12 hours | After it the same view is a new answer with new ids. |
| Distinct areas read for pins, for each account, each day | 20,000 | `429 map_sweep_limit`. See below. |
| The same, for each end user (`X-End-User`) | 2,000 | `429 map_end_user_limit`. |
| Map requests answered at the same moment | 2 on each API server | `429 map_busy` with `Retry-After: 1`. |
| Different routes checked by one end user each day | 20 by default | `429 map_end_user_route_limit`. See below. |
| Records opened each day, for the account or one end user | No limit by default | `429 map_open_limit` when a limit is set. See below. |
| Route length, stops, corridor and radius | 15 miles walking, 60 by bus | See [Routes, stops and corridors](https://offendersearch.app/docs/map/routes.md#route-limits). |

Your account’s own values for the zoom thresholds, the pin cap and the area limits are in `GET /v1/map/status`.

## The daily area limit

Pins are counted by area. The map is divided into fixed squares, the map tiles of zoom 14. Each day an account may read pins for 20,000 different squares.

- **Only different squares count.** Looking at the same neighborhood a thousand times in a day counts its squares once.
- **Only pins count.** Cluster views are never limited, and they keep working after the limit is reached. Test mode is never counted.
- **It resets each day**, at midnight UTC.
- **Each end user has a share.** When you send `X-End-User`, that person may read pins for 2,000 different squares a day. One person paging across a whole state is then stopped with `map_end_user_limit` before the account’s limit is touched.
- **Normal use does not reach it.** People browsing a map look at a small number of places. The limit stops a copy of every pin in the country being taken through the map.

To have either number raised, write to support@offendersearch.app with your account, what the map is for and the number you need. Both are set for each account.

## Daily limits on record opens and routes

Three more daily limits can be set for each account, by us, on request. Each counts different things in a UTC day and resets at 00:00 UTC. `GET /v1/map/status` returns the account’s values under `limits`; `0` means no limit.

| Limit | Status field | Default | Past it |
| --- | --- | --- | --- |
| Different records the account opens in a day | `limits.recordOpensPerDay` | Off | `429 map_open_limit` on the records route |
| Different records one end user (`X-End-User`) opens in a day | `limits.endUserOpensPerDay` | Off | `429 map_open_limit` for that user |
| Different routes one end user checks in a day | `limits.endUserRoutesPerDay` | 20 | `429 map_end_user_route_limit` for that user |

- **Only different records count.** Opening a record already opened today is never refused and never counts twice.
- **Only different routes count.** A route already checked today can be checked again, with pins or as a count.
- **Nothing is charged** for a refused call. The body carries `budget` and `retryAfterSeconds`.
- **The per-end-user limits need `X-End-User`.** Send it on the records route as well as the route routes, so an open limit for each user can apply.
- **For a platform,** these let you give each of your users a share of the account. See [Platform integration](https://offendersearch.app/docs/map/platform-integration.md).

## The error body

Every coded error has the same shape. Switch on `detail.code`. Show `detail.message` to a developer, not to an end user. A `401`, a `402`, and an area body with wrongly typed fields carry a plain `detail` instead, with no code; switch on the status for those.

403 Forbidden

```json
{
  "detail": {
    "code": "map_not_enabled",
    "message": "The Map API is not turned on for this account. …",
    "dashboardUrl": "https://offendersearch.app/dashboard/map-api",
    "docsUrl": "https://offendersearch.app/docs/map/turn-it-on"
  }
}
```

Some errors add `dashboardUrl`, `docsUrl`, `billingUrl` or `retryAfterSeconds`. No error response is billed.

## The map error body: the MapError object

The error body of every `/v1/map/*` route, with three exceptions that carry [the Error object](https://offendersearch.app/docs/errors#object-error) instead: a missing or invalid key (`401`, a plain `detail` string), an account with no active subscription and no active free trial (`402`, a plain `detail` string), and a `POST /v1/map/area` or route body whose fields have the wrong JSON types (`422`, the standard validation `detail` list). Switch on `detail.code`; show `detail.message` to a developer. No error response is billed.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `detail` | `object` |  | The error. |
| `detail.code` | `string` — one of `"invalid_bbox"`, `"invalid_center"`, `"invalid_radius"`, `"invalid_zoom"`, `"invalid_filter"`, `"invalid_format"`, `"invalid_detail"`, `"invalid_page"`, `"invalid_shape"`, `"invalid_route"`, `"invalid_path"`, `"invalid_polyline"`, `"invalid_profile"`, `"invalid_measure"`, `"invalid_corridor"`, `"invalid_stops"`, `"route_too_long"`, `"route_too_many_points"`, `"route_too_many_stops"`, `"route_batch_too_large"`, `"route_too_dense"`, `"route_too_many_people"`, `"end_user_required"`, `"map_not_enabled"`, `"map_paused"`, `"origin_not_allowed"`, `"unknown_marker"`, `"unknown_group"`, `"marker_expired"`, `"group_expired"`, `"record_no_longer_listed"`, `"map_sweep_limit"`, `"map_end_user_limit"`, `"map_end_user_route_limit"`, `"map_open_limit"`, `"map_busy"`, `"overloaded"`, `"map_index_not_ready"`, `"map_unavailable"`, `"map_not_configured"`, `"minimum_not_accepted"`, `"payment_method_required"`, `"payment_failed"`, `"business_account_required"`, `"admin_required"` |  | The stable code to switch on. Every code, with its status, cause and fix, is in the tables on this page. |
| `detail.message` | `string` |  | A plain sentence saying what happened and what to do. |
| `detail.retryAfterSeconds` | `integer` |  | Seconds to wait before retrying; the same as the `Retry-After` header. On `map_busy`, `map_sweep_limit`, `map_end_user_limit`, `map_end_user_route_limit`, `map_open_limit` and `overloaded`. |
| `detail.budget` | `integer` |  | The daily limit that was reached. On `map_sweep_limit`, `map_end_user_limit`, `map_end_user_route_limit` and `map_open_limit`. |
| `detail.counts` | `object` |  | On `route_too_many_people`: the route's exact `total`, `corridorTotal`, `stopsTotal`, `breakdown`, `legs`, `stops`, `approximate.count` and `units`, so you can show the numbers and switch to `detail=count`. |
| `detail.maxMarkers` | `integer` |  | On `route_too_many_people`: the most pins one answer carries. |
| `detail.routeIndex` | `integer` |  | On a `POST /v1/map/routes` refusal: the route it is about, from 0. |
| `detail.dashboardUrl` | `string` |  | Where to fix it in the dashboard. On `map_not_enabled` and `origin_not_allowed`. |
| `detail.docsUrl` | `string` |  | The page that explains it. On `map_not_enabled`. |
| `detail.billingUrl` | `string` |  | The dashboard billing page. On `payment_method_required` and `payment_failed`. |

## Request errors

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| `invalid_bbox` | 422 | `bbox` is missing or is not `west,south,east,north`, or it was sent together with `center`. | Send four numbers in degrees: south below north, west and east within ±180 and different. Send `bbox` or `center`, not both. |
| `invalid_center` | 422 | `center` is not `lat,lng`, or it came without `radiusMiles`. | Send `center=lat,lng` together with `radiusMiles`. |
| `invalid_radius` | 422 | `radiusMiles` is not a number from 0.1 to 100. | Send a radius inside the range. |
| `invalid_zoom` | 422 | `zoom` is not a number, or is below 0 or above 24. | Send the map’s zoom, or leave it out. |
| `invalid_filter` | 422 | `housing` or `precision` has a value that is not listed. | Use `housed`, `homeless`, `incarcerated` for `housing`, and `street` or `any` for `precision`. |
| `invalid_format` | 422 | `format` is not `json` or `geojson`. | Send one of the two. |
| `invalid_detail` | 422 | `detail` is not `full` or `count`. | Send one of the two. |
| `invalid_page` | 422 | `page` is below 1, or `perPage` is not 1 to 100. | Send a page from 1 and a page size inside the range. |
| `invalid_shape` | 422 | The body has both or neither of `polygon` and `route`, the wrong number of points, or a buffer outside the range. | Send one shape, inside the limits above. |

## Route errors: route corridor and stop codes

These come from `POST /v1/map/route` and `POST /v1/map/routes`. A refused route is never charged. In a batch, `routeIndex` names the route that refused it.

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| `invalid_route` | 422 | The route is not a JSON object, or a batch has no routes. | Send a route object; send 1 to 50 in a batch. |
| `invalid_path` | 422 | More than one of `path`, `points` and `polyline`; a point that is not `[lng, lat]`; a line of one point; a corridor with no line; or a route that crosses the date line. | Send one line, longitude first. |
| `invalid_polyline` | 422 | The polyline does not decode, or decodes off the globe. | Check `polylinePrecision`: 5 for Google, 6 for Mapbox, OSRM and Valhalla. |
| `invalid_profile` | 422 | `profile` is not `walk` or `bus`. | Send one of the two. |
| `invalid_measure` | 422 | `measure` is not `corridor`, `stops` or `both`. | Send one of the three, or leave it out. |
| `invalid_corridor` | 422 | `corridorMiles` is outside 0.02 to 0.25. | Send a corridor inside the range. |
| `invalid_radius` | 422 | `stopRadiusFeet` or `parcelAllowanceFeet` is outside its range. | See the limits above. |
| `invalid_stops` | 422 | A stop is not `{id, lng, lat}` or `[lng, lat]`, an id is used twice, or stops are measured with no stops. | Fix the stop, or measure the corridor only. |
| `invalid_detail`, `invalid_format`, `invalid_filter` | 422 | As on the viewport route. A batch answers in `json` only. | Send an accepted value. |
| `route_too_long` | 422 | Longer than the profile allows, or touching more map area than one call reads. | Split the route into shorter routes. |
| `route_too_many_points` | 422 | More than 50,000 points, or more than 2,000 after simplifying. | Split the route. |
| `route_too_many_stops` | 422 | More stops than the profile allows. | Split the route, or use `bus`. |
| `route_batch_too_large` | 422 | More than 50 routes in a batch. | Send at most 50 per call. |
| `route_too_dense` | 422 | The route passes through more registered addresses than one call counts exactly. | Split the route, or narrow the corridor. |
| `route_too_many_people` | 422 | More than 300 pins with `detail=full`. `counts` holds the exact counts. | Show the counts, send `detail=count`, or split the route. |
| `end_user_required` | 422 | A map browser key sent no `X-End-User`. | Send any stable id for your user, such as a hashed user id. |
| `map_end_user_route_limit` | 429 | This end user has checked 20 different routes today (the default). | A route already checked today can be checked again. Other users are not affected. It resets at 00:00 UTC. |
| `map_unavailable` | 503 | The route could not be read just now. No result, nothing charged. | Retry after the `Retry-After` header. |

## Access errors

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| — | 401 | The key is missing or wrong, or a map browser key was used on a route that does not accept one. | Send a valid key in `X-API-Key`. Use a secret key, from your server, for other routes. |
| — | 402 | The account has no active subscription and no active free trial. | Add a payment method in the dashboard under Billing. |
| `map_not_enabled` | 403 | The Map API is off for this account. | Turn it on. See [Turn it on](https://offendersearch.app/docs/map/turn-it-on.md). To build first, send `sandbox=true`. |
| `map_paused` | 403 | We have paused the Map API for this account. | Write to support@offendersearch.app. |
| `origin_not_allowed` | 403 | A map browser key was used from a site that is not on its list. | Add the site to the key, or call from your server with a secret key. See [Access & keys](https://offendersearch.app/docs/map/access.md). |

## Id errors

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| `unknown_marker` | 404 | The marker id or reference is not one this account was given. | Use ids from your own responses, with a key of the same account. |
| `unknown_group` | 404 | The group id is not one this account was given. | The same. |
| `marker_expired` | 410 | The marker id is older than 24 hours. | Request the view again for a new id. For saved links, keep the `recordRef`. |
| `group_expired` | 410 | The group id is older than 24 hours. | Request the view again for a new id. |
| `record_no_longer_listed` | 410 | The registry no longer lists the person. Not billed. | Remove the pin, and say the record is no longer listed. |

## Limit errors

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| `map_sweep_limit` | 429 | The account has used its area limit for the day. | Clusters still work: show them and ask the user to try pins tomorrow. If normal use reaches it, ask us to raise it. |
| `map_end_user_limit` | 429 | One end user has used their area limit for the day. | Tell that user. Other users are not affected. |
| `map_end_user_route_limit` | 429 | One end user has checked their daily number of different routes. | Routes already checked today still answer. Other users are not affected. |
| `map_open_limit` | 429 | A daily limit on record opens is set and reached, for the account or one end user. | Records already opened today still open. It resets at 00:00 UTC. |
| `map_busy` | 429 | Too many map requests at the same moment. | Retry after the `Retry-After` header, which is 1 second. Wait for the map to settle and cancel requests you no longer need. |
| `overloaded` | 429 | The service is at capacity. | Retry after the `Retry-After` header. |

A `429` here is not a charge and not a ban. Wait for the time in `Retry-After` and send the request again.

## Service errors

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| `map_index_not_ready` | 503 | The map data is not ready to answer yet. | Retry shortly. |
| `map_unavailable` | 503 | The map could not be read just now. Nothing was charged. | Retry after the `Retry-After` header. |
| `map_not_configured` | 503 | The Map API is not available on this server. | Retry. If it continues, write to us. |

## Turn-on errors

These come only from `POST /v1/map/enable` and `POST /v1/map/disable`.

| Code | Status | Cause | What to do |
| --- | --- | --- | --- |
| `minimum_not_accepted` | 422 | `acceptMinimum` was not `true`. | Send `{"acceptMinimum": true}`. |
| `payment_method_required` | 402 | There is no card on the account, or it is on the free trial. | Add a payment method in the dashboard under Billing, then try again. |
| `payment_failed` | 402 | The card was declined. Nothing was turned on and nothing was charged. | Fix or replace the card, then try again. |
| `business_account_required` | 403 | The account is a personal account. | Use a business account. |
| `admin_required` | 403 | The dashboard seat is not an admin of the account. | Ask an account admin to turn it on, or use a secret key. |

---

# Sex offender map API reference

> Every Offender Map API endpoint with its request and response fields, and every object it returns: clusters, pins, groups, areas, the record and the plan.

HTML: https://offendersearch.app/docs/map/api-reference · Markdown: https://offendersearch.app/docs/map/api-reference.md

Nine endpoints and the objects they send and return. Each endpoint below links to its Request and Response tables, and each object to the one table that defines it; every other page links to the same tables, so a field is described in one place.

All nine take a secret key in `X-API-Key`. The seven map read routes (viewport, area, route, routes, groups, records and status) also take a map browser key from a site on that key’s list (see [Access & keys](https://offendersearch.app/docs/map/access.md)); on the route routes a browser key also sends `X-End-User`. `sandbox=true` answers the viewport, area and route routes in [test mode](https://offendersearch.app/docs/map/test-mode.md), and the ids a test-mode answer returns keep the groups and records routes in test mode. The opened record is the same object the search API returns: [The Record object](https://offendersearch.app/docs/record-object.md).

## Sex offender map API endpoints

Every Map API route, with what it does. Each one opens its Request and Response tables: every field, its type, default and an example.

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/map/viewport`](https://offendersearch.app/docs/map/viewport#reference-get-v1-map-viewport) | **Map view.** What to draw for one map view: clusters, pins or named pins of registered sex offenders. Send the view as `bbox`, or as `center` with `radiusMiles`. One map request per answer, whatever the view holds; a `304`, test mode and errors are free. |
| [`POST /v1/map/area`](https://offendersearch.app/docs/map/drawn-areas#reference-post-v1-map-area) | **Drawn area.** Everyone inside a drawn shape: a polygon, or a route with a buffer either side. The answer is the viewport answer for the shape's bounding box, limited to what is inside the shape, plus `shape`. One map request per answer; test mode and errors are free. An area answer carries no `ETag`. |
| [`POST /v1/map/route`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-route) | **Route.** Exact counts and nameless pins along one route: within a corridor either side of a line, within a radius of each stop, or both, per leg and per stop. Billed as route units, each one map request; `detail=count` costs the same; a `304`, test mode and errors are free. Never partial: a route is answered whole or refused with nothing charged. |
| [`POST /v1/map/routes`](https://offendersearch.app/docs/map/routes#reference-post-v1-map-routes) | **Routes in a batch.** Up to 50 routes in one call, answered all or nothing: any refused route refuses the batch, with its `routeIndex`, and nothing is billed. Billed as the sum of the routes' units. Every marker shares one response, so a person near several routes is one record open. |
| [`GET /v1/map/groups/{groupId}`](https://offendersearch.app/docs/map/clusters-and-markers#reference-get-v1-map-groups) | **Group members.** One page of the people behind a many-people pin or an approximate-area pin. One map request per page; test mode and errors are free. |
| [`GET /v1/map/records/{markerId}`](https://offendersearch.app/docs/map/open-a-record#reference-get-v1-map-records) | **Open a record.** The complete record behind a marker or a saved reference. One record open per person per map response, at the account's rate ($0.035 by default): the same marker id again is free, and so is a reference within 24 hours of an open. `record_no_longer_listed`, test mode and errors are not billed. |
| [`GET /v1/map/status`](https://offendersearch.app/docs/map/turn-it-on#reference-get-v1-map-status) | **Plan and usage.** The Map API plan, rates, limits and this month's usage. Free, and it answers for an account whose Map API is off: it is how a client finds out that it is. |
| [`POST /v1/map/enable`](https://offendersearch.app/docs/map/turn-it-on#reference-post-v1-map-enable) | **Turn it on.** Turn the Map API on. Any paying business account can, with a secret key or an account admin's dashboard session. The monthly minimum is charged to the card at that moment and covers the current calendar month; if the charge fails, nothing is turned on. On an account that is already on it changes nothing and charges nothing. |
| [`POST /v1/map/disable`](https://offendersearch.app/docs/map/turn-it-on#reference-post-v1-map-disable) | **Turn it off.** Turn the Map API off. Free. The map routes stop within seconds; test mode keeps working. The month stays a minimum month, a minimum already paid is not refunded, and turning it on again in the same month charges nothing more. |

## Sex offender map API objects

Every object the Map API returns, defined once. A field whose type is one of these links here.

| Object | What it is |
| --- | --- |
| [`MapBreakdown`](https://offendersearch.app/docs/map/clusters-and-markers#object-mapbreakdown) | Four counts of the people in a view or a cluster. They are not a split of the total: one person counts in `street` and in a housing number. A person whose registry states no housing status is in `total` and in none of the three housing counts. |
| [`MapCluster`](https://offendersearch.app/docs/map/clusters-and-markers#object-mapcluster) | A count for one grid cell, returned in `clusters` mode (zoom 3 to 12, and closer views holding more than 300 pins). Draw it as a circle with `count` inside. |
| [`MapMarker`](https://offendersearch.app/docs/map/clusters-and-markers#object-mapmarker) | One person at one exact street address, in `markers[]`. In `pins` mode (zoom 13 and 14) it is a position and nothing that identifies anyone. In `markers` mode (zoom 15 and closer) it also carries `label`, `housingStatus`, `hasPhoto` and `registryState`. A person with two mapped addresses has two pins; `total` counts that person once. |
| [`MapMember`](https://offendersearch.app/docs/map/clusters-and-markers#object-mapmember) | One person behind a many-people pin or an approximate-area pin: in `members[]` of a groups response, and inline on a many-people pin at zoom 15 and closer. `label`, `housingStatus` and `hasPhoto` are present only when the group came from a view at zoom 15 or closer (`named: true`); otherwise a member is `id` and `addressType` only. |
| [`MapGroupPin`](https://offendersearch.app/docs/map/clusters-and-markers#object-mapgrouppin) | Several people at one exact street address, drawn as one pin in `markers[]`. Tell it apart from a single pin by `id: null` and a `groupId`. List the people with `GET /v1/map/groups/{groupId}`. |
| [`MapArea`](https://offendersearch.app/docs/map/clusters-and-markers#object-maparea) | People known only to the middle of a ZIP code, a city or a county, in `areas[]`. People in custody are always here, as are people without a fixed address whose registered address has no street number. An area is never drawn as an exact pin: draw it so it does not look like an address. |
| [`MapUnplaced`](https://offendersearch.app/docs/map/clusters-and-markers#object-mapunplaced) | People in the states this view touches who have no mappable address. They are on a registry and they are not on the map: show the count next to the map so a short list of pins is not read as the whole list. The count is for whole states, not for the box. |
| [`MapGeoJson`](https://offendersearch.app/docs/map/clusters-and-markers#object-mapgeojson) | The same answer as a GeoJSON `FeatureCollection` of points, returned with `format=geojson` and content type `application/geo+json`. It goes straight into a map library's GeoJSON source. A route answer with `format: "geojson"` has the same shape: one point per marker (`kind: "marker"`), group (`kind: "group"`) and approximate area (`kind: "area"`), with the rest of the route answer under `meta`. |
| [`MapRouteStopInput`](https://offendersearch.app/docs/map/routes#object-maproutestopinput) | One stop in `stops[]` of a route request. A bare `[lng, lat]` pair is accepted too, and gets the id `s1`, `s2`, … by its position. |
| [`MapRouteLeg`](https://offendersearch.app/docs/map/routes#object-maprouteleg) | The line between two stops, in `legs[]`. With fewer than two stops the whole line is one leg. Each person in the corridor counts on the leg of their nearest point of the line, so the leg counts add up to `corridorTotal`. |
| [`MapRouteStop`](https://offendersearch.app/docs/map/routes#object-maproutestop) | One stop, as answered, in `stops[]`, in the order sent. |
| [`MapRouteMarker`](https://offendersearch.app/docs/map/routes#object-maproutemarker) | One person at one exact street address near the route, in `markers[]`. A position, its place on the route and an id. Never a name, on any plan. |
| [`MapRouteGroup`](https://offendersearch.app/docs/map/routes#object-maproutegroup) | Several people registered at one exact street address near the route, as one pin in `groups[]`. The people behind it open with `GET /v1/map/groups/{groupId}` (one map request per page, no names). |
| [`MapRouteArea`](https://offendersearch.app/docs/map/routes#object-maproutearea) | People mapped only to the centre of a ZIP code, city or county, in `approximate.areas[]`. Their distance to the route is not known, so they are listed here and never counted in `total`. |
| [`MapRouteProfile`](https://offendersearch.app/docs/map/routes#object-maprouteprofile) | The limits of one route profile, as `GET /v1/map/status` reports them under `routes.profiles`. |
| [`MapBilling`](https://offendersearch.app/docs/map/open-a-record#object-mapbilling) | Whether this record open was charged, and why. Every `GET /v1/map/records/{markerId}` answer carries it. |
| [`MapError`](https://offendersearch.app/docs/map/limits-and-errors#object-maperror) | The error body of every `/v1/map/*` route, with three exceptions that carry [the Error object](https://offendersearch.app/docs/errors#object-error) instead: a missing or invalid key (`401`, a plain `detail` string), an account with no active subscription and no active free trial (`402`, a plain `detail` string), and a `POST /v1/map/area` or route body whose fields have the wrong JSON types (`422`, the standard validation `detail` list). Switch on `detail.code`; show `detail.message` to a developer. No error response is billed. |
