# API conventions

The cross-cutting rules that every endpoint follows: base URL, authentication,
coordinate order, units, quotas, and the error envelope. Other pages link here
rather than repeating any of it. The machine-readable contract is always
`GET /openapi.json` on your deployment; where prose and OpenAPI disagree,
OpenAPI wins.

## Base URL and availability

The hosted gateway's base URL is `https://api.mapmap.ai`.

**Status, honestly:** the hosted gateway is live: [sign up](/signup) for a
key and every endpoint on these pages works today. Everything also runs
identically against a [self-hosted deployment](/docs/self-host) of the same
gateway: same endpoints, same error envelope, same keys. Examples throughout
the docs use an exported base so they work against either:

```sh
export BASE=https://api.mapmap.ai   # or your own deployment's origin
export API_KEY=snk_...
```

## Authentication

Metered endpoints take an API key (prefix `snk_`) either way:

```
Authorization: Bearer snk_...      # preferred
?api_key=snk_...                   # query parameter, for clients that
                                   # cannot set headers (e.g. tile URLs)
```

The `Authorization` header wins when both are present. A missing, malformed,
unknown, revoked or expired key is a `401` problem
(`urn:sn-gateway:problem:unauthorized`) whose `detail` says which.

Unauthenticated endpoints (no key needed):

- `GET /`, `GET /health`, `GET /openapi.json`, `GET /llms.txt`, `GET /terms`
- `POST /v1/keys` (self-serve signup) and `GET /v1/keys/verify` (magic link)
- `GET /styles`, `GET /styles/{spec}`, `GET /styles/{spec}/theme`: style
  *reads* are public so browser map clients can reference them by bare URL
  (publishing styles is authenticated and metered)
- `GET /fonts/{fontstack}/{range}` and `GET /sprite/{file}`: glyphs and
  sprites, fetched by bare URL from compiled styles

`GET /v1/keys/self` is key-authenticated but free: it consumes no quota, no
credit and no rate limit, so agents can poll their own key state.

`/admin/*` endpoints (self-host only) use a separate static operator token as
a bearer credential, never an `snk_` key.

One optional header: SDKs may send `X-MapMap-User` to meter monthly active
users on identity-owned keys. It is fail-open: MAU billing can never block
or 402 a request.

### Keyless demo access (self-host)

A self-hosted deployment can open a small, unauthenticated demo lane with
`SN_DEMO_RPM` (requests per minute). It is off by default (`0`), and is
**not enabled on the hosted gateway**; there, every metered endpoint needs
a key as above.

When the operator sets `SN_DEMO_RPM` above `0`, requests with **no API key**
to a fixed demo-safe whitelist are admitted instead of `401`:

- `GET /route/v1/*` (OSRM-compatible routing, all profiles)
- `GET /geocode` and `GET /geocode/reverse`

Every other endpoint (and any non-`GET` method, including `POST /route`)
still returns `401` without a key. Demo traffic is rate-limited by a token
bucket **per client IP** (the first `X-Forwarded-For` hop, since Caddy fronts
the gateway, else the peer address) at `SN_DEMO_RPM` requests/minute; over the
bucket the response is `429 rate-limited` with a `Retry-After` header. Demo
requests are never metered, billed, or drawn against any quota or prepaid
ledger; they exist purely to let a self-hoster expose a public try-it lane.
Keyed requests are unaffected and are always metered to their own identity.

## Coordinates and units

**Bare coordinate pairs are always `lon,lat`**: in the compatible URL path
(`/route/v1/{profile}/{lon},{lat};{lon},{lat}`) and in GeoJSON output
(isochrone contours). JSON request bodies use named fields instead:
`{"lat": 51.5, "lon": -0.1}`.

| Where | Unit |
|---|---|
| `GET /route/v1/...` responses | `distance` metres, `duration` seconds |
| `POST /matrix` responses | `durations` seconds, `distances` metres (`null` = unreachable) |
| `POST /route` responses | `time` seconds; `length` in the request's `units` (kilometres unless you ask for `miles`) |
| `POST /isochrone` contours | `time` in minutes or `distance` in kilometres (request) |
| Vehicle dimensions | metres (`height_m`, `width_m`, `length_m`) |
| Vehicle weights | metric tonnes (`gross_weight_t`, `axle_load_t`) |
| Vehicle speed | km/h |
| Offline download allowance | MiB per calendar month |

Route geometry is encoded polyline: precision 6 on the native `POST /route`
response's `shape`, precision 5 on the compatible `GET /route/v1/...`
endpoint (matching each format's convention).

## Quotas and rate limits

Quotas are per identity (your email), shared across all of that identity's
keys; issuing more keys does not multiply the quota.

| Limit | Value |
|---|---|
| Provisional key (before email verification) | 1,000 calls total, expires after 72 h |
| Verified free tier | 50,000 included calls per calendar month (a premium truck/ADR call draws 20; commercial use allowed) |
| Rate limit (self-served keys) | 60 requests per minute |
| Active keys per identity | 5 |
| Key issuances per IP | 3 per day |

Beyond the free tier, calls draw prepaid credit at a per-call price with two
classes: **standard** (car/van/bus/bike/pedestrian routing, matrix,
isochrone, map matching; from 0.05p/call) and **premium** (anything
truck/ADR: `"costing": "truck"`, a top-level `adr` object, `"hazmat": true`,
`/adr/check`, or the `truck` profile / truck query parameters on the
compatible endpoint; from 1p/call). Included volume (the free tier and
plans) is denominated in standard calls: one premium call draws 20 included
calls, matching the price ratio; pay-as-you-go money is always priced by
each class's own schedule. Requests bill by size where size varies: a
matrix bills one call per started block of 25 elements (`sources ×
targets`, max 10,000 per request), an isochrone one call per contour (max
10), an optimisation a flat 10 calls, an along-route search like a matrix
on its detour budget (`4 × max_detours` elements, max 25 detours; plus one
call when it computes the route). Prices step down with monthly volume;
see [/pricing](/pricing).

Money rule: a request answered with a client error (4xx) is never charged;
the debit is refunded. It still counts against the monthly quota.

Hitting a limit gives you one of two `429`s, or a `402`; see the decision
table below.

## Errors

Every JSON endpoint answers errors as RFC 9457 `application/problem+json`,
with one exception: the compatible `GET /route/v1/...` endpoint answers its
own routing errors (`InvalidQuery`, `InvalidValue`, `NoRoute`, `NoSegment`,
always `400`) in the OSRM `{"code": ...}` error envelope, because
compatible clients dispatch on `code`. Auth, quota and payment errors keep
the shared formats even on the compatible path: `401` and `429` are
`problem+json`, and beyond-tier `402`s use the x402 body.

A real body: the verified free tier exhausted by an identity that never
bought credit:

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

`type` is a stable URN you can dispatch on; `detail` is for humans. Some
problems carry extra machine-readable fields, noted below.

| `type` (`urn:sn-gateway:problem:` +) | Status | Meaning | Fix |
|---|---|---|---|
| `unauthorized` | 401 | Missing, malformed, unknown, revoked or expired key | Send `Bearer snk_...`; reissue at `POST /v1/keys` |
| `admin-unauthorized` | 401 | Wrong admin token (self-host) | Check the operator token |
| `forbidden` | 403 | Key valid but not permitted, e.g. a provisional key attempting a bulk territory-layer download | Verify your email |
| `bad-request` | 400 | Invalid body or parameters | `detail` says what |
| `costing-conflict` | 400 | Your own `costing_options` contradict the `adr` profile; `conflicting_fields` lists them | Remove the conflicting fields or make them agree |
| `tos-not-accepted` | 400 | Signup without `"accept_tos": true`; `tos` echoes the terms URL | Fetch the terms, re-submit with `accept_tos: true` |
| `invalid-email` | 400 | Rejected email (syntax or disposable domain) | Use a real address |
| `not-found` | 404 | Unknown resource | Check the id/path |
| `key-cap` | 409 | Identity already has the maximum active keys (`max_active_keys`) | Revoke one or reuse an existing key |
| `no-identity-ledger` | 409 | Admin credit aimed at an operator-issued key | Credit the identity instead |
| `invalid-theme` | 422 | Style theme failed validation; `problems` lists each offending key | Correct the named slot/layer and resubmit |
| `optimisation-too-large` | 422 | Problem exceeds the fair-use location cap (`max_locations`, `locations`) | Split the problem |
| `quota-exceeded` | 429 | Monthly quota exhausted with no payment path (`quota`, `used`) | Verify email, top up credit, or wait for the month |
| `rate-limited` | 429 | Per-minute limit fired; `retry_after` seconds, also sent as a `Retry-After` header | Back off and retry |
| `issuance-velocity` | 429 | Too many key issuances from your IP today (`limit_per_day`) | Come back tomorrow |
| `internal` | 500 | Unexpected failure; detail is logged server-side, never leaked | Retry; report if persistent |
| `upstream-error` | 400/502 | Routing backend (or Photon geocoder) rejected the request (400 for an upstream 4xx, 502 otherwise; `upstream_status`) | Check the request; else retry |
| `geocoding-not-enabled` | 501 | `/geocode` on a deployment with neither `SN_GEOCODE_DIR` nor `SN_PHOTON_URL` (self-host) | Operator sets one of them |
| `optimisation-not-enabled` | 503 | Deployment has no VROOM sidecar configured (self-host) | Operator sets `SN_VROOM_URL` |
| `upstream-unavailable` | 503 | Routing backend unreachable | Retry |
| `upstream-timeout` | 504 | Routing backend timed out | Retry |

### The two 402s (not problem+json)

Two payment errors deliberately use plain `application/json` bodies instead
of the problem envelope, because machine clients parse them directly:

1. **Payment required**: the x402 wire format (`x402Version`, `accepts`,
   `error`). Returned when a request beyond the free tier cannot be paid:
   a provisional key past its 1,000 calls, a credited identity whose balance
   is below the per-call price, or a failed `X-PAYMENT` verification. Full
   shape and payment flows: [machine payments](/docs/x402).

2. **Download allowance exceeded**: an offline territory-layer download
   would exceed the identity's monthly MiB allowance:

   ```json
   {
     "code": "download_allowance_exceeded",
     "allowance_mib": 8192,
     "used_mib": 2100,
     "message": "offline map download allowance exhausted; upgrade to a plan or the SDK licence for production downloads",
     "upgrade": "https://api.mapmap.ai/pricing"
   }
   ```

### 402 or 429?

| You get | When |
|---|---|
| `429 quota-exceeded` | A free-tier identity that has never bought credit exceeds its monthly quota; or an operator-issued key exceeds its quota |
| `429 rate-limited` | Any key exceeds its per-minute rate limit (`Retry-After` header set) |
| `402` (x402 body) | A provisional key past its call allowance; a credited identity with insufficient balance; a rejected `X-PAYMENT` header |
| `402` (download body) | An offline layer download would exceed the monthly MiB allowance |

Rule of thumb: `429` means *stop or wait*; `402` means *there is a way to
pay*: verify your email, top up, or attach an x402 payment.

## ADR and tunnel codes

ADR (the European agreement on carriage of dangerous goods by road; tunnel
codes B–E restrict which tunnels a hazmat load may use). This section is the
canonical reference the other pages link to.

Routing requests declare the load with a top-level `adr` object
(`"costing": "truck"` required):

```json
{
  "adr": {
    "dimensions": {
      "height_m": 4.0,
      "width_m": 2.55,
      "length_m": 16.5,
      "gross_weight_t": 40.0,
      "axle_load_t": 11.5,
      "axle_count": 5
    },
    "tunnel_code": "D",
    "hazmat": true
  }
}
```

`axle_load_t` and `axle_count` are optional. On the compatible
`GET /route/v1/...` endpoint the same facts travel as query parameters:
`height`, `width`, `length`, `weight`, `hazmat`, `tunnel_code`.

Semantics, worst-case by design:

- `"hazmat": false`: the tunnel matrix does not apply; every tunnel is
  permitted (dimensional limits still apply).
- `"hazmat": true` with no `tunnel_code`: treated as the most restrictive
  non-quantity code (`B`), since the load's code is unknown.
- `"tunnel_code": "(—)"`: the ADR "no restriction" entry, explicitly
  allowed through all tunnels.
- Quantity- and tank-conditional codes (`B1000C`, `B/D`, ...) are read
  worst-case: the API cannot know your net explosive mass or whether the
  goods travel in tanks, so it assumes the restrictive reading.

If your request's own `costing_options.truck` values disagree with what the
`adr` profile implies, the request is rejected with a `400 costing-conflict`
listing the fields; set them in one place or make them agree.

**Tunnel categories** (posted on the tunnel, A least to E most restrictive):

| Category | Restricts |
|---|---|
| A | No restrictions for dangerous goods |
| B | Goods which may lead to a very large explosion |
| C | ... or a large explosion, or a large toxic release |
| D | ... or a large fire |
| E | All dangerous goods except UN 2919, 3291, 3331, 3359 and 3373 |

**Tunnel restriction codes** (assigned to the load, ADR 8.6.4):

| Code | Passage forbidden through |
|---|---|
| `B` | Categories B, C, D and E |
| `B1000C` | Category B above 1,000 kg total net explosive mass per transport unit; always C, D and E |
| `B/D` | Categories B and C when carried in tanks; always D and E |
| `B/E` | Categories B, C and D when carried in tanks; always E |
| `C` | Categories C, D and E |
| `C5000D` | Category C above 5,000 kg total net explosive mass per transport unit; always D and E |
| `C/D` | Category C when carried in tanks; always D and E |
| `C/E` | Categories C and D when carried in tanks; always E |
| `D` | Categories D and E |
| `D/E` | Category D when carried in bulk or in tanks; always E |
| `E` | Category E |
| `(—)` | Nothing; allowed through all tunnels |

To check a load without routing, `POST /adr/check` takes the same `adr`
profile plus a `tunnel_category` (`"A"`–`"E"`) and returns the entry
decision for that tunnel; see the [API reference](/docs/api-reference).

## Next steps

- [Quickstart](/docs/quickstart): key to dimensioned truck route in two calls
- [API reference](/docs/api-reference): every endpoint, truck and ADR parameters
- [Analysis endpoints](/docs/analysis): isochrone, matrix, map matching
- [Machine payments](/docs/x402): the 402 flows in full, prepaid credit and x402
