Hosted vector tiles and styles, scoped to territories

# Hosted vector tiles and styles, scoped to territories

The MapMap gateway serves Mapbox Vector Tiles at `GET /tiles/{territory}/{z}/{x}/{y}.mvt`, a TileJSON 3.0 document at `/tiles/{territory}/tiles.json` and a ready-to-render MapLibre style at `/tiles/{territory}/style.json`. All three are metered per request at the Standard price class, 0.05p per call. The tiles are cut from the same per-territory archives the routing engine and the signed offline packages are built from, so the map you render and the route you get back agree about the world.

## What do the tile endpoints return?

Three endpoints, one territory archive behind them.

| Method | Path | What it returns |
|---|---|---|
| GET | `/tiles/{territory}/{z}/{x}/{y}.mvt` | One Mapbox Vector Tile (`.pbf` also accepted), gzip-encoded, strong ETag, immutable caching. An in-range tile with no data is `204` |
| GET | `/tiles/{territory}/tiles.json` | TileJSON 3.0: zoom range, bounds and centre read from the territory archive, plus OSM attribution |
| GET | `/tiles/{territory}/style.json` | A MapLibre GL style over the territory's vector source |

Territory slugs come from `GET /territories`. Start with `tiles.json`, because it tells you the zoom range and bounds before you render anything:

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

curl -fsS "$BASE/tiles/uk/tiles.json" -H "Authorization: Bearer $API_KEY"
```

```json
{
"tilejson": "3.0.0",
"name": "uk",
"scheme": "xyz",
"tiles": ["https://api.mapmap.ai/tiles/uk/{z}/{x}/{y}.mvt?v=1720981132"],
"minzoom": 0,
"maxzoom": 14,
"bounds": [-8.65, 49.86, 1.77, 60.86],
"center": [-2.0, 54.0, 6.0],
"attribution": "© OpenStreetMap contributors"
}
```

Keep the `?v=` discriminator in any URL you pass through. Tile bytes are served `immutable` for 24 hours but the URLs are not content-addressed, so a republished archive would otherwise race stale caches. The discriminator busts every cache atomically when the operator swaps the archive.

Individual tiles carry a strong BLAKE3 ETag (`"b3-…"`), so a conditional GET with `If-None-Match` short-circuits to `304`. A tile that is in range but holds no data returns `204 No Content`, which is the empty-tile convention MapLibre renders as blank. Unknown territory, out-of-range coordinates, a bad extension or a deployment with no tiles staged all return `404`.

## Why are tiles scoped to a territory rather than a global mesh?

Because a territory is the unit we build, sign and ship, and pretending otherwise would mean two sources of truth. Our map factory builds one archive per territory from OSM extracts, and that archive feeds three consumers: the routing engine, the signed offline package a device downloads, and these tile routes.

The gateway reads tiles from a plain, seekable PMTiles v3 archive staged at `SN_TILES_DIR/<territory>.pmtiles`. That is deliberately not the update-channel tree that [territory packages](/docs/territories) use. The channel wraps each PMTiles inside a `.pmtiles.tar.zst` layer blob so devices download and verify one atomic signed artefact, and a compressed tarball is not randomly seekable. Per-tile serving needs random access, so the operator stages the same Planetiler output uncompressed alongside it. Same bytes, two access patterns, one build.

The practical consequence for you is that a territory scope is honest about coverage. If `GET /territories` does not list it, there are no tiles for it, and you find that out at `404` rather than by rendering a map with a hole in it. Territory base maps do include ocean and land at all zooms: the factory stages the global sources (OSM ocean water polygons, Natural Earth, lake centrelines) into every territory build, so a coastline does not stop at the extract boundary.

## How do you render a map from this?

Point MapLibre GL at the style URL and you are done. The container `div` must have an explicit height, or the map renders zero pixels tall and the page looks silently blank:

```html
<!-- In production, pin an exact maplibre-gl version and add SRI
(integrity="sha384-…" crossorigin="anonymous"), or bundle it via npm. -->






const map = new maplibregl.Map({
container: "map",
style: "https://api.mapmap.ai/tiles/uk/style.json?api_key=snk_…",
center: [-1.5, 52.6], // lon, lat
zoom: 6,
});

```

Metered endpoints take the key as `Authorization: Bearer` (preferred) or as an `?api_key=` query parameter. The query form exists for URL-only contexts exactly like a style URL handed to MapLibre, which has nowhere to put a header. For a typed wrapper with routing helpers, use `@mapmap/maps`, covered in [the web maps SDK post](/news/web-maps-sdk).

## Where do glyphs and sprites come from?

From the gateway, public and unmetered, with immutable caching. Labels and icons are not billed.

| Path | What it serves |
|---|---|
| `GET /fonts/{fontstack}/{range}.pbf` | SDF glyph ranges for the bundled fontstacks |
| `GET /sprite/sprite[@2x].json` and `.png` | The first-party sprite sheet: marker, dot, arrow, lane diagrams, road shields, tunnel-restricted |

The bundled fontstacks are `Noto Sans Regular` (plus Bold and Italic), `Barlow Regular`, `Fira Sans Regular`, `IBM Plex Sans Regular`, `Inter Regular`, `Lato Regular`, `Montserrat Regular`, `Noto Serif Regular`, `Nunito Regular`, `Open Sans Regular`, `Rubik Regular`, `Source Sans 3 Regular` and `Work Sans Regular`, all SIL Open Font Licence, static Regular instances. Keep a theme's `fonts.regular` on one of these or the labels silently drop. If you write your own style by hand instead of compiling a theme, point its `glyphs` and `sprite` fields at these same routes.

One gotcha worth stating plainly: compiled styles default their glyph URL to `https://fonts.mapmap.ai/{fontstack}/{range}.pbf`. That is live and serving, and it is also a call to our infrastructure. On a self-hosted or air-gapped deployment, set the theme's `glyphs` field to your own gateway's route, `$BASE/fonts/{fontstack}/{range}.pbf`, or your labels will quietly depend on us. Those routes serve when `SN_MAP_ASSETS_DIR` is set; tiles need `SN_TILES_DIR` and hosted styles need `SN_STYLES_DIR`.

## Which hosted style calls are metered?

Reads are free, writes are metered. Styles are versioned and immutable: publishing writes a new version, and a versioned style URL can be cached forever.

| Method | Path | Auth | Metered |
|---|---|---|---|
| GET | `/styles` | none | no |
| GET | `/styles/{id}.json` | none | no |
| GET | `/styles/{id}@{version}.json` | none | no |
| GET | `/styles/{id}/theme` | none | no |
| POST | `/styles` | key | yes, Standard class |
| POST | `/styles/{id}` | key | yes, Standard class |

Reads being public and unmetered is the point: a compiled style can be referenced by bare URL from a browser map client without a key in the page. Creating a style is one call:

```sh
curl -fsS -X POST "$BASE/styles" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Midnight Fleet",
"theme": {
"name": "midnight-fleet",
"base": "dark",
"palette": { "water": "#0b2038" }
}
}'
```

```json
{
"id": "midnight-fleet-9f3a2c",
"version": 1,
"style_url": "https://api.mapmap.ai/styles/midnight-fleet-9f3a2c@1.json",
"theme": { "name": "midnight-fleet", "base": "dark", "palette": { "water": "#0b2038" } }
}
```

The id is the name's slug plus six hex characters, so same-named styles never collide. `POST /styles/{id}` publishes the next version, and its body is a wrapper with the full theme document under `"theme"`, not a bare theme and not a diff. POSTing a bare theme is a `400`. An invalid theme is a `422` whose `problems` list names the offending slot or layer and the accepted values, so an agent can self-correct without a human reading the docs. The theme document itself, its 19 palette slots and the browser editor over them are covered in [the Map Studio post](/news/map-studio).

## What this costs

Tiles, `tiles.json`, `style.json` and the two style publishes all bill 0.05p a call at the Standard class. Glyphs, sprites and every `/styles` read are free.

| Item | Price |
|---|---|
| Each tile, `tiles.json` or `style.json` request | 0.05p (Standard class) |
| `POST /styles` and `POST /styles/{id}` | 0.05p (Standard class) |
| Glyphs, sprites, all `/styles` reads | free, unmetered |
| Standard class beyond 1M calls/month | 0.03p |
| Standard class beyond 10M calls/month | 0.02p |
| Free tier | 50,000 calls/month after email verification, commercial use allowed |

Tile requests are not charged against the offline download byte allowance. That gate applies only to bulk territory-package downloads.

## What this does not do

Four real limits, stated before you find them.

- **Every tile is a call.** Metering is per request, so a map browsing session bills for each tile it pulls, not once per page view. The 24-hour immutable caching and the `304` path on `If-None-Match` blunt this a lot on repeat views, but the first load of a busy pan bills honestly. Budget in tiles, not in sessions.
- **No server-side raster rendering.** There is no `/tiles/{territory}/{z}/{x}/{y}.png`. It needs a headless GL stack (styling, glyph and sprite atlases, GPU or Mesa software rendering) that dwarfs the gateway, and it would be a separate service. Clients render the vector tiles themselves.
- **These routes are enabled per deployment.** They answer `404` until the operator stages the tile and asset directories. The hosted gateway has them enabled, with worldwide coverage from the planet build; GET /territories lists exactly what a deployment publishes.
- **Compiled styles default their glyphs to our domain.** Self-hosting is not complete until you override the `glyphs` field, as above.

## Attribution is structural, not a checkbox

Every compiled style carries `© OpenStreetMap contributors © OpenMapTiles` on its tile source, and validation rejects any theme that drops either. There is no theme field that can remove them. The OSM credit covers the data under ODbL, the OpenMapTiles credit covers the tile schema under CC-BY 4.0. Anything you render or republish must credit `© OpenStreetMap contributors` with a link to openstreetmap.org/copyright. The compiled styles carry this for you, which is the intended outcome: nobody has to remember.

## Try it

Get a key, then fetch `tiles.json` for `uk` and paste the style URL into MapLibre. The full reference, including the theme document, the palette slots, the styleable layer ids and the 3D buildings caveat on mobile, lives in [the maps documentation](/docs/maps). Every call above works identically against a self-hosted gateway.