Maps API
A rendered map needs three things from the gateway: vector tiles, a style document that says how
to draw them, and the fonts and icons the style references. @unmap/sdk and @unmap/maps fetch
all three for you. This page is for calling the gateway directly, or for understanding what your
map is doing under the hood. If tile, style, and zoom are new words, the
one-minute glossary defines them first.
The default vector basemap is the Protomaps planet release 20260922, covering the world at Web
Mercator zooms 0–14. The retained Canada archive is a rollback option. Terrain relief is worldwide from
global release 6; contours remain Canada-scoped.
Tiles, styles, terrain, contours, glyphs, and sprites accept Authorization: Bearer <key>,
X-API-Key: <key>, or ?key=<key>. Generated style URLs use query keys; direct callers can
use either header. See Authentication.
If you want the map rather than the endpoints, none of this is required reading:
Tiles
GET /tiles/{z}/{x}/{y}
One square of map data. z is the zoom level, x and y are the column and row of the square at
that zoom, so /tiles/11/375/685 is one piece of downtown Calgary. The squares carry data rather
than a picture of a map, in the Mapbox Vector Tile format (application/x-protobuf), cut from a
single worldwide Web Mercator archive covering zoom 0 through 14. A .pbf or .mvt suffix on y is
accepted and stripped: clients append them inconsistently, so both work.
curl "https://api.unmap.dev/tiles/11/375/685.mvt?key=$UNMAP_API_KEY" -o tile.mvtconst res = await fetch(`https://api.unmap.dev/tiles/11/375/685.mvt?key=${key}`);
const tile = new Uint8Array(await res.arrayBuffer());Four things worth knowing:
- An empty answer is
204 No Content, not404. You get a 204 past zoom 14, and also inside the zoom range wherever the archive holds no tile, such as empty ocean tiles. Both are legitimate emptiness rather than failure. Past zoom 14 MapLibre keeps drawing by scaling up the deepest tile it has, which is called overzooming, so a404here would be both wrong and noisy. - Malformed coordinates (non-integer
z,x, ory) get a400with a plain-text body,invalid tile coordinates, not JSON. A column outside0 .. 2^z-1is that same400: the column you asked for is the column that is read. Tile, terrain, contour, glyph, and sprite requests all reject malformed input in plain text. A missing basemap release, below, is plain text too. Everything else on the gateway answers in JSON. See Errors. - A tile that has bytes is served
Cache-Control: public, max-age=86400. A204is not written to the Worker cache and does not send that header. - A deploy that names a basemap release which is not an eight-digit build id, or whose object is
not in storage, answers
503with the plain-text bodybasemap release is misconfiguredandCache-Control: private, no-store. That response does not serve the previous archive and is not an empty204. On/tiles/{release}/{z}/{x}/{y}, an eight-digit id that was never copied is404basemap release not found, alsoprivate, no-store. An id that is not eight digits is that same404body without the cache header. Retrying does not restore the previous archive.
Tiles that have bytes are cached at the edge with the query string (and so the key) stripped from the cache key,
so the same byte-identical tile is shared across every customer rather than each of you warming a
private copy. An empty 204 is not stored in that cache.
Styles
GET /styles/{style}.json?mode=&lang=&theme=&overlays=&profile=
Returns a complete MapLibre GL style document (version 8): sources, layers, and
self-referencing tiles, glyphs, and sprite URLs that already point back at this gateway,
with your key embedded in their query strings. Point MapLibre at this one URL and it discovers
everything else on its own. Pass overlays= to compose named Canadian catalog layers on top, or a byod:<id> GeoJSON
overlay you uploaded (see Bring your own data),
or profile=energy / agriculture / remote / mining for an industry composition preset (explicit
overlays= replaces the profile list). Layers is the catalog;
Energy, Agriculture, Remote, and
Mining are the presets.
Here it is, driving a real map. Every control changes one thing in the URL:
import { Unmap } from "@unmap/sdk";
const unmap = new Unmap({
key: "um_live_...",
container: "map",
style: "base",
mode: "light",
lang: "en",
center: [-114.0719, 51.0447],
zoom: 11,
});curl "https://api.unmap.dev/styles/outdoor.json?mode=dark&key=$UNMAP_API_KEY"const res = await fetch(`https://api.unmap.dev/styles/outdoor.json?mode=dark&key=${key}`);
const style = await res.json();{style} is one of the fifteen styles below. A compound <style>-<mode> name works too, so
/styles/outdoor-dark.json and /styles/outdoor.json?mode=dark are the same request. An
unrecognised name is a 404, which lists the names that are real:
{
"error": "unknown style",
"code": "not_found",
"styles": ["base", "muted", "outdoor", "blueprint", "blush", "orchid", "canopy", "lagoon", "tropic", "sunset", "bold", "pastel", "atlas", "swiss", "winter"],
}What comes back
{
"version": 8,
"glyphs": "https://api.unmap.dev/glyphs/2/{fontstack}/{range}.pbf?key=...",
"sprite": "https://api.unmap.dev/sprite/2/light?key=...",
"sources": {
"unmap": {
"type": "vector",
"tiles": ["…/tiles/{z}/{x}/{y}?key=..."],
"maxzoom": 14,
},
"unmap_dem": {
"type": "raster-dem",
"encoding": "terrarium",
"maxzoom": 12,
},
"unmap_contours": { "type": "vector", "minzoom": 9, "maxzoom": 14 },
},
"layers": [
/* the cartography, in draw order */
],
"center": [-114.0719, 51.0447],
"zoom": 11,
}While BASEMAP_RELEASE is unset, unmap has no bounds and the tile template is
/tiles/{z}/{x}/{y}. When that release's object is readable, the template is
/tiles/{release}/{z}/{x}/{y} and bounds are [-180, -85.0511287, 180, 85.0511287].
unmap_landcover, on every style but muted, copies the unmap template and bounds and sets
maxzoom to 7. Terrain bounds follow the active DEM release; contours keep the Canadian window.
unmap is always there. unmap_dem and unmap_contours appear only for outdoor, atlas, swiss and winter, and only once the gateway has an archive configured behind
each of them; a style never advertises a source with nothing behind it. center and zoom are a
starting view of Calgary, so a style document is a whole opening shot, not just cartography. Set
the camera with MapLibre's own center and zoom.
The style document itself is generated per request. The response is Cache-Control: private, no-store: it contains the API key and the tile template for the release this deploy is serving, so a shared cache does not keep it. A response that carries tile, terrain, contour, glyph, or sprite bytes is cached. An empty 204 is not.
The fifteen styles
A style is one of the fifteen structural maps; a mode is light or dark. Every style ships in both,
and the two axes are independent, so a compound <style>-<mode> name and a bare name plus ?mode=
mean the same thing:
base-light(default),base-dark: general purpose. Full road hierarchy, visible buildings, landcover differentiated enough to read as terrain.base-darkrenders water darker than land, which is what stops a dark basemap collapsing into mud.muted-light,muted-dark: greyscale end to end, water and icons included. Built to disappear beneath data you draw on top.outdoor-light,outdoor-dark: terrain-first, with a DEM hillshade, contours, and trails.blueprint-light,blueprint-dark: one blue family. A royal blue field with navy roads in dark, blue lines on blue-white paper in light.blush-light,blush-dark: pink land on a peach ground with rose buildings; wine at night.orchid-light,orchid-dark: lavender land with teal parks, mint woods and sky water.canopy-light,canopy-dark: tan earth under dark forest greens, near-black roads, clay buildings and strong blue water.lagoon-light,lagoon-dark: pale green land, aqua water, teal parks, grey labels.tropic-light,tropic-dark: teal water at full strength, spring-green parks, cream land, a coral highway. After CARTO's Tropic and Temps palettes.sunset-light,sunset-dark: a warm duotone. Cream land, peach vegetation, coral roads, indigo water; dusk in dark. After CARTO's Sunset.bold-light,bold-dark: the saturated street map. Clear blue water, leaf-green parks, emerald woods, an amber highway. After CARTO's Bold and Vivid.pastel-light,pastel-dark: lavender-white land, periwinkle water, mint parks, a salmon highway. After CARTO's Pastel.atlas-light,atlas-dark: warm paper, blue water, red roads, serif place labels, hillshade and contours. Inspired by Bay Atlas.swiss-light,swiss-dark: white ground, pale blue water, yellow roads, red railways and stronger relief. Inspired by Bay Atlas’s Swiss style.winter-light,winter-dark: snow-white land, icy blue water, spruce woods and a cold blue highway, with hillshade and contours. Made for the ski overlay, whose run colours are the content; dark is the same map at night.
A bare style name means that style in light mode, so /styles/muted.json is a valid request.
An unrecognised name is a 404 not_found listing the real ones.
Mode
GET /styles/base.json?mode=dark&lang=en
?mode= picks the light or dark half of whatever the path named, and it wins over a mode the path
carried: /styles/base-light.json?mode=dark is base-dark. Prefer the bare name plus ?mode= if
your app has a theme toggle, because flipping one parameter beats rebuilding a compound name on
every theme change. An unrecognised mode is not a 400: the style is served in light mode,
including when the path named dark.
Terrain and contours
The outdoor, atlas, swiss and winter styles add a raster-dem
source plus a vector contour source on top of it. You do not normally fetch either yourself. The
style document already carries their URLs with your key in them, and MapLibre requests them as it
draws. They are listed here so you can recognise them in a network log:
Styles use global relief from release 6:
GET /terrain/6/{z}/{x}/{y}.webp?key=... # Terrarium/512, z0–12
GET /contours/4/{z}/{x}/{y}?key=... # Canadian contours, z9–13
After a rollback, relief returns to /terrain/4/...png. That archive serves the
Canadian relief when TERRAIN_RELEASE is unset. Versioned URLs identify their
own archive; unknown releases return 404. WebP answers image/webp, PNG image/png and
contours application/x-protobuf. Missing tiles or zooms outside the archive range return 204.
An unavailable active release returns 503 without automatic fallback.
Global relief covers Web Mercator (approximately ±85.0511°), z0–12 with overzooming above that. Datums, native resolutions and terrain/surface models vary. Contours keep their Canadian bounds and MRDEM-30 provenance; they are not a survey product. /coverage.json reports the active source. The same DEM feeds the Elevation API. Keep the attribution of every style source.
3D terrain
Add terrain=on to draw any style on 3D terrain, flat ones included. The style document gains
an unmap_terrain raster-dem source and a top-level terrain that MapLibre applies when it
loads the style; tilt the map to see it. exaggeration scales the relief, from 0.1 to 5
(default 1).
GET /styles/winter.json?terrain=on&exaggeration=1.5&key=...
import { createMap } from "@unmap/maps";
const map = createMap({ key: "um_live_...", container: "map", style: "winter", terrain: true, exaggeration: 1.5 });
map.setPitch(60);3D terrain adds no hillshade to a style that has none, and its source is separate from the
hillshade's unmap_dem, which MapLibre renders better than one source feeding both. A
terrain other than on or off, an exaggeration out of range, or an exaggeration without
terrain=on is a 400. With no terrain configured on the gateway the request is a 503
rather than a flat map. Terrain tiles are metered like any other tile.
Custom themes
?theme= carries a theme code from the theme builder. A theme is data only: colours, a
few bounded numbers, and one style name. It replaces the colours the style would have used, so it wins over the style
the path named.
Codes come in two shapes. A style, a named preset, or custom seed colours pack into a short u…
code of a few characters; a theme whose colours have been hand-edited past that falls back to a
ut1. document, base64url of a deflated JSON body.
GET /styles/base.json?theme=ut1.eJyrVipTsjLUUcpLzE1VslIqLilKTS1R0lFKSixG4tcCAOvhDGQ&key=...
Both forms go in the same parameter, and both reach @unmap/sdk as the theme option:
import { Unmap } from "@unmap/sdk";
const unmap = new Unmap({
key: "um_live_...",
container: "map",
theme: "u4e",
center: [-114.0719, 51.0447],
zoom: 12,
});?mode= still picks which half of the theme you get, and an unrecognised mode falls back to
light. Terrain and sprites follow the theme's underlying style, so a theme built on outdoor
keeps its hillshade.
A theme can also hide or reorder the seven major layer groups (land, water, buildings, roads,
boundaries, 3D buildings, labels) for both modes at once. That is the Map layers panel in the
builder; on the wire it is one layers field of booleans and an ordered list, and hidden groups
are simply absent from the style you receive. 3D buildings is the one group off by default: a
fill-extrusion composed from the tiles' building heights, in the theme's building colour.
A bad code is rejected loudly with a 400, unlike an unrecognised ?mode=:
{
"error": "invalid theme",
"code": "invalid_theme",
"detail": ["code is not valid base64url deflate data"],
}That is deliberate. Themes are new enough to have no hardcoded URLs to keep alive, and a silently
ignored theme is the worst possible answer for someone iterating in the builder. detail names
the offending field paths where the code decoded but did not validate.
A themed style also carries the resolved marker and route styling in its metadata, so a client
that already loaded the style can draw overlays in the theme's colours without decoding the code
itself:
"metadata": {
"unmap:theme": {
"marker": { "color": "#FF3E9A", "scale": 1 },
"route": { "color": "#FF3E9A", "width": 3, "opacity": 1,
"casingColor": "#FF3E9A", "casingWidth": 10, "casingOpacity": 0.2 }
}
}metadata is absent when you asked for a style rather than a theme.
Label language
The ?lang= query parameter sets the label language:
GET /styles/base.json?mode=light&lang=fr&key=...
With no lang, the gateway reads Accept-Language, so an embedded map lands in the reader's
language without the host page asking. A full tag resolves by its primary subtag, so fr-CA
gives you French. An unsupported code falls back to English rather than silently emitting a tag
the tiles do not carry. The Protomaps planet and global glyph stacks support the 41 language tags
in the upstream Protomaps language list. Japanese (ja), Korean (ko), Simplified Chinese (zh-Hans),
and Traditional Chinese (zh-Hant) select native labels. Chinese region tags such as zh-CN
and zh-TW resolve to Simplified and Traditional respectively; an explicit script wins.
English and French are both present in the Montréal example; 31 features differ in one tile, Nuns' Island and L'Île-des-Sœurs among them. Global feature labels depend on names present in OpenStreetMap; a supported language does not mean every feature has a translation.
Indigenous names are not a lang code, and you should not ask for one. There is no name:iu
in the tiles. Those names travel on the feature itself, so Iqaluit arrives as Iqaluit with
ᐃᖃᓗᐃᑦ alongside it and the label renders both lines whichever language you requested.
Elsewhere the syllabic name is primary and the English one secondary. Nothing needs enabling.
Fonts and sprites
Glyphs are the letter shapes MapLibre draws labels with, and a sprite sheet is the single image
holding every icon a style uses. The style's glyphs and sprite URLs point at the gateway too,
not at a third-party asset host.
You will not normally call these yourself (MapLibre requests them automatically once you set the
style URL). Generated URLs carry the key as ?key=. Direct requests also accept
Authorization: Bearer <key> or X-API-Key: <key>, just like tiles.
GET /glyphs/{version}/{fontstack}/{range}.pbf?key=...
GET /sprite/{version}/{file}?key=... # file is <sheet>[@2x].(json|png)
The style gives MapLibre the sprite URL without an extension (/sprite/2/light) and MapLibre
appends .json, .png, and @2x itself. Four sheets exist: light, dark, and the desaturated
muted-light and muted-dark. POI icons are raster sprites coloured per sheet, so muted, which
is greyscale down to its icons, needs sheets of its own; every other style borrows the sheet
matching its mode, because icons otherwise vary by mode and not by style.
Fonts are the Noto Sans stacks, and each one merges many source faces rather than covering a
single script: Noto Sans Regular already contains Unified Canadian Aboriginal Syllabics, so
ᐃᖃᓗᐃᑦ renders as text on the map rather than as missing-glyph boxes. There is no separate
Noto Sans Canadian Aboriginal family to ask for; requesting one gets you a 404, because
nothing is stored under that name.
Arabic and Hebrew glyphs are in that same face. MapLibre 6.9 and later join and order them
without a right-to-left text plugin, so a stock MapLibre map draws them from the style URL
alone. Water, ocean, island, and lake labels are italic for Latin
names. For lang=ar, lang=fa, lang=ur, and lang=he those layers use Regular, because
Italic has no Arabic or Hebrew bitmaps and Regular does.
The global fontstacks include hiragana, katakana, Hangul, and CJK unified ideographs.
Local-script second lines and route shields retain those characters. Regional JP, KR, SC,
and TC variants use the corresponding Noto Sans CJK glyphs. Added CJK characters use Regular
weight, including in Medium and Italic stacks; existing glyphs keep their original weight.
lang=hi, lang=ne, and lang=mr retain the Devanagari face and the upstream preformatted
labels where available. Complex-script shaping remains limited by the renderer; support for
a language code does not guarantee a translation for every feature.
The /glyphs/ and /sprite/ paths carry an asset version segment. It addresses nothing (the
underlying object is the same) and exists only so a rewritten font gets a new URL, because a
successful response is served immutable for a year and the cache is keyed on path. The version is
currently 2. Both are served only under it: /glyphs/{version}/{fontstack}/{range}.pbf and
/sprite/{version}/{file}.
A missing glyph range or sprite sheet is plain text 404 not found, not a 500, and that
response does not send the year-long cache header. MapLibre degrades past a coverage gap on its
own, but a 5xx here could take the whole map down. A malformed fontstack,
range, sprite filename, or version segment gets a plain-text 400.
Glyphs and sprites are authenticated and rate-limited like everything else, but they are not metered. One map load pulls many glyph ranges, and counting them would swamp your real usage and penalise label-dense maps.
Next steps
- Quickstart renders a map from these endpoints in two statements.
- Compose your own cartography at /create and pass the code as
?theme=. - Geocoding API and Routing API for what goes on top.