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.
Every endpoint here needs a key. Tiles, glyphs, and sprites accept it only as ?key=, because
MapLibre cannot set headers on those requests. 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 country-wide 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());Three 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, which is most of the ocean and everything outside Canada. 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. Tile, terrain, contour, glyph, and sprite requests all reject malformed input in plain text; everything else on the gateway answers in JSON. See Errors. - A tile is served
Cache-Control: public, max-age=86400.
Tiles 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.
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 twelve 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",
"styles": ["base", "muted", "outdoor", "blueprint", "blush", "orchid", "canopy", "lagoon", "tropic", "sunset", "bold", "pastel"],
}The demo-city form
Before styles were addressed by name, this endpoint took one of the three launch cities and a
?flavor= parameter. That shape still resolves, and still starts the map on that city's viewport,
so a URL minted then keeps working:
GET /styles/{city}.json?flavor=&mode=&lang=&theme=
{city} is calgary, montreal, or iqaluit. ?flavor= is read only on this form. On
/styles/{style}.json the path already names the style, and a flavor parameter there is
ignored. Prefer the style form in anything new, and set the camera with MapLibre's own center
and zoom; a city is a viewport, not a different map.
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,
}unmap is always there. unmap_dem and unmap_contours appear only for outdoor (which is what
the legacy relief name resolves to), 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 (Calgary on the style form, or the city you named on the demo-city form), so a style
document is a whole opening shot, not just cartography.
The style document itself is generated per request and is not edge-cached. Everything it points at is.
The twelve styles
A style is one of the twelve 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.
A bare style name means that style in light mode, so /styles/muted.json is a valid request.
The legacy names still resolve, in case you already have them hardcoded: light → base-light,
dark → base-dark, street → base-light, relief → outdoor-light, plus the stock
Protomaps white → base-light, grayscale → muted-light and black → base-dark. They
resolve in the path and in ?flavor= alike.
block, the pixel-art style, was retired in September 2026 and has no alias. It is a 404 like
any other unrecognised name, rather than silently serving you a different map. street and
relief were retired later and alias onto base and outdoor so existing URLs keep working.
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 ignored rather than rejected.
Terrain and contours
The outdoor style, and so the legacy relief name that resolves to it, adds 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:
GET /terrain/4/{z}/{x}/{y}.png?key=... # terrarium-encoded elevation, z0-12
GET /contours/4/{z}/{x}/{y}?key=... # vector contour lines, z9-13
The number is an archive version segment, the same cache-busting device the glyph and sprite paths
use below. The unversioned forms, /terrain/{z}/{x}/{y}.png and /contours/{z}/{x}/{y}, are still
served for styles embedded before the segment existed; a style you fetch today emits the versioned
pair.
Terrain answers image/png, contours answer application/x-protobuf, and both are cached the
same way tiles are. Outside their zoom range, and anywhere inside it without data, they answer
204 for the same reason a vector tile does. A 404, whose JSON body names the missing archive,
means the archive behind that source is not configured at all, which is a server-side problem
rather than a coverage gap.
Terrain coverage is Canada-wide. The elevation behind outdoor is NRCan's 30 m MRDEM, tiled to
z12 and overzoomed beyond it; it reaches the Arctic archipelago and, because the source follows
watersheds, spills a little across the US border. That spill is not US coverage.
Contours are a terrain-reading aid derived from a roughly 20 m elevation model. They are not a survey product, and not suitable for navigation.
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.
English and French are both present throughout the Canadian extract. 31 features differ in a single Montréal tile, Nuns' Island and L'Île-des-Sœurs among them.
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), but they follow the same auth rule as tiles: the key travels as ?key=, because
MapLibre cannot set headers on these requests either.
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.
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 these
responses are served immutable for a year and the cache is keyed on path. The version is
currently 2. The unversioned forms, /glyphs/{fontstack}/{range}.pbf and /sprite/{file},
are still served for styles that were embedded in pages before the segment existed. Use the
versioned paths in anything new; a style you fetch today already does.
A missing glyph range or sprite sheet gets a plain-text 404, not a 500: 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.