Skip to content

Geocoding API

Geocoding turns text into a coordinate, and back again. Four endpoints do it over roughly 19 million Canadian addresses and places: search from a whole query, type-ahead autocomplete from a partial one, category search near a point, and reverse lookup from a coordinate to the nearest place. A fifth route, POST /geocode/batch, runs up to 100 searches in one request.

Search and autocomplete are ranked by relevance rather than by substring match, so "17 Ave SW Calgary" finds the avenue and not every row containing the word Calgary. Nearby and reverse are ordered by real-world distance instead. Every endpoint returns bilingual names on the same record, plus Indigenous-language names where the source data carries them.

Every request needs an API key; see Authentication.

Here is a live search, and the two lines that made it:

live result
the code that produced it
import { Unmap } from "@unmap/sdk";

const unmap = new Unmap({ key: "um_live_..." });

const results = await unmap.geocoder.search("Calgary Tower", { limit: 3 });
npm i @unmap/geocoding

@unmap/geocoding is the standalone client (no map dependency); @unmap/sdk exposes the same object as unmap.geocoder. Everything below is the HTTP contract underneath both.

Result shape

The four single-query endpoints return a JSON array, even for zero or one match. Batch returns an array of those arrays, one slot per query:

[
  {
    "id": "23493650",
    "name": "Calgary",
    "layer": "locality",
    "lng": -114.08529,
    "lat": 51.05011,
    "score": 11.18,
    "source": "nrcan_cgndb",
    "match_type": "exact",
    "precision": "locality",
    "names": { "en": "Calgary" },
    "address": { "city": "Calgary", "region": "AB", "country": "CA" }
  }
]
  • id: a stable string identifier for the row. Always a string, even though the underlying value is numeric.
  • name: the label to display. It is resolved in this order: the names entry for the lang you asked for, then the row's French name if you asked for fr, then the row's default name, then names.en, then any remaining names value. A plain address row has none of those, so its label is composed from its parts instead: 101 17 Avenue SW, Calgary. Only a row with no name and no street, which the corpus should not contain, comes back with an empty string.
  • layer: what kind of thing the result is, one of address, street, locality (a city or town), region (a province or territory), poi (a point of interest such as a shop or a park), legal_land (a survey description), well, facility, or parcel. Civic results are unchanged.
  • lng, lat: the point, as plain longitude and latitude degrees, the same numbers a browser's geolocation API gives you.
  • score: the ranking score. Present on search and autocomplete results only. Higher is more relevant. It is not comparable between queries.
  • distance: metres from the point you searched around, measured over the curve of the earth and rounded to a whole metre. Present on nearby results only. Lower is nearer, and results already arrive in that order.
  • category: the canonical taxonomy id the feature carries, such as health.pharmacy. Present on nearby results. Search, autocomplete and reverse results do not carry it.
  • names: present whenever the row carries any language-keyed labels at all, even just one (en, fr, iu, cr, …). The earlier "Calgary" example above has a single-entry names. Not every key is a language: a row can also carry alternate spellings people search by, namely en-alt1en-alt3 and fr-alt1fr-alt3 (colloquial and variant names, such as Old Montreal for Vieux-Montréal) and, for airports, en-iata (YYZ) and en-iata2 (YYZ Airport). These keys are matched by search and count as exact-name hits, but they are never used as name. If you enumerate names to build a language picker, skip keys ending in -alt or -iata, with or without a trailing digit. Omitted only when the row has no labels at all.
  • source, source_id: which dataset the returned record came from, and its id there. Records describing the same place are merged before indexing and one survives, so this is the surviving record's source, not a list of everything that agreed. See Coverage.
  • match_type, precision, match_reasons: how well the result answers what you asked, and how precisely it is placed. See Result metadata below.
  • address: present only when at least one structured component (house number, unit, street, city, region, postcode, country) exists for the row. Omitted otherwise. unit appears only where a source published one, which is rare.
  • legal_land: present on a legal-land hit. { system, province?, canonical, unit, components }.
  • uwi, licence, operator, status: present on a well or facility hit.
  • pid: ParcelMap BC PID on a parcel hit.
  • bbox: survey-cell bounds [minLng, minLat, maxLng, maxLat] when the lookup row has a geom.
  • geometry: the survey polygon, only when you pass geometry=true and the row has one.
GET /geocode/search
  • q (required): the search text.
  • lang (optional, default en): which language to render name as.
  • limit (optional, default 10, clamped to 1–50). A non-numeric value falls back to the default rather than failing the request.
  • bbox (optional): minlon,minlat,maxlon,maxlat. This is a hard filter, not a bias. A row whose point falls outside the box is dropped from the results entirely, so a box with nothing in it returns [].
curl "https://api.unmap.dev/geocode/search?q=Calgary%20Tower&limit=3" \
  -H "Authorization: Bearer $UNMAP_API_KEY"

A missing q gets a 400: { "error": "q required" }. A malformed bbox (not exactly four finite numbers) gets a 400: { "error": "bbox must be minlon,minlat,maxlon,maxlat with 4 finite numbers" }.

Naming a city in the query text biases results toward it without constraining them. 17 Ave SW Calgary resolves on 17 Avenue SW in Calgary, and a genuine match just outside the municipal boundary is still returned, just lower. Use bbox when you want a hard boundary instead.

Search controls

Three optional parameters, on search, autocomplete and structured search alike. Two of them exclude results and one only reorders them, and the difference is the whole point:

  • layers: which kinds of result may come back, from address, street, locality, region, poi. A hard filter. An unknown value is a 400 rather than being ignored, so a typo tells you instead of quietly searching everything. The energy values well and facility still select the energy corpus instead and need that add-on; the two sets cannot be mixed.
  • region: a province or territory, in any spelling (AB, Alberta, alta., Québec). A hard filter with one rule: it excludes rows that contradict it and keeps rows that are silent about it. A street record with no province stays in, and says region_not_verified. Naming a province inside q only biases; this parameter excludes.
  • focus: lng,lat. A soft preference. It moves ranking toward the point over roughly 25 km and excludes nothing, so a relevant result across the country still comes back, behind the near ones. This is the difference from bbox: "prefer nearby" is not "restrict to this area".

Hard filters compose by intersection, and contradictory ones return no matches rather than quietly widening. focus applies only within whatever survives the filters.

curl "https://api.unmap.dev/geocode/search?q=Springfield&layers=locality&focus=-63.57,44.64" \
  -H "Authorization: Bearer $UNMAP_API_KEY"
GET /geocode/structured

When you already know which part is the street and which is the province, say so. A free-text engine has to guess, and a province it guesses wrong about becomes an ordinary search word that can score whatever row it likes.

  • address (optional): the street line, civic number and street name.
  • city (optional)
  • region (optional): any accepted spelling, normalised to a two-letter code.
  • postalcode (optional): with or without the space.
  • country (optional): CA only.

At least one of address, city or postalcode is required. A province on its own is a 400: it is not a search, it is a request for four million rows.

Each field has one role and they are not interchangeable. address and city decide which rows are candidates and which wins. region and postalcode narrow, under the same rule as above: they exclude records that contradict them and keep records that are silent about them. country is validated, not matched.

curl "https://api.unmap.dev/geocode/structured?address=100%20Main%20Street&city=London&region=ON" \
  -H "Authorization: Bearer $UNMAP_API_KEY"

Legal land, parcel and energy grammars are not detected here. Those are shapes of a free-text string, and a street literally called NW-25-24-1-W5 is still a street.

Result metadata

Every result says where it came from and how well it answers what you asked. The two are separate questions and the fields keep them separate.

match_type is about your input:

ValueMeans
exactEvery component you supplied that the data supports was matched
partialSomething you supplied did not match, or could not be verified
fallbackThe result is coarser than you asked for: you gave a civic number and this record has none
unknownInput matching does not apply, on reverse, nearby and autocomplete

The rule this exists to enforce: a street or a city standing in for an address is always fallback, never exact. Asking for 101 17 Ave SW Calgary and getting the avenue is a useful answer, and a misleading one if you cannot tell it from the building.

precision is about the position: point, street, locality, region, or unknown. point means a discrete point record in the source, an address point or a place of interest. It is not a rooftop accuracy claim, and it never means interpolated, because nothing is interpolated. locality and region are centroids.

The two axes are independent. Searching "Toronto" and getting the city is exact and locality at the same time: a perfect match, at city precision.

match_reasons explains a non-exact match with stable codes, and is absent when the match is exact: housenumber_not_matched, housenumber_differs, unit_not_verified, unit_differs, postalcode_not_verified, postalcode_differs, region_not_verified, region_differs, terms_unmatched, fuzzy_match.

score is unchanged and is still a raw relevance number. None of these fields is a confidence value, and none of them is derived from score.

Autocomplete

GET /geocode/autocomplete

Same q, lang, and limit parameters as search (default limit is 8), tuned for firing on every keystroke rather than a single deliberate query. It matches genuine partial prefixes rather than only complete indexed words: Calgar finds Calgary, Vancou finds Vancouver. Autocomplete takes no bbox.

curl "https://api.unmap.dev/geocode/autocomplete?q=rue+sainte-cath&lang=fr&limit=5" \
  -H "Authorization: Bearer $UNMAP_API_KEY"
live result
the code that produced it
import { Unmap } from "@unmap/sdk";

const unmap = new Unmap({ key: "um_live_..." });

const results = await unmap.geocoder.autocomplete("rue sainte-cath", { limit: 5, lang: "fr" });

Send the whole input box, not the last word. Every word but the last must match a token in full and the last is treated as the prefix still being typed, so Calgary Tow finds Calgary Tower and rue sainte-cath finds rue Sainte-Catherine. Word order does not matter, hyphens are word boundaries, and accents are folded the same way the index folds them, so cathé and cathe are the same prefix. Input that contains no letters or digits at all returns [].

A missing q gets the same 400: { "error": "q required" }.

One thing to know before you wire this to an input box: autocomplete ranks more crudely than search. It scores on term match, the row's prominence and the same per-layer weighting search uses, and on nothing else: no exact-name bonus, no bias toward a place named elsewhere in the query, no second pass that tolerates a typo. Input of three characters or fewer is treated as a place query and answers with localities, provinces and points of interest only; addresses and streets come back at the fourth character. Use /geocode/search when you want the best single answer and autocomplete when you want fast suggestions.

Nearby

GET /geocode/nearby

Category search: features of a kind near a point, sorted by distance. This is a separate endpoint from search on purpose. Free text is never overloaded into category semantics, so asking for banks here cannot return a street named Silverado Bank Circle.

  • category (required): one to eight categories, comma-separated. Each token is either a canonical taxonomy id (health.pharmacy) or an ordinary English or French word (pharmacy, pharmacie). Matching ignores case, accents and hyphens, so Épicerie, epicerie and EPICERIE all resolve to shop.supermarket. Aliases resolve at the API, not in a client library, so they work from plain curl.
  • near (lng,lat) or bbox (minlon,minlat,maxlon,maxlat): exactly one is required. Passing both, or neither, is a 400. A bbox anchors the search at the box centre and derives a radius that covers the box, so it is a way to say "around here", not a clipping rectangle: a result can sit inside that radius and outside the box corners.
  • radius (optional): metres, default 5000, clamped to 50–50000. An explicit radius wins over the one derived from a bbox.
  • limit (optional, default 10, clamped to 1–50).
  • lang (optional, default en): which language to render name as.

Results are sorted nearest first and carry distance in metres and category. They carry no score: nearest means nearest, and mixing relevance into it is how a distant big-box store beats the pharmacy across the street.

curl "https://api.unmap.dev/geocode/nearby?category=pharmacy&near=-114.07,51.05&limit=3" \
  -H "Authorization: Bearer $UNMAP_API_KEY"
 
# Same query in French, and two categories at once
curl "https://api.unmap.dev/geocode/nearby?category=pharmacie,depanneur&near=-114.07,51.05" \
  -H "Authorization: Bearer $UNMAP_API_KEY"

An unrecognized token gets a 400 naming the word you typed: { "error": "unknown category \"warp_drive\"" }. A missing category gets a 400, and so does a value resolving to more than eight ids. Missing or duplicated anchors get { "error": "exactly one of near or bbox required" }, and a malformed near gets { "error": "near must be lng,lat" }.

A 503 with { "error": "category search requires a corpus rebuilt with categories" } means the running corpus predates category tagging. It is retryable, and it clears when the next corpus build is swapped in.

live result
the code that produced it
import { Unmap } from "@unmap/sdk";

const unmap = new Unmap({ key: "um_live_..." });

const nearby = await unmap.geocoder.nearby("pharmacy", { near: [-114.0719, 51.0447], radius: 2000, limit: 5 });

Category ids

Ninety-one ids, grouped by prefix. Pass any of these verbatim as category, or pass the everyday word instead and let the API resolve it.

GroupIds
food.restaurant fast_food cafe bar pub bakery ice_cream food_court
shop.supermarket convenience mall department_store clothes shoes electronics hardware furniture alcohol cannabis butcher florist books sports pet jewelry gift bicycle
health.pharmacy hospital clinic dentist veterinary optician
finance.bank atm
education.school kindergarten college university
culture.library museum gallery cinema theatre arts_centre nightclub casino
transport.airport train_station fuel charging_station parking car_rental car_wash car_repair car_dealer bus_station ferry_terminal taxi
accommodation.hotel motel hostel guest_house camp_site
tourism.attraction viewpoint information zoo theme_park artwork picnic_site
recreation.park playground gym sports_centre swimming_pool golf stadium ice_rink dog_park
services.post_office police fire_station community_centre childcare hairdresser beauty laundry
government.townhall courthouse
religion.place_of_worship

Reverse

GET /geocode/reverse
  • lon, lat (required): the point to look up.
  • lang (optional, default en).
  • limit (optional, default 5, clamped to 1–50).
curl "https://api.unmap.dev/geocode/reverse?lon=-68.517&lat=63.7467" \
  -H "Authorization: Bearer $UNMAP_API_KEY"

Non-numeric lon/lat get a 400: { "error": "lon and lat must be numbers" }. Results are ordered by true geodesic distance, not raw coordinate difference. Degrees of longitude shrink toward the poles, so a naive ordering would rank a point due east ahead of one closer by, and that effect only grows the further north you query.

Batch

POST /geocode/batch

Up to 100 forward searches in one request. The body is JSON. The response is an array aligned to queries: index i is exactly what GET /geocode/search would return for that string.

  • queries (required): an array of 1–100 strings, order preserved. An empty array is a 400. More than 100 is a 413 payload_too_large.
  • lang, limit, layers, focus, region, bbox: the same shared controls as search. No per-item options in v1.
  • Blank or whitespace-only entries stay in place as []. They do not hit Postgres and are not billed.
curl -X POST "https://api.unmap.dev/geocode/batch" \
  -H "Authorization: Bearer $UNMAP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"queries":["17 Ave SW Calgary","Springfield"],"limit":1}'

Each non-blank query is one geocode increment, including a valid no-match. A 400, a 413, or a container failure for the whole request increments nothing. If remaining monthly allowance is less than the number of non-blank queries, the gateway refuses the whole request (429 quota_exceeded, or spend_cap / billing_required when that is the block). Partial fulfilment would break alignment.

There is no edge cache for the batch POST. geocoder.batch chunks a longer list at 100 and concatenates in input order. A CSV on your machine is a client of this route; see Batch geocoding and the example.

Caching

A successful geocoding response is cached at our edge for 24 hours. The API key is stripped before the query is answered, so the cached body is a pure function of the query parameters and is shared across every key that asks the same thing. Reverse rounds lon/lat, and nearby rounds near, to about a metre in the cache key, so neighbouring lookups share an entry. The exact coordinates you sent are still what gets answered. On nearby, a category word and its canonical id share one entry too, because aliases are resolved before the key is built.

X-Unmap-Cache on the response says HIT or MISS. A HIT is still a billable call: caching lowers our latency, not your bill. Errors are never cached, and carry Cache-Control: no-store.

From the client library

@unmap/geocoding wraps the endpoints with the same parameters:

import { Geocoder, GeocoderError } from '@unmap/geocoding'
 
const geocoder = new Geocoder({ key: 'um_live_...' })
 
await geocoder.search('Calgary Tower', { limit: 3, lang: 'fr' })
await geocoder.batch(['17 Ave SW Calgary', 'Springfield'], { limit: 1 })
await geocoder.autocomplete('Yellowkni', { limit: 5 })
await geocoder.nearby(['pharmacy', 'convenience store'], {
  near: [-114.07, 51.05],
  radius: 2000,
  limit: 10,
})
await geocoder.reverse(-68.517, 63.7467)

nearby takes a single category or an array of them, and passes your words through untouched, so French aliases work here too. search and autocomplete share one options type, so bbox type-checks on both, but only search acts on it. reverse takes lang and signal only; call the endpoint directly if you need its limit. Every method accepts an AbortSignal as signal. A non-2xx response throws a GeocoderError carrying the HTTP status on .status, and the gateway's machine-readable code on .code when the body carried one.

Languages

A row's names map can hold several language-keyed labels at once; lang= picks which one comes back as name. The same query returns the same record regardless of language: you choose the locale at render time rather than picking a different dataset at build time.

  • q=Montreal&lang=enname: "Montreal", names: {"en":"Montreal","fr":"Montréal","iu":"ᒧᕆᐊᓪ"}
  • q=Montreal&lang=frname: "Montréal", same names
  • q=Iqaluit&lang=iuname: "ᐃᖃᓗᐃᑦ"

Search matches names in whatever language the query is written in, including Inuktitut syllabics: q=ᐃᖃᓗᐃᑦ resolves to the locality Iqaluit, not an unrelated building near it. Coverage is a data question, not a search-engine one: the corpus carries an Inuktitut name for some communities and not others, so a place with no iu name in the source data has nothing for a syllabic query to match yet, even though the search engine itself handles every language present.

A well-formed Dominion Land Survey (or NTS / FPS / Ontario concession / river-lot) query branches off civic BM25 and returns layer: "legal_land". Mixing the two is how 25-24-1-W5 becomes a street in Ontario, so a parse never falls through to address ranking.

This needs the legalLand capability, which Legal Land, Energy and Agriculture all grant. An account key without it gets 403:

{
  "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."
}

Founder and integration keys (no accountId) are exempt. Civic queries on the same key are unaffected. Successful legal-land calls are metered under legal-land, not geocode.

lng and lat are required on every result. A successful parse whose legal_land row is missing (table not imported yet, empty table, or unknown cell) returns []. We do not invent a centroid. Pass geometry=true (or { geometry: true } on the client) to include the polygon when the row has one.

await geocoder.search('NW-25-24-1-W5', { geometry: true })

The registry item legal-land-search is this search box with an LSD placeholder. It sends the query string only. Do not pass profile= as a search source.

SystemProvincesParserLookupNotes
DLS LSD / qtr / sec / twpAB, SK, MB (+ BC Peace)yesyes, when the trimmed dump is loadedAB ATS is OGL-AB. SK / ON / MB UNVERIFIED
NTSBC, YT / NT contextyesyes, when the dump is loadedTiles are survey.nts
FPSNT, NU, YT, offshoreyesnofps_unit_lookup will not fit the container
ON con / lotONyesnoLicence UNVERIFIED
River lotsprairie river lotsyesno
Quebec / Atlanticn/anonoNot in the source tables; do not invent
CivicCAyescivic corpus
Rural route / general deliveryCAyescivic corpus, on the communityDelivery terms are stripped; the route itself is not a place

The parser works without the table. A hit needs an imported row.

Energy (UWI / wells)

A Unique Well Identifier (AER ST37 or Petrinex/IHS DLS form, or a BC NTS UWI) is the energy add-on. It branches off civic BM25 and returns layer: "well", or 403:

{
  "error": "Energy requires the Energy add-on",
  "code": "addon_required",
  "capability": "energy",
  "addon": "addon-energy",
  "message": "Energy requires the Energy add-on for production use."
}

Founder and integration keys are exempt. Civic queries on the same key are unaffected. A UWI miss (table not imported, empty table, or unknown well) returns []. We do not invent a coordinate and we do not fall through to address ranking.

await geocoder.search('00/01-01-001-01W4/0')

The registry item well-search is this search box labelled for UWIs. It sends the query string only; the gateway detects UWI vs civic. Do not pass profile= as a search source.

Prefixed licences (W0485123, LIC 123456) take the same path. Bare digits do not. Facility names are too generic unless you pass layers=facility (or profile=energy).

BC parcels (ParcelMap BC PID)

A nine-digit ParcelMap BC PID (010-867-813 or 010867813) needs the legalLand capability, the same one the survey.parcels layer needs. The BC export carries no agricultural filter, so the parcel fabric is cadastre and is gated as cadastre; Agriculture customers reach it through the bundle, which grants legalLand. It branches off civic BM25 and returns layer: "parcel", or 403:

{
  "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."
}

Successful PID calls meter as legal-land. A PID miss (table not imported, empty table, or unknown parcel) returns []. We do not invent a centroid. Coverage is British Columbia only. PIN, plan number, and parcel-name search are not shipped.

await geocoder.search('010-867-813')

Identify the containing parcel with layers=survey.parcels. Both paths need parcels.copy in the Land image.

Coverage
IntendedAlberta (AER ST37 / ST102)
SK / BCUNVERIFIED
ManitobaHeld
Petrinex productionHeld

See Energy profile.

Identify

The other direction: a point, the containing catalog polygons. That is GET /identify, not a new geocode layer. Default layers is municipalities only. Survey cells must be asked for and need the legalLand capability. energy.wells, energy.pipelines, energy.facilities (and fields / tenure) need energy and are empty-safe: a missing table returns no features, not a 500. energy.ccs and energy.geothermal are not identify-capable. Municipalities load from pipeline/legal-land/ (municipalities.copy in the Land image). Until that bake is running, results is []. Bbox listing of the same tables is GET /data/query / unmap.data.query() on Layers. See Layers and Coverage.

await unmap.identify({ at: [-114.07, 51.05], layers: ["municipalities"] })

Known gaps

  • A rural route is never geocoded to the mailbox. RR 2 Okotoks resolves to Okotoks, not to a point on the route. RR 2 exists in hundreds of communities and the position of the boxes is in no open dataset, so there is nothing honest to return. Canada Post delivery terms (RR, SS, MR, GD, SITE, COMP, BOX, STN) are removed before the query reaches the corpus, because sending them whole ranks on the digits and returns something arbitrary. A query that is delivery terms and nothing else is a 400 saying to add the community. parseRuralRoute from @unmap/geocoding returns the parsed terms if you want to display them beside the result.
  • Legal-land lookup depends on the imported table. The parser recognises NW-25-24-1-W5 without it; coordinates come back only after pipeline/legal-land/ has loaded the cell.
  • Energy lookup depends on the imported tables. UWI detect works without them; coordinates come back only after pipeline/energy/ has loaded the well.

Next steps

  • Quickstart steps 4 and 5 search and then pin the answer on a map.
  • Batch geocoding for a CSV on your machine, and the example.
  • Errors for every status this endpoint can answer with, and the bodies it sends.
  • Components for a copy-in search box built on this API.