# API reference

Summary of the MapMap gateway API. **The machine-readable contract is always
`GET /openapi.json` on your deployment.** Where this page and that document
disagree, OpenAPI wins. A plain-text orientation for LLM agents is at
`GET /llms.txt` on every deployment.

The API is drop-in compatible with common OSRM/Valhalla-style routing
clients, so your existing routing tooling works against it unchanged.

## Base URL

The base URL is `https://api.mapmap.ai`.

**Status, honestly:** the hosted gateway is live: [sign up](/signup) for a
key, or run the [self-host distro](/docs/self-host) against your own host.
Every example below uses `$BASE` so it works unchanged against either.

Conventions in brief: coordinates are `lon,lat` order, distances are metres,
durations are seconds. The full conventions (auth, units, quotas, the error
envelope, 402-vs-429, ADR tunnel codes) live on the
[conventions](/docs/conventions) page.

## Authentication

Metered endpoints require an API key, sent as a bearer token or `?api_key=`:

```
Authorization: Bearer snk_…
```

The unauthenticated surface is `/`, `/health`, `/openapi.json`, `/llms.txt`,
`/terms`, self-serve signup (`POST /v1/keys`) with its magic-link
verification (`GET /v1/keys/verify`), and the read-only map assets that
browser map clients fetch by bare URL: `GET /styles/{spec}`,
`GET /styles/{spec}/theme`, `/fonts/*` and `/sprite/*`. Do not send API keys
with those asset requests. `GET /styles` — the *listing* — is authenticated
and returns only your own styles.

Browser clients can call every endpoint directly: the gateway answers with
`Access-Control-Allow-Origin: *`, including for `null`-origin contexts
(Figma plugins, Electron apps, `file://` pages). Keys travel as bearer
headers, never cookies, which is what makes the open policy safe.

Self-serve keys come from `POST /v1/keys` (see the
[quickstart](/docs/quickstart)); self-hosted deployments can also issue keys
via the admin API (`POST /admin/keys`). Keys are shown once at creation, one
key per integration, rotate by issue-then-revoke. There is no self-serve
revoke endpoint yet: on self-hosted deployments revoke via
`DELETE /admin/keys/{id}` (admin token); on the hosted gateway, ask us.

## Your first request

```bash
export BASE=https://api.mapmap.ai
export API_KEY=snk_...

curl "$BASE/route/v1/truck/-0.1276,51.5072;-1.8904,52.4862?height=4.0&weight=44.0&hazmat=true&tunnel_code=D" \
  -H "Authorization: Bearer $API_KEY"
```

Truncated response (OSRM shape: `distance` in metres, `duration` in
seconds, `location` arrays are `[lon, lat]`, `geometry` is an encoded
polyline at **precision 5** by default; add `?geometries=polyline6` for
six-digit precision, or `?geometries=geojson` to skip decoding altogether):

```json
{
  "code": "Ok",
  "routes": [
    {
      "distance": 190843.0,
      "duration": 8611.0,
      "weight": 8611.0,
      "weight_name": "duration",
      "geometry": "u{~vFvyys@fS]...",
      "legs": ["..."]
    }
  ],
  "waypoints": [
    { "name": "Victoria Embankment", "location": [-0.1276, 51.5072] },
    { "name": "Corporation Street", "location": [-1.8904, 52.4862] }
  ]
}
```

Zero-install alternative: the [playground](/playground) runs the same
requests from your browser.

## Endpoints

| Method | Path | Purpose |
|---|---|---|
| POST | `/route` | Native routing request (rich JSON body) with the ADR extension |
| POST | `/route/along` | Along-route search over your own places, ranked by honest matrix-measured detour cost (see the [analysis guide](/docs/analysis)) |
| GET | `/route/v1/{profile}/{coordinates}` | Compatible URL routing with vendor truck/ADR query params |
| POST | `/adr/check` | ADR 8.6.4 tunnel-category compliance check for a load, no routing |
| POST | `/geodata/validate` | Does a dataset's declared CRS actually describe its own coordinates? Catches swapped lat/lon axes, degrees labelled as metres and a projection mislabelled with the wrong EPSG code, before the data is drawn on a map; see [Validating a dataset's CRS](#validating-a-datasets-crs) |
| GET | `/geocode` · `/geocode/reverse` | Forward and reverse geocoding, when the self-hoster enables it (first-party `SN_GEOCODE_DIR`, or a `SN_PHOTON_URL` proxy); see [Geocoding](#geocoding) |
| POST | `/isochrone` | Reachability polygons (GeoJSON) for a time or distance budget (see the [analysis guide](/docs/analysis)) |
| POST | `/matrix` | Many-to-many duration and distance matrix (see the [analysis guide](/docs/analysis)) |
| POST | `/elevation` | Point elevation for up to 500 coordinates, honest per-sample source/resolution metadata, `null` (never a guess) wherever the engine has no terrain-tile coverage. Live on the hosted gateway over worldwide-land DEM tiles, 71°N to 56°S at ~30 m; self-hosters stage tiles and set `SN_ELEVATION_ENABLED=true`, else 501 (see the [analysis guide](/docs/analysis#elevation-post-elevation-and-post-elevationalong)) |
| POST | `/elevation/along` | Elevation profile along a route: distance, elevation and percent grade per sample, plus whole-route ascent/descent. Live on the hosted gateway; self-hosters stage tiles and set `SN_ELEVATION_ENABLED=true`, else 501 (see the [analysis guide](/docs/analysis#elevation-post-elevation-and-post-elevationalong)) |
| GET | `/timezone` | IANA timezone id, UTC offset, DST offset and abbreviation at a coordinate and timestamp; 501 unless the deployment has a timezone boundary database |
| POST | `/trace_route` · `/trace_attributes` | Map matching: snap a GPS trace to the road network, with per-edge attributes (see the [analysis guide](/docs/analysis)) |
| POST | `/route/report` | Distance/duration breakdown of an existing route or trace by road class, admin area, toll/bridge/tunnel and surface; pure aggregation over `/trace_attributes`, no extra engine call (see [Boundaries and route primitives](#boundaries-and-route-primitives)) |
| GET | `/boundary` | Administrative boundary lookup: point-in-polygon against the Valhalla admin database → country and sub-divisions with `admin_level`, `iso_code`, `name`, `drive_on_right`; 501 when the deployment has no admin-boundaries database (see [Boundaries and route primitives](#boundaries-and-route-primitives)) |
| POST | `/locate` · `/centroid` | Snap-to-graph and "meet in the middle" convergence-point search; Valhalla-only, 501 on a GraphHopper-only deployment (see [Boundaries and route primitives](#boundaries-and-route-primitives)) |
| POST | `/v1/cameras/along` | Safety cameras along a route shape, jurisdiction-gated server-side with a conservative off-by-default policy; 501 when the deployment has no camera data (see the [analysis guide](/docs/analysis)) |
| POST | `/v1/incidents/along` | Traffic incidents and closures along a route shape (National Highways closures feed plus its live events feed, merged), with honest per-source coverage metadata; 501 when the deployment has no incident data (see the [analysis guide](/docs/analysis)) |
| POST | `/v1/fuel/along` | Fuel stations along a route ranked cheapest first, each priced with its real engine-computed detour and flagged when the price is over 24 hours old; 501 when the deployment has no fuel-price file configured (see the [analysis guide](/docs/analysis#cheapest-fuel-along-a-route-post-v1fuelalong)) |
| POST | `/v1/weather/along` | Forecast weather per route sample, aligned to each sample's estimated time of arrival rather than the current time; 501 when the deployment has no weather source configured (see the [analysis guide](/docs/analysis)) |
| POST | `/v1/clearance/along` | Measured overhead clearance along a route shape, judged against surveyed point cloud geometry rather than map tags: a verdict per axis, the limiting point with its measured headroom and uncertainty bound, coverage gaps named apart from measured open sky, and a deep link into the survey viewer. Not a signed or legal height and never a certificate for the route; 501 when the deployment has no clearance artefacts (see [Measured clearance along a route](#measured-clearance-along-a-route) and the [clearance guide](/docs/clearance)) |
| GET | `/pointclouds/{dataset}/{file}` | Hosted point-cloud payload serving: a baked `@mapmap/points` payload (`<cloud>.bin`) or its sidecar (`<cloud>.json`) for one dataset. `Range` requests are honoured (206 with `Content-Range`); `ETag`/`If-None-Match` gives 304. A dataset is served only to the keys its manifest names, and everything else, including an unknown dataset and a dataset this key may not see, is an identical 404, so the surface is not an existence oracle. Metered in chunk units at the point-cloud discount rather than per request, so a whole-file download costs the same as fetching it in ranged slices; 404 when the deployment hosts no payloads (see the [SDK reference](/docs/sdks#point-clouds-mapmappoints)) |
| POST | `/optimise` (`/optimize`) | Multi-vehicle route optimisation with truck and ADR constraints (see the [optimisation guide](/docs/optimisation)) |
| GET | `/tiles/{territory}/{z}/{x}/{y}.mvt` (also `.pbf`) · `/tiles/{territory}/style.json` · `.../tiles.json` | Hosted vector tiles, MapLibre style and TileJSON (see the [maps guide](/docs/maps)) |
| GET · POST | `/styles` · `/styles/{spec}` · `/styles/{spec}/theme` | Hosted and custom map styles. `GET /styles/{spec}` and `.../theme` are public (browser map clients fetch them by bare URL); `GET /styles` lists your own styles and needs a key; POST is metered and only a style's owner may publish over it (see the [maps guide](/docs/maps)) |
| PUT · GET · DELETE | `/places` | Your own places dataset (store finder), scoped to your key, up to 10,000 places; see [Places](#places-bring-your-own-places) |
| GET | `/places/search` · `/places/nearest` | Ranked search and straight-line nearest-N over your own places; see [Places](#places-bring-your-own-places) |
| POST | `/verify` (intended path, not yet implemented on the gateway; today served at `POST /api/verify` on the website, see below) | Geo-hallucination firewall: checks whether AI-mentioned places and itineraries are real, findable and physically possible; see [Verify places](#verify-places-the-geo-hallucination-firewall) |
| GET | `/fonts/{fontstack}/{range}` · `/sprite/{file}` | Glyph PBFs and sprite sheets for rendering, public |
| GET · POST | `mapmap.ai/api/static-map` | Static Map Images: render a PNG/WebP/JPEG of a view, with a route/GeoJSON/marker overlay; see [Static map images](#static-map-images). Served from the website, not `$BASE`; no API key |
| GET | `/territories` · `/territories/index.sig` | Signed index of offline territories (see the [territories guide](/docs/territories)) |
| GET | `/territories/{id}/{version}/manifest` (also `.sig`) | Signed manifest for one territory version |
| GET | `/territories/{id}/{version}/layers/{addr}/{file}` | Offline layer download, metered against the monthly download allowance; verified accounts only |
| POST | `/v1/keys` | Self-serve key issuance (`{email, accept_tos, label?}` returns a provisional `snk_` key, plus a zero-authority `claim_url` + `display_to_user` for the human when the deployment has a console) |
| GET | `/v1/keys/self` | Key state: quota, usage, prepaid credit, owning identity email (quota-free) |
| GET | `/v1/keys/verify?token=` | Email verification, upgrades provisional keys to the free tier |
| POST | `/v1/keys/claim` | Mint a fresh claim pointer for the calling key's identity (key-authenticated, quota-free) |
| POST | `/v1/console/signin` | Email a 15-minute single-use sign-in link (uniform 202 whether or not the address has an account) |
| GET | `/v1/console/claims/{token}` | Resolve a claim pointer to a masked email (read-only, consumes nothing) |
| POST | `/v1/console/claims/{token}/signin` | The claim page's "email me a sign-in link" action |
| POST · DELETE | `/v1/console/session` | Exchange an emailed sign-in token for an identity session (POST) · sign out (DELETE) |
| GET | `/v1/console/self` | Identity account view for a session: balance, key metadata, usage, credit history; never key material |
| POST | `/map-issues` | Report a map error: `{lat, lon, category, note?, contact?}`, where `category` is one of `road-missing`, `road-wrong`, `restriction-wrong`, `name-wrong`, `place-closed`, `place-missing`, `other`. One Standard-class call, capped at 50 reports per key per day. This is the only write path onto the public [Map Health](/map-health) board, and the same one the report button on [the map](/map) uses |
| POST | `/v1/feedback` | Submit one integration retro (quota-free, never charged); see [Integration feedback](#integration-feedback) |
| GET | `/health` | Liveness/readiness, no auth |
| GET | `/openapi.json` | OpenAPI 3.1 document for this deployment |
| GET | `/llms.txt` | LLM-agent orientation text |
| POST | `/admin/keys` · `/admin/keys/{id}/credit` · `/admin/identities/credit` | Admin (admin token): create keys, credit the prepaid ledger (idempotent by `reason`) |
| DELETE | `/admin/keys/{id}` | Admin: revoke a key |
| GET | `/admin/overview` · `/admin/keys` · `/admin/keys/{id}/usage` · `/admin/identities` · `/admin/identities/{email}` | Admin: deployment overview, key listing and usage, identities with balance and credit history |
| GET | `/admin/retros` · `/admin/mcp-map-issues` | Admin: submitted integration retros, and the MCP map-issue NDJSON queue (`?limit=` 1–500, default 50; `?offset=`) |
| GET | `/admin/pointclouds` · `/admin/clearance` | Admin: hosted payload datasets with their declared bytes and each owner's computed monthly hosting fee, and the clearance artefact inventory (what is published, whether it still matches its sources, the bake's own quality statistics, and the reason for any artefact that will not open). Both read-only |
| POST | `/admin/identities/{email}/quota` · `.../download-allowance` · `.../mau-included` | Admin: set an identity's monthly quota, offline download allowance, or included monthly active users (idempotent by `reason`) |

The compatible URL endpoint accepts these `{profile}` values (with common
aliases): `driving` (`car`, `auto`), `truck`, `bus`, `bicycle` (`bike`,
`cycling`), `walking` (`foot`, `pedestrian`), `scooter` and `motorcycle`
(`motorbike`); anything else is rejected with `InvalidValue`. Each maps to
the matching Valhalla costing. On `POST /route`, `costing` is one of `auto`,
`truck`, `bus`, `motor_scooter`, `motorcycle`, `bicycle` or `pedestrian`. The
truck and ADR parameters below require the `truck` profile specifically;
sending them with any other profile is a `400`. Everything except truck and
ADR bills at the cheaper Standard rate.

The `adr` extension is honoured only on `POST /route` and `POST /optimise`.
The analysis endpoints (`/isochrone`, `/matrix`, `/trace_route`,
`/trace_attributes`) apply no ADR restrictions; an `adr` object sent there
is ignored, so do not rely on them for dangerous-goods compliance.

## Truck and ADR parameters

ADR is the European agreement on carriage of dangerous goods by road; tunnel
codes B–E restrict which tunnels a hazmat load may use. Slashed codes like
`C/E` mean the restriction differs by carriage mode (tank versus bulk). The
full tunnel-code table is on the [conventions](/docs/conventions) page; ADR
8.6.4 is the section of the agreement that defines the tunnel restrictions.

On the compatible URL endpoint, standard params (`overview`, `steps`,
`geometries`, `alternatives`: `true`/`false` or a number, max 3) are
honoured plus vendor extensions:

| Param | Unit | Example |
|---|---|---|
| `height` | metres | `height=4.0` |
| `width` | metres | `width=2.55` |
| `length` | metres | `length=16.5` |
| `weight` | tonnes | `weight=44.0` |
| `hazmat` | boolean | `hazmat=true` |
| `tunnel_code` | ADR code | `tunnel_code=D` |

With `steps=true`, the OSRM endpoint also accepts
`voice_instructions=true` (Mapbox-shaped spoken prompts with SSML and
distance triggers), `banner_instructions=true` (visual banners with
maneuver glyphs, plus lane diagrams where OSM carries `turn:lanes`) and
`language` (BCP 47 narration, e.g. `en-GB`). Navigation UIs built for the
Mapbox shapes parse our responses unchanged, and the SDK guidance session
surfaces the same instructions on device.

### Lane guidance

Where the road approaching a manoeuvre carries OSM `turn:lanes`,
`steps=true` responses include lane guidance on the step **before** the
manoeuvre (the approach), as `steps[].intersections[0].lanes`: one
object per physical lane, left to right:

| Field | Type | Meaning |
|---|---|---|
| `indications` | `string[]` | Turn directions the lane allows, as OSRM indication strings (`straight`, `left`, `slight right`, `uturn`, …). A combined `turn:lanes` value such as `left;through` yields multiple entries. |
| `valid` | `boolean` | The lane can legally be used for the upcoming manoeuvre. |
| `active` | `boolean` | Guidance recommends this lane (implies `valid`). |

With `banner_instructions=true` the same lanes also appear as Mapbox
`lane` components on the banner's `sub` line (`directions`, `active`,
`active_direction`), so Mapbox-built lane bars render unchanged. Coverage
follows OSM tagging: rich on major junctions in well-mapped regions,
absent elsewhere; treat a missing `lanes` array as "no lane guidance",
never "one lane". Roundabout manoeuvres carry no lane data.

## Route options

Boolean route preferences shape the cost model. All default to off; pass
`true` to enable. `avoid_tolls` and `avoid_motorways` apply only to
motorised profiles (`driving`, `truck`, `bus`, `scooter`, `motorcycle`);
requesting them on `bicycle` or `walking` returns `InvalidValue`.
`avoid_ferries` and `shortest` apply to every profile.

| Param | Effect | Profiles |
|---|---|---|
| `avoid_tolls` | Penalise toll roads (`use_tolls=0`) | motorised |
| `avoid_motorways` | Penalise motorways/highways (`use_highways=0`) | motorised |
| `avoid_ferries` | Penalise ferries (`use_ferry=0`) | all |
| `shortest` | Optimise for distance, not time (`shortest=true`) | all |

```bash
curl "$BASE/route/v1/driving/-0.1276,51.5072;-1.8904,52.4862?avoid_tolls=true&avoid_motorways=true" \
  -H "Authorization: Bearer $SN_API_KEY"
```

These options are honoured by the hosted gateway's router. The public
keyless demo router ignores costing options, so use an API key to see them
take effect.

Self-hosters choose the routing engine with `SN_ROUTING_ENGINE` (`valhalla`, `graphhopper` or `auto`); the hosted API and every endpoint above behave identically whichever is selected.

On `POST /route`, the body is a Valhalla-style request, `locations`
(at least two `{lat, lon}` objects) and a `costing`, plus the ADR
extension: a full vehicle profile in a top-level `adr` object.
`dimensions`, `tunnel_code` (nullable) and `hazmat` are all required keys,
which the gateway merges into `costing_options.truck` (a conflicting value
the caller already set is a `400`):

```json
{
  "locations": [
    { "lat": 51.5072, "lon": -0.1276 },
    { "lat": 52.4862, "lon": -1.8904 }
  ],
  "costing": "truck",
  "adr": {
    "dimensions": {
      "height_m": 4.0, "width_m": 2.55, "length_m": 16.5,
      "gross_weight_t": 44.0, "axle_load_t": null, "axle_count": null
    },
    "tunnel_code": "C/E",
    "hazmat": true
  }
}
```

ADR tunnel-category enforcement happens in costing and needs an ADR-capable
routing backend. Against a backend without ADR costing the dimensions still
apply but the tunnel restriction code is not enforced, and there is no
per-response warning field, so choose an ADR-capable backend when compliance
matters (`POST /adr/check` is always authoritative).
`/adr/check` takes `{"adr": <profile>, "tunnel_category": "A".."E"}`.

## Measured clearance along a route

A truck route enforces the restrictions the map records. Where a structure
carries no height tag the router has nothing to act on, returns the road as
passable, and the response is indistinguishable from one where every
structure was checked. An unchanged route is not a clearance.

`POST /v1/clearance/along` is the separate, measured check. Give it a route
shape and a vehicle height and it walks the corridor over a survey's own
geometry, station by station, and reports whether the vehicle passes.

```bash
curl -X POST "$BASE/v1/clearance/along" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "geometry_polyline6": "u{~vFvyys@fS]...",
        "vehicle_height_m": 4.2,
        "vehicle_width_m": 2.55,
        "margin_m": 0.0
      }'
```

`geometry_polyline6` is typically a shape from a prior route call:
`routes[0].geometry` with `?geometries=polyline6` on the compatible URL
endpoint, or `geometry_polyline6` from the `route` MCP tool. It is capped at
10,000 vertices. There is no truck profile
and no `adr` object in the body, and that is deliberate: the two calls are
two different claims, and composing them is the caller's job. Agents get the
composition as the `check_clearance_on_route` [MCP tool](/docs/mcp).
`vehicle_width_m` asks the corridor-width axis as well; `margin_m` is your
operating margin, added to the vehicle before the verdict and echoed back,
defaulting to `0`. `datasets` narrows the search to named datasets and can
never widen it.

The response is a report with a verdict per axis:

| `verdict` | Meaning |
|---|---|
| `pass` | Every assessed station clears the vehicle by more than the stated bound, over coverage with no gaps. |
| `fail` | At least one station does not clear the vehicle on the measured figure; `limiting` names the worst and `all_failures` lists every one. |
| `indeterminate` | The vehicle clears the measurement but not the bound on it, or the deciding surface is foliage on a survey too old to trust. `reasons` is machine-readable; `resolution_hint` is the sentence for a human. |
| `no_verdict` | Coverage gaps prevented an answer. Not a fail, and never a pass. |

The width axis additionally answers `not_assessed`, with the reason spelled
out, when no `vehicle_width_m` was supplied and when the height axis could
not be assessed either. Supply a width over ground the survey covers too
thinly at the corridor edges and the width axis runs the same ladder,
returning `indeterminate` or `no_verdict` over those stretches while the
height axis can still answer for the same ground.

Three things about the shape are worth knowing before you parse it.

**Every measured figure carries its bound.** `headroom_m` is what the survey
measured; `safe_headroom_m` is that figure less two sigma
(`sigma_m`) and less a one-sided sampling bias bound (`sampling_gap_m`).
`safe_headroom_m` is the verdict input and the figure to plan against.

**Coverage is two distinct shapes.** `complete` has no field for gaps at all,
and only a `pass` verdict takes it, so a route that passes over ground nobody
surveyed cannot be represented. `partial` carries `not_surveyed` and
`insufficient_data` as separate lists of located route sections, because "no
survey covers this" and "the survey covers this but not well enough" have
different remedies. Neither is the same answer as measured open sky, which is
a survey looking and finding nothing above the corridor.

**It is a measurement, not a certificate.** Every response, including the
clear one, carries a `clearance_enforcement` block with
`route_certified: false` and a caveat, and the measurement types have no
field for a signed or legal height. Where a posted height and a measured gap
disagree, that is a matter for the road authority that posted the sign.

`GET /admin/clearance` (admin token) is the operator inventory: published
artefacts, whether each still matches its sources, the bake's own quality
statistics, and the reason for any artefact that will not open. An artefact
that has not been checked against its sources is treated exactly as one known
to lag, so nothing passes on it.

The endpoint answers **501** on a deployment with no clearance artefacts
configured anywhere, computed before the caller's own visibility is
considered so it cannot become an oracle for other customers' data.
Feature-detect on it, as with elevation. Full contract, including the datum
argument and every refusal:
[measured clearance on a route](/docs/clearance).

## Validating a dataset's CRS

Geospatial files carry a declared coordinate reference system, and that
declaration is often wrong: a default left in place by an export tool, an
axis order swapped, a unit assumed. The failure is quiet. The numbers look
plausible, every tool accepts them, and the mistake surfaces only once the
data is drawn on a map and turns out to be in the wrong country.

`POST /geodata/validate` is a cheap check to run first. Give it the declared
CRS and a sample of the raw coordinates (a few dozen is plenty) and it
says whether the two agree.

```json
{
  "declared_crs": "EPSG:32610",
  "coordinates": [
    { "x": -13638000, "y": 6116000 },
    { "x": -13636000, "y": 6118000 }
  ]
}
```

The coordinates are `x`/`y`, deliberately not `lon`/`lat`, because whether
they are degrees is exactly the question being asked.

```json
{
  "verdict": "impossible",
  "interpreted_as": "UTM zone 10N",
  "problems": [
    "declared as UTM zone 10N, but x -13638000..-13636000 is outside the valid UTM easting range (100000..900000 m)"
  ],
  "suggestions": [
    "the magnitudes look like Web Mercator metres (EPSG:3857). Read directly that is 48.0603, -122.5030; read with the axes SWAPPED it is …"
  ],
  "extent": { "min_x": -13638000, "max_x": -13636000, "min_y": 6116000, "max_y": 6118000 }
}
```

`verdict` is one of:

| Verdict | Meaning |
|---|---|
| `consistent` | The coordinates are compatible with the declared CRS. |
| `suspect` | They fit, but only under an assumption worth stating: degrees declared as a projected CRS in metres, for instance. |
| `impossible` | The declared CRS cannot describe these coordinates at all. |

It understands geographic degrees (`EPSG:4326`, `4258`, `4269`), Web
Mercator (`3857`, `900913`, `3785`), the WGS84 and ETRS89 UTM zones, and
treats any other EPSG code as a generic projected CRS in metres.

This is a **sanity check, not a reprojection**. It carries no projection
database and never transforms coordinates: it reasons about the ranges,
spans and signs a CRS family implies. So it can be silent about an exotic
CRS, but it is never wrong about a UTM easting of 6.1 million. The same
check is available to agents as the `validate_geodata` MCP tool.

## Why this route

Every route can explain itself. Add a top-level `"rationale": true` to a
`POST /route` request in `truck`, `auto`, `bicycle`, `pedestrian` or
`motor_scooter` costing and the response gains a `rationale` block naming
the declared constraints or preferences that actually changed the route,
with the location where the routes part ways and what each one costs in
time and distance. The explanation is derived by the engine, not written
by a language model: there is nothing to hallucinate.

```json
{
  "locations": [
    { "lat": 51.5072, "lon": -0.1276 },
    { "lat": 52.4862, "lon": -1.8904 }
  ],
  "costing": "bicycle",
  "costing_options": { "bicycle": { "use_hills": 0.1 } },
  "rationale": true
}
```

```json
{
  "trip": { "…": "the normal route response, untouched" },
  "rationale": {
    "method": "route_divergence",
    "avoided": [{
      "kind": "hill_avoided",
      "constraint": "use_hills",
      "value": 0.1,
      "basis": "route_divergence",
      "baseline": "engine_default",
      "location": [-1.2431, 51.9911],
      "divergence": { "start": [-1.2431, 51.9911], "end": [-1.4102, 52.1408], "length_m": 23410 },
      "time_saved_s": 380,
      "distance_saved_m": 2900,
      "reason": "the declared routing preference use_hills 0.1 changed the route here: …"
    }],
    "caveats": "…"
  }
}
```

### How it works, honestly

The routing engine does not expose an exclusion set. There is no field
that says "this route avoided the 3.9 m bridge on the A40". So the
rationale is computed by **route divergence**: the gateway re-routes with
each declared constraint or preference relaxed and compares the geometry.
Where the relaxed route takes a different way, that field provably changed
the route, and the diverging span says where. One probe relaxes everything
at once (if the route does not change, nothing was binding and the
explanation honestly says so); when it does change, one probe per declared
field attributes the divergence. Fields that only bind in combination are
reported as a single `kind: "combined"` entry rather than guessed apart.

The baseline differs by field type, because honesty demands it:

| Field type | Relaxed to | `baseline` value |
|---|---|---|
| Truck constraints (`height`, `weight`, `hazmat`, `adr_tunnel_code`, …) | explicitly non-binding values (a zero-size vehicle, an unrestricted tunnel code); never removed, because removal would re-impose engine defaults that can themselves bind | `relaxed_to_non_binding` |
| Preference factors (`use_hills`, `use_tolls`, `use_lit`, …) | removed, so the engine default applies; relaxing to an extreme would let the probe's own bias masquerade as divergence | `engine_default` |
| Hard caps (`max_hiking_difficulty`) | their explicit maximum | `cap_lifted` |

Typed reason kinds by costing:

| Costing | Kinds |
|---|---|
| `truck` | `max_height`, `max_width`, `max_length`, `max_weight`, `hazmat`, `adr_tunnel` |
| `auto` | `highway_avoided`, `toll_avoided`, `ferry_avoided`, `living_street_avoided` |
| `bicycle` | `hill_avoided`, `surface_avoided`, `road_avoided`, `ferry_avoided`, `living_street_avoided` |
| `pedestrian` | `unlit_street_avoided`, `hiking_difficulty_limited`, `ferry_avoided`, `living_street_avoided`, `access_profile` |
| `motor_scooter` | `hill_avoided`, `primary_road_avoided`, `toll_avoided`, `ferry_avoided` |
| any | `combined` |

### What it can and cannot claim

The method proves a declared field was binding and where the routes part
ways. It does not identify the physical restriction or feature that caused
the divergence: the specific signed bridge, the particular hill, the
individual unlit street. The `value` reported is your declared value, not
the infrastructure limit. Only avoidance-side declarations are attributed
(a preference that seeks something rather than avoids it cannot honestly
be described by an avoidance vocabulary, so it is skipped). Every entry
carries `basis: "route_divergence"` and the block repeats these limits in
`caveats`, so an agent relaying the explanation relays the epistemics too.

There is deliberately no `incident_avoided` kind: incident records are
not a routing input, so no divergence can ever be attributed to one, and
no delay figure is ever estimated from one. Live traffic speeds (UK
Strategic Road Network) are a routing input when the request carries a
`date_time` (they shape ETAs and route choice) but traffic is not a
preference field, so it has no rationale kind either. To see what speed
data stood behind an ETA, request the `traffic: true` annotation, which
reports coverage, confidence and sources per leg.

### Live incidents as context

Where the hosted gateway has incident data configured, the rationale block
also carries a `context.incidents` array: currently active incidents whose
geometry overlaps the route corridor, each with its road, severity, cause
and position along the route, plus the source's coverage note and licence.
These are context, never causes: the route did not avoid them and no
delay estimate is attached, for the reason above. Where a source publishes
its own delay figure, `/v1/incidents/along` passes it through as
`delay_s`; this block deliberately drops it, because a number here would
read as our claim about your route rather than the operator's about their
network. An empty array means no
matched incident within the configured sources' coverage, not a clear
road; when no source is configured the field is absent entirely, because
an empty list would be a claim.

The rationale is opt-in because it costs up to 1 + N extra engine calls
(one combined probe plus one per declared field; the per-field probes run
concurrently, so the explanation adds little latency), and it is billed for
exactly the computations it performs: one call of its price class for the
route itself and one for each probe that runs. At most 8, and typically
fewer. A call with nothing to probe costs 1. A call whose declarations turn
out not to have changed the route costs 2, because it stops after the first
probe. Only a truck declaring all six constraints reaches 8. For truck the
weights multiply, so a truck rationale call drawing 3 computations draws 60
included standard calls. Beyond included volume the same count is charged
in money, at the class's ordinary per-unit price.

You are never asked to take that on trust. Every rationale block carries a
`metering` object saying what the call was charged and what the charge
bought, and the same figures ride on the `x-mapmap-rationale-computations`,
`x-mapmap-rationale-reserved` and `x-mapmap-quota-units` response headers:

```json
"metering": {
  "engine_computations": 3,
  "route_computations": 1,
  "probe_computations": 2,
  "billed_units": 3,
  "max_billed_units": 8
}
```

Because the count is only known once the probes have run, the call is
reserved against the ceiling its own request implies and the unused part is
returned before the response is sent, so nobody pays for a probe that did
not run. A call paid with `X-PAYMENT` is the one exception: an x402
authorisation is signed for the quoted amount in advance and cannot be
settled in part, so it pays the ceiling it was quoted. A failed probe
degrades to a `note` on the block; the primary route is never failed by
its own explanation. The MCP `route` tool accepts the same
`rationale: true` flag and returns the same block, so agents can ask for
a route and its reasons in one call; it fans out into ordinary metered
route calls, so it has always been billed the same way.

## Landmark turn instructions

People do not give directions by street name. They say "turn right just
after the petrol station", because that is what you can see from the car.
Add a top-level `"landmarks": true` to a route request and every eligible
manoeuvre gains a `landmark_instruction` anchored to a recognisable place
from the first-party place index:

```json
{
  "locations": [{"lat": 51.5074, "lon": -0.1278}, {"lat": 51.5155, "lon": -0.1410}],
  "costing": "auto",
  "landmarks": true
}
```

```json
{
  "trip": {
    "legs": [{
      "maneuvers": [{
        "type": 10,
        "instruction": "Turn right onto Great Portland Street",
        "landmark_instruction": "Turn right just after the Shell garage"
      }]
    }]
  },
  "landmarks": { "annotated": 3 }
}
```

**It is additive.** The engine's own `instruction` is never replaced, so
you choose which to show and nothing breaks on a route where nothing is
recognisable. Clients that read instructions aloud should prefer
`landmark_instruction` when it is present: it is the one a passenger would
have given you.

### What gets named, and what never does

A wrong landmark is worse than no landmark. On an API surface it is worse
still, because there is no map beside the text to correct the impression
and a voice client will read it out as fact. So a place is only named when
it clears every gate:

| Gate | Rule |
|---|---|
| Recognisability | Scored by category. Highest are the things that are unmistakable from a moving car and usually the only one of their kind on the road: petrol stations, supermarkets, stations, churches, pubs and department stores. Then cinemas, town halls and DIY sheds. Cafés, banks, pharmacies and takeaways sit below the bar on their own and need a household name to clear it. Categories outside the vocabulary score zero and can never anchor an instruction. |
| Household names | A national chain gets a bonus, matched as whole words against the name and the `brand` tag, so "Tesco Express" counts and "BPX Logistics" is not mistaken for BP. |
| Distance | Beyond 40 m from the junction the place is set back from it (a supermarket across its own car park) and is never named. Inside that, distance only breaks ties between comparable places: a station across the junction still beats a café on the corner, because the kind of place matters more than a few metres. |
| Freshness | Any record tagged disused, abandoned or closed is never named, whatever it scores. A landmark that is not there any more is worse than none. |
| Manoeuvre shape | Only turns, slight and sharp turns, and forks are annotated. "Take the second exit just after Tesco" is ambiguous about what the landmark qualifies, so roundabouts, merges, ramps and ferry transitions keep their own instruction. |

When nothing clears the bar the manoeuvre is simply not annotated. Silence
is the safe answer.

### On the OSRM route surface too

The same flag works on `GET /route/v1/{profile}/{coordinates}`: add
`landmarks=true` (alongside `steps=true`, since the phrasing rides on the
steps) and each step gains a `landmark_instruction` beside its ordinary
fields. Same gates, same conservatism, and clients that ignore the field
keep exactly the behaviour they had.

```
GET /route/v1/driving/-0.1278,51.5074;-0.1410,51.5155?steps=true&landmarks=true
```

```json
{
  "routes": [{ "legs": [{ "steps": [{
    "name": "Great Portland Street",
    "maneuver": { "type": "turn", "modifier": "right" },
    "landmark_instruction": "Turn right just after the Shell garage"
  }] }] }]
}
```

### The summary block

The response carries a `landmarks` block reporting how many manoeuvres
were annotated. It also carries a `note` when the per-route fair-use cap
was reached, or when the deployment has no place index configured at all.
That distinction matters: "no landmarks" must never be ambiguous between
"nothing recognisable on this road" and "this deployment cannot look them
up", because only one of those is a fact about the route.

Landmark lookups run in-process against the place index, so the cost is
one index query per eligible manoeuvre rather than a network round trip.

## Traffic confidence

Every routing API gives you an ETA. Almost none tells you what that ETA
rests on: whether the road ahead was measured this minute, inferred from
a typical Tuesday, or simply assumed from its speed limit. Add a top-level
`"traffic": true` to a route request and each leg comes back with a
`traffic` object that says so:

```json
{
  "locations": [{"lat": 51.5074, "lon": -0.1278}, {"lat": 52.4862, "lon": -1.8904}],
  "costing": "auto",
  "date_time": { "type": 0, "value": "2026-08-13T09:20" },
  "traffic": true
}
```

```json
{
  "trip": {
    "legs": [{
      "traffic": {
        "covered_pct": 0.62,
        "confidence": 0.78,
        "band": "live",
        "sources": ["live", "profile", "default"],
        "shares": { "live": 0.62, "predicted": 0.0, "profile": 0.2, "default": 0.18 },
        "distance_m": 178432.0
      }
    }]
  }
}
```

`covered_pct` is the share of the leg's **distance** (0–1) that ran over
roads carrying a live or near-horizon-forecast record when you asked.
`confidence` is a distance-weighted data-quality score in the bands the
industry already publishes: above `0.7` the leg is live-dominated, `0.5`
to `0.7` means typical historical speeds carried it, below `0.5` means
default speeds did. `band` spells that out so you never have to hard-code
the thresholds, and `sources` names exactly which layers contributed.

Ask for alternates and each one is annotated too, so "which of these
routes has the best data behind it?" becomes a field comparison rather
than a guess.

**It is free.** The annotation is computed from the geometry your response
already carries (no second routing call, no extra charge, no quota
weight) and it is purely additive, so omitting the flag leaves your
payload exactly as it is today.

### How coverage is measured, and what it does not claim

Coverage is measured geometrically: your route's shape is walked in 25 m
steps and each step matched, within 30 m, against the traffic network's
own map-matched link geometry, then credited by distance to whatever that
link carried: a current measured speed, a near-horizon forecast, a
typical speed, or nothing.

That tells you a record **existed** for the road you drove. It is not a
claim that the router's internal blend used that exact number (it fades
live speeds toward typical ones over the first hour of a journey), it
matches a link as a whole rather than edge by edge, and it does not
distinguish carriageway direction. Every response repeats those limits in
a `caveats` string, so an agent relaying the number relays its boundaries
with it.

### It never claims more than your request allows

Live and typical speeds only enter costing for time-dependent requests,
and `speed_types` lets you switch layers off. The annotation follows:

| Your request | `live` claimed | `profile` claimed |
|---|---|---|
| no `date_time` | no | no |
| `date_time.type: 0` (now) | yes | yes |
| a stated departure or arrival time | no | yes |
| `speed_types` without `current` | no | unchanged |
| `speed_types` without `predicted` | unchanged | no |

A layer you excluded is downgraded rather than quietly reported anyway:
switch `current` off and no `live` can appear in `sources`, however good
the coverage is. When a layer is excluded the block carries a `note`
saying which, so a zero is never left looking like a data gap.

### An empty result is never an all-clear

Traffic records cover the National Highways strategic road network in
England. Local roads, Scotland, Wales and everywhere outside the UK carry
no record, and the honest answer for that distance is `default`.
`covered_pct: 0` means "no data here", never "the road is clear". A
snapshot more than five minutes old stops supporting a live claim at all
and the block says `stale: true`: a frozen feed decays to typical speeds
rather than going on asserting a speed nobody is measuring.

On a deployment with no traffic data configured at all, the block says
that in as many words instead of reporting zero coverage: "this
deployment cannot see traffic" and "these roads have no traffic" are
different statements, and only the first is ours to make.

## Places: bring your own places

Customer-owned point data (store locations, depots, branches) attached to
your API key: upload once, then search it with the same ranking engine that
powers territory geocode indexes. The canonical use case is a store finder;
the web SDK's `PlacesLayer` renders the same data client-side
(see [SDKs](/docs/sdks)).

Datasets are strictly per key: a key can only ever read, search or delete
the places it uploaded. All five routes are authenticated and metered at the
Standard price class, one call per request.

| Method | Path | What it does |
|---|---|---|
| PUT | `/places` | Replace your dataset; body `{"places": [{"id", "name", "lat", "lon", …}]}`, up to 10,000 places; returns `{"count", "updated_at"}` |
| GET | `/places` | Fetch your full dataset plus meta; `404` before the first upload |
| DELETE | `/places` | Remove dataset and index; `204`, idempotent |
| GET | `/places/search` | Ranked full-text search: `q` (required), optional `lat`/`lon` proximity focus (both or neither), `limit` 1–50, `category` filter |
| GET | `/places/nearest` | Straight-line nearest-N: `lat`, `lon`, `limit`. For drive-time ranking feed your places into `POST /matrix` as targets |

Each place record carries optional `alt_names`, `categories`, address parts
(`street`, `locality`, `region`, `postcode`, `country_code`), an
`importance` weight and a free-form `properties` object (≤2 KiB) that
round-trips untouched: opening hours, phone numbers, URLs. Over 10,000
places is a `413` (`places-too-many`); per-field validation failures are a
`422` (`invalid-places`) with a `problems` list naming each offender.

```bash
curl -fsS -X PUT "$BASE/places" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"places": [{"id": "croydon-01", "name": "MyBrand", "lat": 51.3762,
       "lon": -0.0982, "locality": "Croydon", "categories": ["store"]}]}'

curl -fsS "$BASE/places/search?q=MyBrand+Croydon&lat=51.5&lon=-0.12" \
  -H "Authorization: Bearer $API_KEY"
```

## Geocoding

Geocoding has two backends, in precedence order. **First-party** (set
`SN_GEOCODE_DIR` to an sn-geocode index, a territory index from a signed
package, or a whole-world merge of them): forward and reverse geocoding are
served in-process by our own `sn-geocode` engine, so the ranking you get
from the API is the ranking a device gets offline from its package: one
engine, online and off. **Photon proxy** (`SN_PHOTON_URL`) is the fallback
for operators who prefer it, and `SN_GEOCODE_FORWARD=photon` routes forward
search to the proxy while reverse stays first-party. That split is what the
hosted gateway currently runs: reverse geocoding (and its POI `details`)
from a worldwide first-party index of every named OSM place and POI, forward
search via Photon while first-party forward ranking closes the remaining
world-scale gaps. With neither backend set, `/geocode` and
`/geocode/reverse` answer `501`
(`urn:sn-gateway:problem:geocoding-not-enabled`). Both endpoints are
authenticated and metered exactly like any other Standard call (one metered
call per request).

First-party POI hits also carry a **`details` object**: a curated set of
display attributes straight from OpenStreetMap: `opening_hours`, `phone`,
`website`, `email`, `brand`, `operator`, `cuisine`, `wheelchair`,
`wikidata`, `wikipedia`, EV `socket:*` connectors and friends. It is what
powers "open now, +44 …, tate.org.uk" place cards without a second data
provider; keys absent in OSM are simply omitted.

`GET /geocode`, forward geocoding:

| Param | Required | Meaning |
|---|---|---|
| `q` | yes | Free-text place query |
| `limit` | no | Maximum results, `1`–`10` |
| `lang` | no | Result language, e.g. `en`, `de`, `fr` |
| `bias` | no | Location bias as `lon,lat` (WGS84), e.g. `-0.1278,51.5074`; reorders candidates, never excludes one |
| `bbox` | no | Hard bounding-box filter as `minLon,minLat,maxLon,maxLat` (WGS84), e.g. `-0.489,51.286,0.236,51.686` for Greater London; the same convention as Mapbox/Google/HERE. Unlike `bias`, this excludes any result outside the box outright. Honoured on both backends; `minLon` may exceed `maxLon` to describe a box crossing the antimeridian |

`GET /geocode/reverse`, reverse geocoding:

| Param | Required | Meaning |
|---|---|---|
| `lon` | yes | Longitude (WGS84) |
| `lat` | yes | Latitude (WGS84) |
| `kinds` | no | Restrict hits to these kinds, comma-separated: `address`, `street`, `locality`, `poi`, `postcode`. `kinds=poi` is the "what place did the user tap" lookup; in dense areas the unfiltered nearest-5 is often all addresses and postcodes. First-party backend only |

The response is a GeoJSON `FeatureCollection` in the Photon property shape,
so a client written against either backend works against the other. The
first-party backend adds a stable `id` on every hit, `distance_m` on
reverse-geocode hits (distance from the query point, metres), and `details`
on POI hits:

```bash
curl "$BASE/geocode/reverse?lon=-0.09934&lat=51.50743&kinds=poi" \
  -H "Authorization: Bearer $SN_API_KEY"
```

```json
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": { "type": "Point", "coordinates": [-0.0998, 51.5075] },
      "properties": {
        "id": "osm:w24642569:poi",
        "name": "Tate Modern",
        "type": "poi",
        "countrycode": "GB",
        "street": "Bankside",
        "city": "London",
        "postcode": "SE1 9TG",
        "categories": ["arts_centre"],
        "distance_m": 31.0,
        "details": {
          "internet_access": "wlan",
          "opening_hours": "Su-Th 10:00-18:00; Fr-Sa 10:00-21:00",
          "phone": "+44 20 7887 8888",
          "website": "https://www.tate.org.uk/visit/tate-modern",
          "wheelchair": "yes",
          "wikidata": "Q193375",
          "wikipedia": "en:Tate Modern"
        }
      }
    }
  ]
}
```

A missing `q` (or malformed `lon`/`lat`, or an unknown `kinds` value) is a
`400`; an upstream backend failure is a `502`.

## Boundaries and route primitives

Three endpoints, each cheap and previously unexposed even though the data
or engine capability already existed underneath.

**`GET /boundary?lat=&lon=`**: point-in-polygon against the same
administrative-boundary database Valhalla's own routing already consults,
now exposed read-only. Returns the containing country and sub-divisions:

```json
{
  "point": { "lat": 51.5074, "lon": -0.1278 },
  "admins": [
    { "admin_level": 2, "iso_code": "GB", "name": "United Kingdom", "drive_on_right": false },
    { "admin_level": 4, "iso_code": "ENG", "name": "England", "drive_on_right": false }
  ]
}
```

An empty `admins` array is a normal answer (open sea, or a genuine gap in a
deployment's admin-boundary coverage), never an error. 501 when the
operator has not set `SN_ADMIN_BOUNDARIES_DB`.

**`POST /route/report`**: a summary over an existing route (`locations`)
or trace (`shape`/`encoded_polyline`): total distance and duration broken
down by `road_class`, by admin area, plus toll/bridge/tunnel counts and a
surface breakdown. No new engine capability; it aggregates
`/trace_attributes` internally, so it costs one ordinary metered call, not
two.

**`POST /locate`** and **`POST /centroid`**: two Valhalla services that
had no client bindings until now. `/locate` snaps a coordinate to the
graph and reports the edges (way ids, side of street) found there.
`/centroid` is "meet in the middle": given several locations, it finds the
point in the road graph where paths from all of them converge for the
least total cost, and returns one path per location to that shared point:
a primitive absent from Google, Mapbox, HERE, TomTom and NextBillion
alike. Both require `SN_ROUTING_ENGINE=valhalla` or `auto` (the default);
a GraphHopper-only deployment answers 501, since GraphHopper has no
equivalent service.

## Verify places: the geo-hallucination firewall

`POST /api/verify` on the website today; see [Gateway path](#gateway-path-not-yet-implemented)
below for the intended first-class endpoint. Checks whether places (and
itineraries) an AI mentioned are real, findable and physically possible,
using MapMap's own `/geocode` and `/matrix` as evidence. Also exposed as
the `verify_places` MCP tool (see [MCP](/docs/mcp)).

### The three-state verdict

Every claim gets exactly one of three verdicts. **Never a boolean.**

| Verdict | Meaning |
|---|---|
| `verified` | Matched a real place in MapMap's index: the matched place (with a stable id), and the source and date of the evidence. |
| `contradicted` | A specific, dated, sourced fact contradicts the claim; in this release, only an itinerary leg the routing engine proves cannot be driven in the stated time. Comes with the contradicting source and date. |
| `unverified` | No evidence either way: no confident name match, or the check could not run. |

**Hard rule, non-negotiable: MapMap never asserts that a named real
business does not exist or has closed.** That is a pure defamation risk;
we can be wrong about a business we have never visited, and "closed"
or "never existed" are exactly the claims that damage a real business if
wrong. The only negative outputs this API can produce are "contradicted,
with a dated source" or "unverified". This is enforced in code, not just
convention: every sentence the implementation generates comes from a
fixed template switched over the three-member verdict type (a fourth
verdict, some "does not exist" state, is impossible to add without a
compile error), and the test suite statically scans the implementation
source for banned phrases ("does not exist", "has closed", "no longer
trading", …) and separately fuzzes the render path with adversarial
claim names to prove the guard survives real, unsanitised input. See
`website/lib/verify-places.ts` and `verify-places.test.ts` in the repo.

### Request

Structured claims (reliable; supports itinerary feasibility):

```json
POST /api/verify
Content-Type: application/json

{
  "claims": [
    { "id": "breakfast", "name": "The Roman Baths", "locality": "Bath", "sequence": 1, "claimed_time": "2026-08-01T09:00:00+01:00" },
    { "id": "meeting", "name": "Edinburgh Castle", "locality": "Edinburgh", "sequence": 2, "claimed_time": "2026-08-01T10:00:00+01:00" }
  ],
  "costing": "auto"
}
```

Or free text (best-effort extraction; existence checks only, no
itinerary feasibility, since there are no explicit times to anchor legs
to):

```json
{ "text": "Breakfast at the Roman Baths in Bath, then a 10am meeting at Edinburgh Castle." }
```

| Field | Type | Notes |
|---|---|---|
| `text` | string | Free text to extract place claims from. Mutually exclusive with `claims`. Max 8,000 characters. |
| `claims` | array | Structured claims (below). Mutually exclusive with `text`. Max 20 per request. |
| `costing` | string | Optional, default `auto`. Same values as `/matrix`. Used for feasibility legs. |

One claim:

| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | The place name as claimed. |
| `id` | string | no | Caller-chosen id, echoed back. Auto-assigned (`claim-1`, …) when absent. |
| `locality` | string | no | Disambiguating context, e.g. `"Bath"`. Country-level words (`UK`, `England`, …) are stripped before querying; appending them to a name search adds noise, not signal (confirmed against the live gateway: `"…, Oxford, UK"` ranked an unrelated place literally named "… UK" above the correct Oxford result; `"…, Oxford"` did not). |
| `sequence` | number | no | Itinerary position. Claims sharing increasing `sequence` values, both carrying `claimed_time`, form legs the feasibility pass checks. |
| `claimed_time` | string (ISO 8601) | no | When the itinerary claims you are at this place. |

### Response

```json
{
  "results": [
    {
      "id": "breakfast",
      "claim": { "name": "The Roman Baths", "locality": "Bath", "sequence": 1 },
      "verdict": "verified",
      "match": { "id": "osm:w500279537", "name": "The Roman Baths", "lat": 51.3810283, "lon": -2.359675, "category": "attraction" },
      "evidence": [
        { "source": "mapmap-geocode", "checked_at": "2026-07-26T15:53:57.710Z", "detail": "matched \"The Roman Baths\" (attraction) in the MapMap geocode index." }
      ]
    },
    {
      "id": "meeting",
      "claim": { "name": "Edinburgh Castle", "locality": "Edinburgh", "sequence": 2 },
      "verdict": "verified",
      "match": { "id": "osm:w4301292", "name": "Edinburgh Castle", "lat": 55.9486884, "lon": -3.2004184, "category": "attraction" },
      "evidence": [
        { "source": "mapmap-geocode", "checked_at": "2026-07-26T15:53:58.000Z", "detail": "matched \"Edinburgh Castle\" (attraction) in the MapMap geocode index." },
        { "source": "mapmap-matrix", "checked_at": "2026-07-26T15:53:58.098Z", "detail": "the routing engine declined to compute this leg because it exceeds the engine's maximum routable distance, so feasibility could not be confirmed either way. The leg is long, but no travel time was computed, so nothing here contradicts the claimed times." }
      ],
      "feasibility": {
        "from_id": "breakfast", "from_name": "The Roman Baths",
        "to_id": "meeting", "to_name": "Edinburgh Castle",
        "available_minutes": 60, "travel_time_minutes": null, "status": "implausible"
      }
    }
  ],
  "summary": "2 claims checked: 2 verified, 0 contradicted, 0 unverified. breakfast (\"The Roman Baths\"): verified. meeting (\"Edinburgh Castle\"): verified. Leg breakfast -> meeting is implausible: uncomputable needed, 60 minutes claimed."
}
```

This is a real response captured against the production gateway (`https://api.mapmap.ai`) on 26 July 2026.

`summary` is always present as a plain string alongside the structured
`results`; several MCP clients (notably ChatGPT connectors) drop
non-text content blocks, so the text summary must stand on its own.

`evidence[].checked_at` is when MapMap gathered that piece of evidence
(the check's own timestamp), not necessarily the underlying map data's
edit date; the gateway does not expose OpenStreetMap edit timestamps
today.

`feasibility.status` is `feasible`, `implausible` (tight; flagged, not
asserted as impossible) or `impossible`. Only `impossible` flips the
arriving stop's `verdict` to `contradicted`; `implausible` is a flag
only, since the evidence does not clear the bar for a contradiction.

### Errors

| Status | When |
|---|---|
| 400 | Malformed body, unsupported `costing`, more than 20 claims, text over 8,000 characters, or free text with no extractable claims. |
| 413 | Request body too large. |
| 429 | Per-IP rate limit exceeded (`Retry-After` set). |
| 502 | The upstream geocode/matrix check failed unexpectedly. |
| 503 | The deployment has no gateway URL / demo key configured. |

### Evidence sources

- **Existence and location**: `GET /geocode` (above), matched against the
  claimed name with a conservative token-overlap threshold. Deliberately
  strict: a false "unverified" is safe (it just means "no evidence
  either way"); a false "verified" defeats the product. Live testing
  found "The Sherlock Holmes Museum Annexe" (a plausible-sounding
  embellishment of the real, annexe-free Sherlock Holmes Museum) scoring
  0.75 token-overlap against the real name, which is why the threshold
  sits at 0.85.
- **Feasibility**: `POST /matrix` between consecutive itinerary stops, in
  the request's `costing`. When the routing engine cannot even compute a
  leg (beyond its maximum routable distance) that is reported as
  `implausible`, not `impossible`: the engine's distance cap says nothing
  about the claimed time window, so only a real computed travel time may
  contradict a claim. The actual computed (or uncomputable) travel time is
  always included in the evidence.
- **Your own places**: if you maintain a proprietary dataset via
  [`/places`](#places-bring-your-own-places), `GET /places/search` and
  `GET /places/nearest` are the equivalent existence check scoped to
  your own data; pass your own API key. Not wired into `/api/verify` in
  this release; noted here because "is this claimed branch one of ours"
  is a different, and differently-evidenced, question from "does this
  place exist anywhere in the world".
- **Closure signals**: the gateway's world index carries no explicit
  "closed" flag (it is a live OpenStreetMap-derived snapshot, not a
  directory with lifecycle status), so a missing match is `unverified`,
  never evidence of closure. Where a matched POI's `details` carries a
  `website`, a future iteration could check that domain's own JSON-LD or
  microdata for its own opening-hours claims: the business's own site
  only, respecting `robots.txt`, never Google/Yelp/aggregators (both a
  ToS problem and, in the UK specifically, a database-right problem: the
  UK has no commercial text-and-data-mining exception). Not implemented
  in this release.

### Gateway path (not yet implemented)

This release ships only the website's demo proxy, `POST /api/verify` (a
server-held demo key, rate-limited, the same pattern as
`/api/route-demo` and `/api/map-issue`). The natural production surface
is a first-class `POST /verify` on the gateway itself, authenticated
with the caller's own key, metered at Standard rate (one call per
claim's existence check, one per feasibility leg, mirroring how
`/geocode` and `/matrix` already bill), sharing this exact
request/response contract. That `sn-gateway` addition is tracked but out
of scope for this change, and needs a box rebuild to deploy. Until then,
call the website route or the `verify_places` MCP tool.

## Integration feedback

`POST /v1/feedback` takes one **integration retro**: the short structured
report a developer (or, with the developer's approval, their AI coding agent)
sends once a MapMap integration works or is abandoned. It is
key-authenticated, quota-free and **never charged**, like `/v1/probe`. Agents
usually reach it through the MCP `submit_integration_retro` tool rather than
by hand; see the [MCP server](/docs/mcp) page.

```bash
curl -X POST "$BASE/v1/feedback" \
  -H "Authorization: Bearer $SN_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "what_built": "A store finder with restriction-aware truck routing.",
    "problems": [
      { "area": "docs", "description": "Tunnel codes must be URL-encoded on the compatible endpoint.", "workaround_found": true }
    ],
    "gotchas": ["Bare coordinates are lon,lat."],
    "wins": ["check_adr_tunnel answered without a route call."],
    "docs_gaps": ["No worked ADR example for the OSRM-compatible path."],
    "agent_name": "Claude Code",
    "sdk_version": "0.4.1"
  }'
```

| Field | Rules |
|---|---|
| `what_built` | Required, non-empty, ≤500 bytes |
| `problems[]` | ≤20 entries of `{area, description, workaround_found}`; `area` is one of `sdk`, `api`, `mcp`, `docs`, `billing`, `self-host`, `other`; `description` ≤1000 bytes |
| `gotchas[]` · `wins[]` · `docs_gaps[]` | ≤20 entries each, ≤500 bytes per entry |
| `agent_name` | Optional, ≤100 bytes |
| `sdk_version` | Optional, ≤50 bytes |

A stored retro answers `202 Accepted`:

```json
{ "id": "9c1f2a6e-…", "status": "received" }
```

Each key may submit **5 retros per rolling 24 hours**; the sixth is a `429`
with problem type `urn:sn-gateway:problem:feedback-velocity`. A payload that
breaks the schema (missing `what_built`, an unknown `area`, an over-long
field or an over-list) is a `400` (`bad-request`). Unknown fields are
dropped rather than stored.

Retro content is stored verbatim as untrusted text and reviewed by a person;
it is never interpreted or executed. What is kept, for how long, and how to
have it deleted is set out in the
[Agent Feedback Programme](/legal/agent-feedback) and the
[telemetry disclosure](/legal/telemetry).

## Static map images

Agents are vision models, not MapLibre runtimes: they can look at a map but
cannot render one. `GET`/`POST https://mapmap.ai/api/static-map` renders a
MapLibre view (a centre and zoom, a bbox, or auto-framed to whatever overlay
you pass) to a PNG, WebP or JPEG image. This is a **website** endpoint, not
a gateway one: no `$BASE`, no API key, `mapmap.ai` directly.

```bash
# A plain city view
curl "https://mapmap.ai/api/static-map?center=-0.1276,51.5072&zoom=13&size=800x500" -o map.png

# Render the route you just got back from /route/v1 (see the precision
# trap below) straight onto the map
curl "https://mapmap.ai/api/static-map?route=$ROUTE_GEOMETRY&precision=5&size=800x500" -o route.png

# Render an /isochrone response; POST when the polygon is too big for a
# URL, which a real isochrone usually is
curl -X POST "https://mapmap.ai/api/static-map?size=800x500" \
  -H "Content-Type: application/json" \
  --data @isochrone-response.json \
  -o isochrone.png
```

| Parameter | Meaning |
|---|---|
| `center=lon,lat` + `zoom=` | Fixed camera (`zoom` required with `center`, 0-20) |
| `bbox=west,south,east,north` | Fit to a box (GeoJSON bbox order); omit both `center` and `bbox` to auto-fit to whatever overlay you pass instead |
| `size=WxH` (or `width=`/`height=`) | Up to 1280×1280; append `@2x` (or `retina=2`) for a retina image at double the pixel density |
| `bearing=` · `pitch=` | Degrees; `pitch` capped at 85 |
| `style=` | `light` (default) or `dark` for the site's own live-map style, a hosted style id from your own `GET /styles` listing, or an `https://` style URL on an allowlisted MapMap host |
| `format=` | `png` (default), `webp` or `jpeg`; `quality=1-100` for the lossy formats |
| `route=` (or `polyline=`) | An encoded polyline overlay; `precision=6` (default) for `POST /route`'s native shape, `precision=5` for `GET /route/v1`'s default OSRM-compatible shape (see the trap below); `route_colour=` and `route_width=` to restyle it |
| `geojson=` (repeatable) or a JSON POST body | GeoJSON overlays: an isochrone's `FeatureCollection`, a shape, or a `Feature`; simplestyle-spec `stroke`/`fill`/`fill-opacity` properties are honoured |
| `markers=lon,lat[,label[,colour]]` (repeatable, `;`-separated) | Up to 50 pins; `label` is 1-2 characters |
| `satellite=1` | Sentinel-2 imagery where the pilot region has coverage |
| `buildings3d=1` | 3D building extrusions (pair with a `pitch`) |
| `lang=` | `local`, or an ISO 639 code, for map labels |
| `pois=0` | Hide POI labels |

**Precision trap, found the hard way while building this:** `GET
/route/v1/{profile}/{coordinates}` (the OSRM-compatible routing endpoint)
returns an encoded polyline at **precision 5** by default (matching real
OSRM), not 6. `POST /route` and the MCP `route` tool return **precision 6**
(the native Valhalla shape), which is also this endpoint's default. Passing
a precision-5 string through with no `precision=5` decodes silently to the
wrong place (no error, just a badly-wrong map) so always add
`precision=5` for anything from `/route/v1` that did not explicitly request
`geometries=polyline6`.

**Legal, always on:** every image carries OpenStreetMap/OpenMapTiles
attribution (plus Sentinel-2/Copernicus wording when `satellite=1` renders
imagery) and the MapMap mark, bottom-right. No parameter removes either.

**Caching:** responses carry an `ETag` and a long `Cache-Control` (a year,
immutable, for a pinned render; an hour for an unpinned hosted style that
could be republished); send `If-None-Match` and expect `304`s on a hot map.

**Limits:** dimensions, overlay counts and payload size are capped (see the
parameter table); requests are also rate-limited per client and by a
concurrent-render ceiling, so this cannot become a free rendering farm.
There is no API key on this endpoint; the guard is the limits above, not
billing.

### The POST body contract

Integrators have been discovering these by trial and error, so here they
are stated plainly, verified against production:

- **The POST body IS the GeoJSON.** Send the value itself: a `Feature`, a
  `FeatureCollection`, a bare geometry, or a JSON **array** of any of
  those. Do not wrap it in an envelope object. `{"geojson": [...]}` (or
  any other wrapper key) carries no coordinates of its own, so it fails
  with a `400`, "a geojson value carried no usable coordinates", however
  valid the GeoJSON inside the wrapper is.
- **Styling applies per posted value, not per feature.** Each top-level
  value you post becomes one overlay with one paint, read from its
  simplestyle properties; a multi-feature `FeatureCollection` is painted
  uniformly from its **first** feature. To draw several colours or styles
  in one image, post an array of Features, each carrying its own
  `stroke`/`fill` properties.
- **The query string carries everything that is not GeoJSON.** `size=`,
  `style=`, `format=`, the `@2x` suffix and the rest of the parameter
  table above all still apply on a POST; only the overlay travels in the
  body. `pois=false` (an alias of `pois=0`) hides POI labels when the
  image is about your overlay rather than the base map.
- **1280 pixels is the ceiling on either dimension.** For more detail in
  the same frame, append `@2x` to `size=` for double pixel density
  instead of asking for a larger image.
- **The first render after idle can be slow, or a `504`.** The renderer
  is headless Chromium behind a serverless route, and a cold container
  has to fetch and launch the browser before it draws anything. Treat a
  `504` (or an unusually slow first response) as a cold start and retry
  once; warm renders are quick, and the `ETag`/`Cache-Control` behaviour
  above makes repeats cheap.

Two differently coloured routes in one card, with the camera auto-fitted
to both:

```bash
curl -X POST "https://mapmap.ai/api/static-map?size=1000x640&pois=false" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "type": "Feature",
      "properties": { "stroke": "#1a6bff", "stroke-width": 5 },
      "geometry": { "type": "LineString", "coordinates":
        [[-0.1276, 51.5072], [-0.1426, 51.5014], [-0.1533, 51.5010]] }
    },
    {
      "type": "Feature",
      "properties": { "stroke": "#ff7a1f", "stroke-width": 5 },
      "geometry": { "type": "LineString", "coordinates":
        [[-0.1276, 51.5072], [-0.1195, 51.5033], [-0.1120, 51.5045]] }
    }
  ]' -o compare.png
```

### render_map (agent/MCP surface)

The intended agent entry point is an MCP tool, `render_map`, wrapping this
endpoint for exactly the constraints real MCP clients have today: a
top-level `image` content block with **raw base64** (never a `data:` prefix
or a `resource.blob`, which several clients either mishandle or ignore),
paired with a short text summary (some clients, ChatGPT connectors among
them, silently drop image blocks and only ever see the text), a `mimeType`
sniffed from the actual bytes, and a small default size (long edge around
1568px, targeting roughly 70kB) so one call doesn't blow a client's image
token budget. The full tool schema is documented in this feature's PR
description; **as of this writing it is not yet live**; it ships from the
box-side MCP server (`crates/sn-mcp`) in a separate PR flagged "needs box
rebuild", so call the HTTP endpoint above directly until then.

## Errors, RFC 9457 problem+json

Errors are `application/problem+json`, with two exceptions: the compatible
URL endpoint uses OSRM's own error envelope (clients dispatch on
`{"code": …}`), and `402` bodies are plain JSON: either the x402 wire
format (HTTP-native machine payments, see [machine payments](/docs/x402))
or, on territory layer downloads, a download-allowance upgrade pointer. The
`type` is a stable URN, `urn:sn-gateway:problem:<slug>`; match on it, not on
human wording. There is no `instance` field.

Example problem body (a key over its monthly quota):

```json
{
  "type": "urn:sn-gateway:problem:quota-exceeded",
  "title": "Monthly quota exceeded",
  "status": 429,
  "detail": "monthly quota exceeded (50000/50000)",
  "quota": 50000,
  "used": 50000
}
```

| Status | Problem slug(s) | When |
|---|---|---|
| `400` | `bad-request` · `invalid-email` · `tos-not-accepted` · `costing-conflict` | Malformed request, invalid/disposable email, ToS not accepted, `costing_options` conflicting with the `adr` profile |
| `400` | `upstream-error` | The routing engine rejected the request (upstream 4xx, relayed; `upstream_status` in the body) |
| `401` | `unauthorized` · `admin-unauthorized` | Missing/invalid API key or admin token |
| `402` | n/a (plain JSON, not problem+json) | Payment required: x402 body on machine-payment endpoints; `{"code": "download_allowance_exceeded", "upgrade": …}` on territory layer downloads |
| `403` | `forbidden` | Authenticated but not permitted, e.g. a provisional (unverified) key attempting a territory layer download |
| `404` | `not-found` | Unknown key/identity/path |
| `409` | `key-cap` · `no-identity-ledger` | Identity key limit reached; crediting a key with no identity ledger |
| `413` | `places-too-many` | A `PUT /places` body over the 10,000-place cap |
| `422` | `invalid-theme` · `invalid-places` · `optimisation-too-large` | Style theme or places dataset failed validation (machine-readable `problems` list in the body); optimisation problem over the cap of 200 unique locations (`max_locations`/`locations` in the body) |
| `429` | `quota-exceeded` · `rate-limited` · `issuance-velocity` · `feedback-velocity` · `map-issue-velocity` | Monthly quota, per-minute rate limit (`Retry-After` header), per-IP signup velocity, or a key past its daily cap on integration retros / map-issue reports |
| `501` | `geocoding-not-enabled` | `/geocode` or `/geocode/reverse` on a deployment with neither `SN_GEOCODE_DIR` nor `SN_PHOTON_URL` set (a self-host concern; the hosted gateway serves both) |
| `502` | `upstream-error` | The routing engine (or geocoder backend) failed (upstream 5xx) |
| `503` | `upstream-unavailable` · `optimisation-not-enabled` | Routing backend unreachable (retry with backoff); `/optimise` on a deployment without a VROOM sidecar |
| `504` | `upstream-timeout` | Routing backend timed out, retry with backoff |
| `500` | `internal` | Unexpected failure; detail is logged server-side, never leaked |

Gateway-origin `400`s and relayed upstream `400`s carry different slugs, so
callers can tell them apart. The distinction between `402` (buy or upgrade)
and `429` (back off) is spelled out on the
[conventions](/docs/conventions) page.

## map_as_text (agent-native map schematics)

`POST /api/map-as-text`: a website-hosted endpoint (not the gateway, no API
key) that renders the street network around a point as a compact,
token-budgeted ASCII schematic, built for an LLM to reason over instead of
raw GeoJSON:

```sh
curl -fsS -X POST "https://mapmap.ai/api/map-as-text" \
  -H "Content-Type: application/json" \
  -d '{"lon": -0.4546, "lat": 51.7526, "radius_m": 350, "token_budget": 2000}'
```

Returns `{schematic, legend, truncated, token_estimate, bbox}`: `schematic`
is a plain-ASCII grid (junctions `+`, road ends `.`, the query point `@`,
roads as `- | / \` by compass direction, an unconnected crossing as `X`, a
lettered/numbered `legend` of every road and POI shown, and honest
degrade-to-budget behaviour (`truncated: true` plus exactly what was
dropped) rather than ever overflowing `token_budget`. Full reference:
[`docs/API.md`](https://github.com/Mapmapai/mapmap/blob/main/docs/API.md#post-apimap-as-text--map_as_text-v1)
in the repo.

## Quotas

Self-serve tiers (verified in the gateway source; paid plans are on the
[pricing](/pricing) page):

| Tier | Requests | Rate limit | Notes |
|---|---|---|---|
| Provisional (unverified) | 1,000 calls total | 60/min | Expires after 72 h unless you verify; no territory downloads |
| Verified free | 50,000/calendar month | 60/min | Up to 5 active keys per email; 8,192 MiB/month offline download allowance; 1,000 included monthly active users |

Signup itself is limited to 3 key issuances per IP per UTC day. Self-hosted
operators set their own numbers via the admin API.

On exceeding a rate limit or quota you get `429 Too Many Requests` with
`Retry-After`; honour it and back off exponentially. Usage is metered per
key and visible at `GET /v1/keys/self` (authenticated, quota-free):

```json
{
  "key_id": "1f3c9a2e-…",
  "state": "verified",
  "monthly_quota": 50000,
  "used_this_month": 1240,
  "remaining": 48760,
  "credits_pence": 0,
  "credits_millipence": 0,
  "expires_at": null,
  "first_success_at": "2026-07-19T12:04:31Z",
  "identity_keys": 1,
  "download_allowance_mib": 8192,
  "download_used_mib": 0,
  "mau_this_month": 0,
  "mau_included": 1000,
  "mau_overdrawn": false
}
```

`first_success_at` is the RFC 3339 timestamp of this key's **first successful
(2xx) metered call** (its activation moment) or `null` if it has never had
one. It is written once and never moves, so it is safe to treat as the key's
birthday; because a buffered task stamps it, it can lag the actual first
success by a moment.

## Next steps

- [Conventions](/docs/conventions): base URL, auth, units, error envelope, ADR tunnel codes
- [Analysis endpoints](/docs/analysis): isochrones, matrices, map matching
- [Quickstart](/docs/quickstart): from zero to a routed truck in five minutes
- [Playground](/playground): run these requests from your browser, no install
