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:
import { Unmap } from "@unmap/sdk";
const unmap = new Unmap({ key: "um_live_..." });
const results = await unmap.geocoder.search("Calgary Tower", { limit: 3 });@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: thenamesentry for thelangyou asked for, then the row's French name if you asked forfr, then the row's default name, thennames.en, then any remainingnamesvalue. 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 ofaddress,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, orparcel. 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 ashealth.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-entrynames. Not every key is a language: a row can also carry alternate spellings people search by, namelyen-alt1…en-alt3andfr-alt1…fr-alt3(colloquial and variant names, such asOld Montrealfor Vieux-Montréal) and, for airports,en-iata(YYZ) anden-iata2(YYZ Airport). These keys are matched by search and count as exact-name hits, but they are never used asname. If you enumeratenamesto build a language picker, skip keys ending in-altor-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.unitappears 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 aparcelhit.bbox: survey-cell bounds[minLng, minLat, maxLng, maxLat]when the lookup row has a geom.geometry: the survey polygon, only when you passgeometry=trueand the row has one.
Search
GET /geocode/search
q(required): the search text.lang(optional, defaulten): which language to rendernameas.limit(optional, default10, 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"import { Geocoder } from "@unmap/geocoding";
const geocoder = new Geocoder({ key: "um_live_..." });
const results = await geocoder.search("Calgary Tower", { limit: 3 });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, fromaddress,street,locality,region,poi. A hard filter. An unknown value is a400rather than being ignored, so a typo tells you instead of quietly searching everything. The energy valueswellandfacilitystill 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 saysregion_not_verified. Naming a province insideqonly 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 frombbox: "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"Structured search
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):CAonly.
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®ion=ON" \
-H "Authorization: Bearer $UNMAP_API_KEY"import { Geocoder } from "@unmap/geocoding";
const geocoder = new Geocoder({ key: "um_live_..." });
const results = await geocoder.structured({
address: "100 Main Street",
city: "London",
region: "ON",
});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:
| Value | Means |
|---|---|
exact | Every component you supplied that the data supports was matched |
partial | Something you supplied did not match, or could not be verified |
fallback | The result is coarser than you asked for: you gave a civic number and this record has none |
unknown | Input 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"const hints = await geocoder.autocomplete("rue sainte-cath", { lang: "fr", limit: 5 });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,epicerieandEPICERIEall resolve toshop.supermarket. Aliases resolve at the API, not in a client library, so they work from plain curl.near(lng,lat) orbbox(minlon,minlat,maxlon,maxlat): exactly one is required. Passing both, or neither, is a400. Abboxanchors 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, default5000, clamped to 50–50000. An explicitradiuswins over the one derived from abbox.limit(optional, default10, clamped to 1–50).lang(optional, defaulten): which language to rendernameas.
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"const pharmacies = await geocoder.nearby("pharmacy", { near: [-114.07, 51.05], limit: 3 });
// Same query in French, and two categories at once
const shops = await geocoder.nearby(["pharmacie", "depanneur"], { near: [-114.07, 51.05] });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.
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.
| Group | Ids |
|---|---|
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, defaulten).limit(optional, default5, clamped to 1–50).
curl "https://api.unmap.dev/geocode/reverse?lon=-68.517&lat=63.7467" \
-H "Authorization: Bearer $UNMAP_API_KEY"const here = await geocoder.reverse(-68.517, 63.7467);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 a400. More than 100 is a413payload_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}'const results = await geocoder.batch(["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=en→name: "Montreal",names: {"en":"Montreal","fr":"Montréal","iu":"ᒧᕆᐊᓪ"}q=Montreal&lang=fr→name: "Montréal", samenamesq=Iqaluit&lang=iu→name: "ᐃᖃᓗᐃᑦ"
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.
Legal land
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.
| System | Provinces | Parser | Lookup | Notes |
|---|---|---|---|---|
| DLS LSD / qtr / sec / twp | AB, SK, MB (+ BC Peace) | yes | yes, when the trimmed dump is loaded | AB ATS is OGL-AB. SK / ON / MB UNVERIFIED |
| NTS | BC, YT / NT context | yes | yes, when the dump is loaded | Tiles are survey.nts |
| FPS | NT, NU, YT, offshore | yes | no | fps_unit_lookup will not fit the container |
| ON con / lot | ON | yes | no | Licence UNVERIFIED |
| River lots | prairie river lots | yes | no | |
| Quebec / Atlantic | n/a | no | no | Not in the source tables; do not invent |
| Civic | CA | yes | civic corpus | |
| Rural route / general delivery | CA | yes | civic corpus, on the community | Delivery 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 | |
|---|---|
| Intended | Alberta (AER ST37 / ST102) |
| SK / BC | UNVERIFIED |
| Manitoba | Held |
| Petrinex production | Held |
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 Okotoksresolves to Okotoks, not to a point on the route.RR 2exists 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 a400saying to add the community.parseRuralRoutefrom@unmap/geocodingreturns 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-W5without it; coordinates come back only afterpipeline/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.