Skip to content

Layers

unmap is not a generic GIS host. Canadian authoritative data is a named catalog: each layer has an id, a licence, attribution, coverage, and known gaps. Tiles are named archives on R2, served the same way contours are: a range read plus the Cache API, never compute.

GET /layers
GET /layers/{id}
GET /layers/{id}/{z}/{x}/{y}
GET /styles/{style}.json?overlays=municipalities,survey.dls
GET /identify?at=lng,lat
GET /data/query?layer=energy.wells&bbox=minlng,minlat,maxlng,maxlat
npm i @unmap/sdk
live result
the code that produced it
import { Unmap } from "@unmap/sdk";

const unmap = new Unmap({
  key: "um_live_...",
  container: "map",
  style: "base",
  overlays: ["municipalities"],
  center: [-114.0719, 51.0447],
  zoom: 8,
});

How ids are named

An id names the thing, never the country. wells, rail, survey.dls. Where a layer actually has coverage is the jurisdiction field, which is machine readable and can grow; the id is a stable name and cannot.

Foundation layers used to carry a ca. prefix that industry layers did not, which read as a geographic statement and was not one. Every layer in this catalog is Canadian, and most of the industry ones are Alberta-only, so ca.rail and energy.wells sat on opposite sides of a line that tracked product tier rather than country. The prefix is gone.

Six of the eight renamed ids still resolve: ca.survey.dls fetches survey.dls, and so on for ca.municipalities, ca.parks, ca.aboriginal-lands, ca.survey.nts and ca.survey.parcels. ca.water and ca.rail are retired along with the layers they aliased (see below) rather than repointed at something else. They are aliases, not catalog rows: /layers lists 30 layers under the new names. Prefer the new ids; the old ones are not going to be removed from under you.

When unmap serves another country, the dataset decides what happens. A well is a well, so American wells widen energy.wells to ["CA-AB", "US-TX"] and a customer working the Bakken across the Saskatchewan border queries one layer. A survey system is not: the Dominion Land Survey and the Public Land Survey System are different systems, so they are survey.dls and survey.plss, two ids whose own names already tell them apart.

Catalog

GET /layers returns every row. GET /layers/{id} returns one. Both are public metadata: licence, attribution, coverage, known gaps, zoom bands, and whether the layer is an add-on. Raw R2 object keys are not included. Incomplete coverage is never hidden.

GET /layers?jurisdiction=CA-AB narrows to layers with verified coverage there. A country code matches its own subdivisions, so ?jurisdiction=CA includes everything Albertan. Verified is the load-bearing word: several layers come from upstream archives that look national but whose provincial coverage nobody has checked, and those provinces are deliberately absent from jurisdiction even though coverage describes them in prose. The field answers "what can I rely on", so a generous answer would be a wrong one. An unknown code returns an empty array rather than an error.

Canada 0.1–0.4 ids. Every archive in this table is uploaded and serving, and every row's coverage and jurisdiction fields say where it actually reaches.

idAdd-onStatus
municipalitiescoreOGL-Canada (StatCan census subdivisions). Overlay tiles live. Identify needs the Land bake.
parkscoreOGL-Canada (ECCC CPCAD, Dec 2025). The protected-areas register: 22,438 areas, terrestrial and marine, with designation type and en/fr/Indigenous names. National.
aboriginal-landscoreOGL-Canada. Legislative/admin boundaries, not traditional territories. Overlay tiles live.
survey.dlslegal-landProvincial open data: AB (OGL-AB, ATS v4.1), SK (GOS Standard Unrestricted Use v2.0, GeoHub), MB (OpenMB Licence, Manitoba Land Initiative), BC Peace (OGL-BC, BC Data Catalogue). 32 source-layers with labels.
survey.ntslegal-landOGL-Canada, NRCan Indexes of the National Topographic System. 8 source-layers with labels.
energy.wellsenergy.geothermalenergyEnergy 0.1. AER / Alberta Energy attribution. See Energy profile.
agriculture.cropsagriculture.bc-capabilityagricultureAgriculture 0.1 overlays. AAFC / OGL-AB / OGL-BC. Not a national farm map. See Agriculture profile.
survey.geographic-townshiplegal-landOntario's own survey: named townships, concession strips, lots. Not DLS and not NTS. OGL-Ontario (Land Information Ontario). 6 source-layers with labels.
survey.river-lotslegal-landManitoba river lots, which predate the DLS and are surveyed off the rivers rather than to a grid. OpenMB Licence (Manitoba Land Initiative). 2 source-layers with labels.
agriculture.soil-ordersagricultureAAFC Soil Landscapes of Canada v3.2, ~1:1M. The one agriculture layer that reaches the Atlantic provinces. Regional context, not a soil survey.
survey.parcelslegal-landThe whole ParcelMap BC cadastral fabric, urban lots included, with identify and PID lookup. Not farmland: intersect with agriculture.alr for that. OGL-BC.
remote.airportsremoteRemote 0.2. Transport Canada ANS airports. Overlay only. Overlay tiles live. See Remote profile.
mining.occurrencesmining.sk-potentialminingMining 0.1 overlays. Mining 0.2 identify via Land extra tables. Tenure is energy.mineral-tenure. See Mining profile.

Petrinex activity/production and Manitoba Petroleum Branch are held and are not catalogued.

A missing archive is a 404 { "error": "layer archive not configured" }. A zoom outside the layer's bands is a 204. That is enough to compose styles and write clients before the objects land. Live archive checks run only when UNMAP_CANADA_LAYERS=1.

aboriginal-lands is open NRCan administrative geography. It is not a map of Indigenous nations or traditional territories. Quote known_gaps on /layers/aboriginal-lands.

Overlays

?overlays= on a style URL adds sources and palette-only line and label layers above roads and below labels. Colour comes from the style's flavour, never from inline hex, so a theme recolours the overlay with the rest of the map. Unknown ids are a 400. A gated overlay on a key without the add-on is a 403 addon_required.

import { Unmap } from "@unmap/sdk";
 
const unmap = new Unmap({
  key: "um_live_...",
  container: "map",
  style: "base",
  overlays: ["municipalities"],
  center: [-114.0719, 51.0447],
  zoom: 8,
});

profile: "energy", agriculture, remote, or mining is a composition preset for that overlay set. It is not a style. When both profile and overlays are set, explicit overlays win. See Energy, Agriculture, Remote, and Mining.

Identify

GET /identify?at=lng,lat (SDK: unmap.identify()) is the identify API. It asks which catalog polygons contain a point. It is not reverse geocoding: the answer is feature ids from PostGIS ST_Contains, not a civic ranking. Bbox listing is GET /data/query / unmap.data.query() below.

Default layers is municipalities only, so a core key can identify without the legal-land add-on. Ask for survey.dls or survey.nts when you want the survey cell; those need the add-on and meter under legal-land. Core-only calls meter as layers. lon and lat are accepted instead of at. geometry=true includes polygons when the row has one.

Identify-capable ids today: municipalities, survey.dls, survey.nts, survey.parcels, energy.wells, energy.pipelines, energy.facilities, energy.fields, energy.petroleum-tenure, energy.mineral-tenure, plus mining identify ids. Anything else, including catalogued tile layers that have no table yet (parks, rail, aboriginal-lands, water, energy.ccs, energy.geothermal, remote.airports), is a 400 that lists those ids. Energy layers need the energy add-on. survey.parcels needs legal-land. Both are empty-safe: a missing table returns no features, not a 500. Results are empty until the matching table is loaded.

import { Unmap } from "@unmap/sdk";
 
const unmap = new Unmap({ key: "um_live_..." });
 
const { at, results } = await unmap.identify({
  at: [-114.0719, 51.0447],
  layers: ["municipalities", "survey.dls"],
  geometry: true,
});
GET /identify?at=-114.0719,51.0447&layers=municipalities
{
  "at": [-114.0719, 51.0447],
  "results": [
    { "layer": "municipalities", "id": "ab:calgary", "name": "Calgary", "province": "AB" }
  ]
}

municipalities is exported by pipeline/legal-land/ from Township Canada public.municipal_boundaries and baked into the Land image as municipalities.copy. Until that COPY is in the running image, results is []. That is the honest empty, not an error. Overlay tiles for municipalities can still paint. See Coverage.

Query

GET /data/query?layer=&bbox= (SDK: unmap.data.query()) lists features of one catalog layer that intersect a bounding box. Same PostGIS tables as identify. Same add-on and metering rules. Limit defaults to 50 and is clamped to 1–50. geometry=true includes geometries when the row has one. Results are empty until the matching table is loaded.

Queryable ids are the identify-capable set: municipalities, survey.dls, survey.nts, survey.parcels, energy.wells, energy.pipelines, energy.facilities, energy.fields, energy.petroleum-tenure, energy.mineral-tenure, mining.occurrences, mining.prospectivity, mining.sk-potential. A catalogued tile-only layer (parks, aboriginal-lands, energy.ccs, energy.geothermal, remote.*, other agriculture overlays) is a 501 that lists those ids. An unknown id is a 400. There is no arbitrary-polygon filter, no GeoAI, and no imagery catalog.

import { Unmap } from "@unmap/sdk";
 
const unmap = new Unmap({ key: "um_live_..." });
 
const { layer, bbox, results } = await unmap.data.query({
  layer: "energy.wells",
  bbox: [-114.3, 50.9, -113.8, 51.2],
  limit: 20,
});
GET /data/query?layer=energy.wells&bbox=-114.3,50.9,-113.8,51.2&limit=20
{
  "layer": "energy.wells",
  "bbox": [-114.3, 50.9, -113.8, 51.2],
  "results": []
}

An empty results array is the honest miss when the Energy table is not loaded, not an error. /identify is still the click-at-a-point API.

Retired layers

water, rail and remote.roads were withdrawn on 2026-09-15 because the basemap already draws all three, better. remote.roads had a second problem: the remote profile exists for winter and ice road access, and the NRN all-season network carries no winter road status, so it never answered the question it was added for.

Retired idUse instead
water, ca.waterthe basemap's own water layer, z0–15
rail, ca.railthe basemap's own roads layer filtered to kind: rail, z3–15
remote.roadsthe basemap's own roads layer, z3–15, with kind, ref, network and name:fr

Both basemap layers reach z15 where the overlays stopped at z12, and both carry name:fr plus the name2/name3 and script fields that render Indigenous names. The overlays carried id, name and region. There was nothing in them the basemap did not already have.

What retirement means in practice:

  • GET /layers no longer lists them, so the catalog is 29 rows.
  • GET /layers/water answers 410 Gone with use_instead naming the replacement, rather than 404. "Withdrawn, use this" is a different fact from "no such thing".
  • A style URL that still names one keeps working. ?overlays=water,municipalities returns 200 with the municipalities overlay and no water overlay. This matters because an unknown overlay id is a 400 that fails the whole style request: if a retired id were treated as unknown, a customer whose URL still said water would lose their entire map, not one layer.
  • Tile requests under /layers/water/{z}/{x}/{y} 404.

Survey grids are one source, many levels

survey.dls, survey.nts, survey.geographic-township and survey.river-lots are each a single archive holding every level of that survey as its own vector source-layer, with its own zoom range inside the file. They are four separate layers because they are four separate survey systems: a Lot 2 Con 4 Osprey in Ontario is not expressible as a DLS quarter section, and Manitoba's river lots predate the DLS grid entirely. GET /layers/{id} publishes the list as source_layers:

Layersource_layers
survey.dlsab_twp, bc_twp, mb_twp, sk_twp, ab_secsk_lsd (sixteen: four provinces x township, section, quarter, legal subdivision), plus ab_twp_labelsk_lsd_label
survey.ntsbc_series, bc_block, bc_unit, bc_qtr_unit, plus bc_series_labelbc_qtr_unit_label
survey.geographic-townshipon_twp, on_con, on_lot, plus on_twp_label, on_con_label, on_lot_label
survey.river-lotsmb_river_lots, plus mb_river_lots_label

Every polygon level has a matching _label point layer holding descriptor, the legal description itself (NW-25-24-1-W5 for DLS, the map-sheet designation for NTS, Lot 2 Con 4 Osprey for an Ontario lot). Draw the polygons as lines and the labels as a symbol layer on descriptor: a grid of identical squares is unreadable without them, and a symbol layer placed on the polygons lands in the wrong spot.

That means one source in your style and one tile request per tile, whichever levels you draw. Add the level you want as its own map layer:

map.addSource("dls", {
  type: "vector",
  tiles: ["https://api.unmap.dev/layers/survey.dls/{z}/{x}/{y}?key=um_live_..."],
  minzoom: 0,
  maxzoom: 14
});
 
// Townships from far out, quarter sections only when you are close enough to read them.
map.addLayer({
  id: "twp", type: "line", source: "dls", "source-layer": "ab_twp",
  paint: { "line-color": "#294049", "line-width": 1 }
});
map.addLayer({
  id: "qtr", type: "line", source: "dls", "source-layer": "ab_qtr",
  minzoom: 11,
  paint: { "line-color": "#294049", "line-width": 0.5 }
});
 
// The legal description, from the point layer built for it.
map.addLayer({
  id: "twp-label", type: "symbol", source: "dls", "source-layer": "ab_twp_label",
  layout: { "text-field": ["get", "descriptor"], "text-size": 11 }
});

Township spans z0 to z14; section, quarter and legal subdivision span z9 to z14. A request for ab_lsd at z5 is legitimately empty rather than an error. The same holds for NTS: bc_series covers z0 to z14, the three finer levels z9 to z14.

?overlays=survey.dls still works and draws every level for you. Use the source directly when you want control over which levels appear at which zoom.

Add-ons and metering

Two words that look interchangeable and are not. A SKU is what you buy and are billed for, like addon-energy. A capability is what it lets you do, like energy. They differ whenever a SKU bundles another, and /account/me returns both.

SKUMonthlyAnnualCapabilities it grants
addon-legal-land$39$390legalLand
addon-energy$129$1,290energy, legalLand
addon-agriculture$69$690agriculture, legalLand
addon-mining$49$490mining
addon-remote$39$390remote

ca.* foundation layers are core. survey.* needs legalLand. energy.* needs energy, agriculture.* needs agriculture, remote.* needs remote, mining.* needs mining. Because Energy and Agriculture bundle legalLand, either one reaches the survey grids without buying Legal Land separately.

A gated request on a key without the capability is:

{
  "error": "Legal Land requires the Legal Land add-on",
  "code": "addon_required",
  "capability": "legalLand",
  "addon": "addon-legal-land",
  "message": "Legal Land requires the Legal Land add-on for production use."
}

addon names the cheapest SKU that would grant it, which is what to buy. Keys without an account (founder and integration keys) are exempt, the same way they skip origin policy.

Free for development. On the dev plan every capability is unlocked from a dev origin: localhost, 127.0.0.1, *.localhost, *.pages.dev and *.workers.dev. No card, no subscription, nothing to buy. Build the whole integration first and pay when it goes live. The same key from a production origin answers 403 dev_only, whose message names the step that actually fixes it, which is subscribing rather than buying an add-on.

There is a ceiling on it: 5,000 premium calls a month, summed across the gated buckets. It exists to stop the free grant being used as free production, not to meter you, so it is a sum over the usage counter you already have rather than an allowance of its own. Past it the gated capabilities answer 403 usage_limit_reached and core tiles, styles, geocoding and routing keep serving normally. It resets at the start of the month.

Buying one. Add-ons are flat items on top of a paid plan, billed on the same interval as the plan (a monthly subscription takes monthly add-ons, an annual one takes annual), managed from the billing page in your dashboard or with POST /account/billing/addons. Send the full set you want to end with, not a delta, so a retry is safe:

{ "addons": ["addon-energy", "addon-remote"] }

Stripe carries the entitlement. The route updates your subscription and the grant lands on your keys by webhook a few seconds later, so re-read /account/me rather than assuming the call granted it.

Bundles are never billed twice. Adding Energy to an account that already holds Legal Land replaces the Legal Land item in the same update rather than stacking on it, because Energy already grants legalLand. The bill goes up by $129 minus the unused part of $39, not by $129.

Adding starts now; dropping waits. An addition is prorated and takes effect immediately. A removal is scheduled for the end of the period you have already paid for: the capability keeps working until then, and /account/me reports it in addonsRemoving with the date in addonsRemovingAt while still listing it in addons and capabilities, because all of that is true at once. Asking for the SKU again before that date cancels the removal. Cancelling the subscription revokes every add-on.

Add-ons need a paid plan to hang from: on the dev plan there is no subscription to add an item to, and the call answers 400. An add-on whose Stripe price is not configured yet answers 503 billing_required and names which one, rather than failing vaguely.

Metering. One request is one call, whatever it touches. Catalog reads (GET /layers, GET /layers/{id}) and ungated layer tiles count as layers. Gated layer tiles count under a usage bucket named for the capability in wire form: legal-land, energy, agriculture, remote, mining. Those bucket names are hyphenated and the capability names are not, and the difference is deliberate: the buckets are a billing wire format that predates the SKU split and cannot be renamed without breaking stored usage rows. The style document itself still counts as styles; overlay tile fetches are the billable layer hits.

There is no separate add-on allowance. A gated request draws from your plan's included calls exactly like any other; the add-on is what makes it allowed, not what pays for it.

See Errors for the addon_required contract.