Skip to content

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:

npm i @unmap/sdk

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.mvt

Three things worth knowing:

  • An empty answer is 204 No Content, not 404. 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 a 404 here would be both wrong and noisy.
  • Malformed coordinates (non-integer z, x, or y) get a 400 with 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:

live result
the code that produced it
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"

{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-dark renders 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: lightbase-light, darkbase-dark, streetbase-light, reliefoutdoor-light, plus the stock Protomaps whitebase-light, grayscalemuted-light and blackbase-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:

live result
the code that produced it
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