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
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.
| id | Add-on | Status |
|---|---|---|
municipalities | core | OGL-Canada (StatCan census subdivisions). Overlay tiles live. Identify needs the Land bake. |
parks | core | OGL-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-lands | core | OGL-Canada. Legislative/admin boundaries, not traditional territories. Overlay tiles live. |
survey.dls | legal-land | Provincial 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.nts | legal-land | OGL-Canada, NRCan Indexes of the National Topographic System. 8 source-layers with labels. |
energy.wells … energy.geothermal | energy | Energy 0.1. AER / Alberta Energy attribution. See Energy profile. |
agriculture.crops … agriculture.bc-capability | agriculture | Agriculture 0.1 overlays. AAFC / OGL-AB / OGL-BC. Not a national farm map. See Agriculture profile. |
survey.geographic-township | legal-land | Ontario'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-lots | legal-land | Manitoba 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-orders | agriculture | AAFC Soil Landscapes of Canada v3.2, ~1:1M. The one agriculture layer that reaches the Atlantic provinces. Regional context, not a soil survey. |
survey.parcels | legal-land | The whole ParcelMap BC cadastral fabric, urban lots included, with identify and PID lookup. Not farmland: intersect with agriculture.alr for that. OGL-BC. |
remote.airports | remote | Remote 0.2. Transport Canada ANS airports. Overlay only. Overlay tiles live. See Remote profile. |
mining.occurrences … mining.sk-potential | mining | Mining 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 id | Use instead |
|---|---|
water, ca.water | the basemap's own water layer, z0–15 |
rail, ca.rail | the basemap's own roads layer filtered to kind: rail, z3–15 |
remote.roads | the 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 /layersno longer lists them, so the catalog is 29 rows.GET /layers/wateranswers410 Gonewithuse_insteadnaming the replacement, rather than404. "Withdrawn, use this" is a different fact from "no such thing".- A style URL that still names one keeps working.
?overlays=water,municipalitiesreturns200with the municipalities overlay and no water overlay. This matters because an unknown overlay id is a400that fails the whole style request: if a retired id were treated as unknown, a customer whose URL still saidwaterwould 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:
| Layer | source_layers |
|---|---|
survey.dls | ab_twp, bc_twp, mb_twp, sk_twp, ab_sec … sk_lsd (sixteen: four provinces x township, section, quarter, legal subdivision), plus ab_twp_label … sk_lsd_label |
survey.nts | bc_series, bc_block, bc_unit, bc_qtr_unit, plus bc_series_label … bc_qtr_unit_label |
survey.geographic-township | on_twp, on_con, on_lot, plus on_twp_label, on_con_label, on_lot_label |
survey.river-lots | mb_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.
| SKU | Monthly | Annual | Capabilities it grants |
|---|---|---|---|
addon-legal-land | $39 | $390 | legalLand |
addon-energy | $129 | $1,290 | energy, legalLand |
addon-agriculture | $69 | $690 | agriculture, legalLand |
addon-mining | $49 | $490 | mining |
addon-remote | $39 | $390 | remote |
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.