# 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
- **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
- **Map API reference as markdown:** https://offendersearch.app/docs/map.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).

---

## Related

- Previous: [Drawn areas & routes](https://offendersearch.app/docs/map/drawn-areas.md)
- Next: [Filters & lists](https://offendersearch.app/docs/map/filters-and-lists.md)
- Index: [Map API reference](https://offendersearch.app/docs/map.md)
