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.

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:

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

Four 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, 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 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. A column outside 0 .. 2^z-1 is that same 400: 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. A 204 is 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 503 with the plain-text body basemap release is misconfigured and Cache-Control: private, no-store. That response does not serve the previous archive and is not an empty 204. On /tiles/{release}/{z}/{x}/{y}, an eight-digit id that was never copied is 404 basemap release not found, also private, no-store. An id that is not eight digits is that same 404 body 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:

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 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-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.
  • 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:

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