Calling the API directly
Call any Unmap API directly over HTTPS at https://api.unmap.dev. The @unmap/* packages attach
your key, format requests and parse responses, but you can use curl, Python or an HTTP client
in any language. Each endpoint defines its HTTP method, parameters and response format.
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. Unmap's own paths have no version prefix;
Optimize's Google-compatible paths also accept /v1/projects/....
Read-only data endpoints
usually use GET with query parameters. Batch geocoding, travel-time matrices, map matching,
stop-order optimization, fleet optimization, feedback and uploads use POST; deleting an overlay uses DELETE.
/route and /isochrone also accept POST with a JSON body, including avoid_areas polygons.
The endpoint reference lists every method and request shape.
The key travels as Authorization: Bearer <key>, X-API-Key: <key>, X-Goog-Api-Key: <key>, or
?key=<key>, checked in that order.
Generated style URLs include query keys so MapLibre can fetch tiles and assets automatically;
custom MapLibre integrations can set headers with transformRequest (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. Most failures return JSON with an error message and a code.
Optimize errors use a structured error object in Google's format.
A malformed tile, terrain, contour, glyph, or sprite request answers in plain text instead. An unconfigured terrain or contour archive is JSON 404 not_found. A style whose active basemap release is missing is plain text too:
503 basemap release is misconfigured (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", "atlas", "swiss", "winter"],
"auth": "API key required via 'Authorization: Bearer um_...', 'X-API-Key', 'X-Goog-Api-Key', or '?key=', checked in that order",
"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 the gateway data and agent endpoints: 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, and a
422 outside_coverage apart from a 422 no_route.
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.13.0/dist/maplibre-gl.css"
rel="stylesheet"
integrity="sha384-yFNc3bs28S14jOJo1ZO9SnLiAmtyY2a3H9be8GkLMpDGlO5r1zZNP8fYu+fOCE/w"
crossorigin="anonymous"
/>
<div id="map" style="height: 400px"></div>
<script type="module">
import { Map } from "https://unpkg.com/maplibre-gl@6.13.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 an import statement has no attribute to carry one. 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:
- Coordinate pairs are
lng,lat, longitude first. A point is one comma-separated pair:fromandtoon/route,nearon/geocode/reverse,/isochroneand the other endpoints that take an anchor. The clients take[lng, lat]tuples and write the HTTP parameters for you./timezoneuses separatelatandlonparameters;/identifyusesat=lng,lat. JSON request bodies use arrays, such as"near": [lng, lat]onPOST /isochrone. - 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 unless its code isbilling_required. 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. A502has none: the client retries that status on its own backoff. - 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 that has bytes ispublic, max-age=86400. An empty204does not send that header. 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.