Skip to content

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.json is 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 at api.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 a Link header, rel="api-catalog".
curl https://api.unmap.dev/openapi.json | jq '.paths | keys'
curl https://unmap.dev/.well-known/api-catalog

The 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}'

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. /route takes them as one comma-separated pair per point, while /geocode/reverse and /isochrone take lon and lat as 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 Tower must go over the wire as Calgary%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 400 still has a JSON body, so read it; the message names the parameter at fault.
  • Honour Retry-After. A 429 carries 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 is public, 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