Calling the API directly
The @unmap/* packages are a convenience, not a requirement. Everything they do is a GET
request to https://api.unmap.dev with your key attached, and everything they return is the
gateway's own JSON, untouched. If your application is not JavaScript, if you would rather not add
a dependency, or if you already have an HTTP client you trust, you can skip the packages entirely
and lose nothing: there is no endpoint the SDK reaches that plain HTTPS cannot.
There is a third path between these two. If you want the packages but not a build step, unmap.dev serves the whole SDK as one script tag; the Quickstart has it.
This page is the whole of what you need to know to do that: (1) the shape of the contract, (2)
the same geocode and route as curl, fetch, and Python, (3) rendering a map with a stock
MapLibre GL JS and nothing else, and (4) what the packages were doing for you that is now your
job. The parameters and response bodies of each endpoint are not repeated here; they live on
the Maps API, Layers (including
GET /identify and GET /data/query), the Geocoding API, the
Routing API, the Transit API, and the
Elevation API, and those pages already describe the HTTP contract rather than
the SDK. Point-in-polygon is GET /identify / unmap.identify(). Bbox listing against a
queryable catalog layer is GET /data/query / unmap.data.query().
The contract in one paragraph
The base URL is https://api.unmap.dev, with no version prefix. Almost every endpoint is a
GET with query parameters and no request body. The exceptions are POST /byod and
DELETE /byod/{id} (a private overlay; see Bring your own data) and
POST /geocode/batch (up to 100 searches in one JSON body; see
Batch geocoding).
The key travels as Authorization: Bearer <key>,
X-API-Key: <key>, or ?key=<key>; MapLibre fetches tiles, glyphs, and sprites itself and can
only send the last form (see Authentication). Geocoding, routing, and style responses are
application/json; tiles are vector-tile bytes, glyphs and sprites are the binary and image
formats MapLibre expects. A failure is a JSON body with an error message and, on
authentication and plan failures, a code, except on tile, terrain, contour, glyph, and sprite
paths, which answer in plain text (see Errors).
The root endpoint needs no key, and describes the rest:
curl https://api.unmap.dev/{
"name": "unmap gateway",
"endpoints": ["/tiles/{z}/{x}/{y}", "/styles/{style}.json", "/geocode/search", /* … */],
"styles": ["base", "muted", "outdoor", "blueprint", "blush", "orchid", "canopy", "lagoon", "tropic", "sunset", "bold", "pastel"],
"auth": "API key required via 'Authorization: Bearer um_...', 'X-API-Key', or '?key='",
"docs": "https://unmap.dev/docs",
"openapi": "https://api.unmap.dev/openapi.json",
"apiCatalog": "https://api.unmap.dev/.well-known/api-catalog",
/* plus authMd, mcp, a2a, agentCard and protectedResource, and the data attribution */
}Machine-readable description
If you are wiring up a tool, a code generator, or an agent rather than reading, two more unauthenticated documents describe the same contract in formats built for that:
https://api.unmap.dev/openapi.jsonis an OpenAPI 3.1 description of every endpoint on this page and the three that follow: parameters, the three key forms as security schemes, and the response and error bodies. Generate a client from it, or hand it to an agent.https://unmap.dev/.well-known/api-catalog(also atapi.unmap.dev) is an RFC 9727 API catalog: a linkset that names the API, points at the OpenAPI document, and points back at these docs in English and French. Both homepages and the API root advertise it with aLinkheader,rel="api-catalog".
curl https://api.unmap.dev/openapi.json | jq '.paths | keys'
curl https://unmap.dev/.well-known/api-catalogThe root response links a few more unauthenticated documents in the same spirit: authMd
(/auth.md, how an agent obtains a credential), protectedResource (RFC 9728 metadata),
agentCard and mcpServerCard.
The OpenAPI document covers the API-key data plane only. The account API behind the dashboard authenticates with a browser session and is not in it.
The same two requests, three ways
Geocode a query, then drive from the answer. The three tabs are one program: pick the language
you work in. fetch is all @unmap/geocoding and @unmap/routing use underneath, and the only
things the packages add are URL building, a thrown error on a non-2xx status, and TypeScript
types for the response, so the fetch tab is the client library written out by hand, and it
runs unchanged in Node, Deno, Bun, Workers, or a browser. Put your key in an environment variable
and every tab runs as written.
export UNMAP_API_KEY=um_live_...
# Forward geocode. The response is a JSON array, so pipe it to jq to read it.
curl "https://api.unmap.dev/geocode/search?q=Calgary%20Tower&limit=1" \
-H "Authorization: Bearer $UNMAP_API_KEY" | jq '.[0] | {name, lng, lat}'
# Drive between two points. Coordinates are lng,lat, and the comma needs no encoding.
curl "https://api.unmap.dev/route?from=-114.0719,51.0447&to=-113.9871,51.0899&mode=auto" \
-H "Authorization: Bearer $UNMAP_API_KEY" | jq '{distanceMeters, durationSeconds}'const key = "um_live_...";
const base = "https://api.unmap.dev";
async function unmapGet<T>(path: string, params: Record<string, string>): Promise<T> {
const url = new URL(path, base);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (!res.ok) {
const body = (await res.json()) as { error: string; code?: string };
throw new Error(`${res.status} ${body.code ?? ""} ${body.error}`.trim());
}
return (await res.json()) as T;
}
type Place = { id: string; name: string; layer: string; lng: number; lat: number };
type Route = {
distanceMeters: number;
durationSeconds: number;
geometry: { type: "LineString"; coordinates: [number, number][] };
};
const [tower] = await unmapGet<Place[]>("/geocode/search", { q: "Calgary Tower", limit: "1" });
const route = await unmapGet<Route>("/route", {
from: `${tower.lng},${tower.lat}`,
to: "-113.9871,51.0899",
mode: "auto",
});import os
import requests
BASE = "https://api.unmap.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['UNMAP_API_KEY']}"}
places = requests.get(f"{BASE}/geocode/search",
params={"q": "Calgary Tower", "limit": 1},
headers=HEADERS, timeout=10)
places.raise_for_status()
tower = places.json()[0]
route = requests.get(f"{BASE}/route",
params={"from": f"{tower['lng']},{tower['lat']}",
"to": "-113.9871,51.0899", "mode": "auto"},
headers=HEADERS, timeout=10)
route.raise_for_status()
print(route.json()["distanceMeters"], route.json()["durationSeconds"])The first call prints the name and coordinates of the top hit; the second prints a road distance
in metres and a duration in seconds. Every other endpoint follows the same pattern: a path, a few
query parameters, a header. The Geocoding API and
Routing API pages carry a curl and a fetch line for each one.
Two details in those tabs are worth carrying into your own code. First, let the library encode
the query: URL and URLSearchParams in the fetch tab, params= in the Python tab. That is
what makes a query like rue Sainte-Catherine, or one written in syllabics, arrive intact.
Reading the body on failure is worth the extra line too: the message names the parameter at
fault, and the code tells a 429 for the rate limit apart from a 429 for a plan quota.
Second, set a timeout, as the Python tab does. Geocoding and /route answer in a few hundred
milliseconds, but an isochrone is several costing passes inside the routing container and
measured around 700 ms warm on 2026-09-02, so a client that has no timeout at all will one day
wait on it.
Rendering a map with a stock MapLibre
A map needs no @unmap code either. The style endpoint returns a complete MapLibre style
document whose tile, glyph, and sprite URLs already point back at the gateway with your key in
their query strings, so a plain MapLibre GL JS pointed at that one URL discovers everything else
itself. This is the whole page, with MapLibre loaded from a CDN and no build step:
<link
href="https://unpkg.com/maplibre-gl@6.7.0/dist/maplibre-gl.css"
rel="stylesheet"
integrity="sha384-Q5Blg3vUVAlUKqIPJYz7wGnz40Vwrx4pVuFVicI73+8c/26Zr5hhckfuIiIUflLE"
crossorigin="anonymous"
/>
<div id="map" style="height: 400px"></div>
<script type="module">
import { Map } from "https://unpkg.com/maplibre-gl@6.7.0/dist/maplibre-gl.mjs";
new Map({
container: "map",
style: "https://api.unmap.dev/styles/base.json?mode=light&key=um_live_...",
center: [-114.0719, 51.0447],
zoom: 11,
});
</script>MapLibre 6 is ESM-only. There is no UMD build and no maplibregl global, so the script is a
module and the pieces you want are imported by name. The stylesheet still carries an integrity
hash; the module cannot, because maplibre-gl.mjs pulls a sibling maplibre-gl-shared.mjs that
the browser resolves on its own and Subresource Integrity does not reach. That is the second
reason the CDN here is a demonstration and not a recommendation: in an application, install
maplibre-gl and serve it from your own site, as the Quickstart does.
The key must be in the style URL as ?key=, because that is the one form the tile, glyph, and
sprite requests MapLibre fires can carry, and the gateway copies whatever key fetched the style
into the URLs it writes. Everything the SDK's style, mode, lang, and theme options do is
a query parameter on this same URL, so /styles/outdoor.json?mode=dark&lang=fr is a dark
outdoor map with French labels and ?theme=<code> applies a theme from /create. The
full list of styles and parameters is on the Maps API.
The same URL works anywhere a MapLibre style is accepted: MapLibre Native on iOS and Android, the MapLibre Leaflet plugin, or any renderer that reads a style specification version 8 document. You are installing our cartography, not our code.
Calling from a browser
The API answers cross-origin requests from any origin: GET, HEAD, OPTIONS, and POST (the
last only reaches /mcp, the JSON-RPC endpoint; everything on this page is a GET), with the
Authorization and X-API-Key headers permitted. Both header forms therefore work from a
browser fetch, but ?key= is the better choice there. A custom header turns each request into a
preflighted one, an extra OPTIONS round trip before the GET, while a query parameter is a
simple request that goes straight through, and it is the mechanism the map's own tile requests
already use.
What a browser sends that a server does not is an Origin header, and the gateway reads it. A
request from a dev origin is counted but never billed: localhost, 127.0.0.1, [::1], any
*.localhost host, and any *.pages.dev or *.workers.dev preview, provided the key allows
that origin. A request from any other origin is billed normally. If the key carries an
allowed-origins list, anything not on it is a 403 with the code origin_not_allowed, including
a request that sends no Origin at all. A server-side call carries no Origin and always counts
as production traffic, whichever key it uses, so give your backend a key with no allowed
origins. The exact rules are on Plans & Limits.
What the packages did for you
Going without them means picking up a few responsibilities the clients handled. None is hard; they are listed so nothing is a surprise:
- Coordinates are
lng,lat, longitude first, everywhere./routetakes them as one comma-separated pair per point, while/geocode/reverseand/isochronetakelonandlatas two separate parameters. The clients hide that difference behind[lng, lat]tuples; over HTTP you spell each one the way its page shows. - Encode the query.
q=Calgary Towermust go over the wire asCalgary%20Tower. Every HTTP library does this when you pass parameters as a map rather than concatenating a string. - Check the status yourself. The clients throw on any non-2xx response. Over HTTP, a
400still has a JSON body, so read it; the message names the parameter at fault. - Honour
Retry-After. A429carries one. Back off for that many seconds instead of retrying immediately; the burst limit is 1,000 requests per 60 seconds per key, per Cloudflare location. - Let the cache headers work. A geocoding response is cached at our edge for 24 hours, a
route or isochrone is
private, max-age=3600(your client may reuse it, a shared cache must not), and a tile ispublic, max-age=86400. A browser respects all of this automatically. A server-side client only does if it has an HTTP cache, and a repeated route request without one is a repeated billable call. - Keep a
um_live_key out of anything you ship to strangers, or list the origins it may be used from so a copied key is useless elsewhere. On the server, an environment variable is enough, and that key should carry no allowed origins at all.
Next steps
- Authentication for the three key forms and how a bad key answers.
- The Maps API, Layers for
GET /identifyand Layers forGET /data/query, the Geocoding API, the Routing API, and the Elevation API for every parameter and response body, with acurlline for each endpoint. - Errors for the status codes and the
codeunion to branch on. - If you do want the packages after all, the Quickstart is the 60-second version of this page.