JS SDK
@dock/js 0.82.0. A MapLibre map on dock tiles, search, reverse, places by category nearby, a building's doors, the door nearest a point, building codes, places by id, changes, door reports, driver confirmations, usage, trip recording, fleet probes and live traffic. One client holds the key. Browser and Node 20+. No runtime dependencies; maplibre-gl is needed only for the map. Try every call without writing code in the playground. Coming from Mapbox or Google Maps: the same calls, side by side. Native apps: iOS SDK, Android SDK.
One script tag
No npm, no bundler: dock.js holds the SDK and maplibre-gl, on window.dock; dock.css holds the styles. Any site may load them. dock.createMap(client, options) uses its own maplibre-gl.
<link href="https://dock.mn/sdk/v1/dock.css" rel="stylesheet">
<script src="https://dock.mn/sdk/v1/dock.js"></script>
<div id="map" style="height: 480px"></div>
<script>
const client = new dock.Client({ key: 'dk_...' });
const map = dock.createMap(client, { container: 'map' });
client.geocode('Зайсан').then(({ results }) => {
map.flyTo({ center: results[0].point, zoom: 17 });
});
</script>map.dock.mn's control
One line: map.dock.mn's sheet (search, categories, the place card, doors with their codes, the route to the door), the buttons top right, the layers panel and the turn banner, on your map. Options: key or client, preset (auto, day, dusk, night, winter), code (a door to open first), q (a search to run first), center, zoom. brand: false hides the dock mark (#brand). It returns a maplibre-gl Map; map.ui holds the sheet's calls.
One line, with no box and no key in the call: put the key on the script tag. The sheet fills the page's #map, or the whole page when there is none.
<link href="https://dock.mn/sdk/v1/dock.css" rel="stylesheet">
<script src="https://dock.mn/sdk/v1/dock.js" data-key="dk_..."></script>
<script>new dock.Map({ ui: 'sheet' });</script>In your own box, with a client you already have:
<div id="map" style="height: 560px"></div>
<script>
const map = new dock.Map({ container: 'map', key: 'dk_...', ui: 'sheet' });
</script>The sheet before the script arrives
dock.js carries maplibre-gl, so the sheet cannot draw before it has loaded; the page itself can. Put the sheet's shell (the search box, the six chips, the three buttons, the dock mark) inside your #map, with dock.css in the head. It paints with your first bytes and new dock.Map({ ui: 'sheet' }) swaps it for the live sheet in one step: the boxes do not move, what was typed stays. The chips and buttons in the shell are inert until then. Set data-theme="night" on the box for a night page.
<div id="map" class="dock-ui" style="height: 100%">
<div id="brand" data-pre aria-hidden="true">dock<span>.</span></div>
<div id="sheet" data-pre class="sheet collapsed" role="dialog" aria-label="Хайлт ба газрууд">
<div class="handle-wrap" id="handle"><div class="handle"></div></div>
<div class="sheet-view" id="v-search">
<div class="searchbar">
<svg viewBox="0 0 24 24"><path d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg>
<input id="q" type="search" aria-label="Хайх" placeholder="Хаяг, нэр эсвэл 5 тэмдэгт код" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="chips" id="chips">
<button type="button" data-cat="food">Хоол</button>
<button type="button" data-cat="shop">Дэлгүүр</button>
<button type="button" data-cat="pharmacy">Эмийн сан</button>
<button type="button" data-cat="bank">Банк</button>
<button type="button" data-cat="fuel">Шатахуун</button>
<button type="button" data-cat="bus">Буудал</button>
</div>
<button id="btn-here" class="here" type="button"><span class="ic"><span class="dot"></span></span><span class="t"><span class="n">Энд байгаа хаяг</span><span class="s">Таны байгаа барилгын код ба хаяг</span></span></button>
</div>
</div>
<div id="tr" class="stack" data-pre style="pointer-events:none" aria-hidden="true">
<button id="btn-layers" class="rb" type="button" aria-label="Давхарга"><svg viewBox="0 0 24 24"><path d="M12 4l-8 4l8 4l8 -4l-8 -4"/><path d="M4 12l8 4l8 -4"/><path d="M4 16l8 4l8 -4"/></svg></button>
<button id="btn-3d" class="rb" type="button" aria-label="3D">3D</button>
<button id="btn-locate" class="rb" type="button" aria-label="Миний байршил"><svg viewBox="0 0 24 24"><path d="M9 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M4 12a8 8 0 1 0 16 0a8 8 0 1 0 -16 0"/><path d="M12 2l0 2"/><path d="M12 20l0 2"/><path d="M20 12l2 0"/><path d="M2 12l2 0"/></svg></button>
</div>
</div>Install
Not on npm yet. Until it is, use the script tag above, or ask your contact for the package file:
npm install maplibre-gl@6 ./dock-js-0.82.0.tgzmaplibre-gl 6.4.1 or later, in 6.x. Every version up to 6.4.0, all of 4 and 5 included, has a critical XSS in its HTML sanitizer (GHSA-jrc7-96c5-q579), so the SDK no longer accepts them. Already on maplibre-gl? Run npm ls maplibre-gl and update if it shows less than 6.4.1. The snippet below is for Vite; see Bundlers for the rest.
A map
<div id="map" style="height: 480px"></div>import * as maplibregl from 'maplibre-gl';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Client, createMap } from '@dock/js';
maplibregl.setWorkerUrl(workerUrl);
const dock = new Client({ key: 'dk_...' });
const map = createMap(maplibregl, dock, { container: 'map' });The map opens on Ulaanbaatar at zoom 15. Buildings show from zoom 13, doors from zoom 15, colored by confidence and grey when confidence is unknown. Attribution comes from the tiles.
| Option | Default | |
|---|---|---|
container | Element or its id. Required. | |
center, zoom | UB_CENTER, 15 | [lon, lat]. |
extrude | false | Raise buildings by height (or floors x 3 m). |
background | paper tone | Background color. |
building, buildingOutline | warm grey | Building fill and outline colors. |
styleId | A style saved in the style editor (st_ and 10 characters). Loaded from the API with your key. Not with style. | |
style | dockStyle(...) | Your own style. Add dockSources and dockLayers to it. |
transformRequest | Runs after the key is added, for your own hosts. |
Any other option goes to the maplibre-gl Map as is.
Bundlers
maplibre-gl 6 starts its worker from a second file, maplibre-gl-worker.mjs, which imports a third, maplibre-gl-shared.mjs. A bundler moves the main file without them and the map stays blank, with one console error: "Worker failed to load". Each setup below swaps the two worker lines of the snippet for its own.
| Setup | What to do | Tested |
|---|---|---|
| Vite | The snippet above. ?worker&url makes Vite bundle the worker and give its URL. | Vite 8 dev and build |
| webpack 5 | The worker as a second entry, below. | production and development builds |
| esbuild | The worker as a second entry with --splitting, below. The worker shares one chunk with the map. | build |
| No bundler | Drop the workerUrl import and the setWorkerUrl line. The worker loads from next to the main file. | import map |
| Any other bundler | Copy maplibre-gl-worker.mjs and maplibre-gl-shared.mjs from node_modules/maplibre-gl/dist/ next to your page, then the line below. | under webpack 5 and esbuild |
webpack 5, in webpack.config.js:
entry: { main: './main.js', 'maplibre-gl-worker': 'maplibre-gl/dist/maplibre-gl-worker.mjs' },
output: { filename: '[name].js' },maplibregl.setWorkerUrl(__webpack_public_path__ + 'maplibre-gl-worker.js');webpack warns "Critical dependency" three times. It is maplibre's import(url) for worker plugins (importScriptInWorkers, an ES module RTL text plugin): those do not load in a webpack-built worker. dock uses neither. If you do, use the copy setup.
esbuild:
esbuild main.js node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs --bundle --splitting --format=esm --outdir=dist --entry-names=[name]maplibregl.setWorkerUrl(new URL('./maplibre-gl-worker.js', import.meta.url).href);Any other bundler, after copying the two files:
maplibregl.setWorkerUrl(new URL('maplibre-gl-worker.mjs', document.baseURI).href);Each setup is rebuilt and opened in Chrome by make bundlers (maplibre-gl 6.11.2, vite 8.3.2, webpack 5.111.1, esbuild 0.28.2). Without the worker lines, Vite, webpack and esbuild all give the blank map.
On your own basemap
const myStyle = { version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#eee' } }] };
const map = createMap(maplibregl, dock, {
container: 'map',
style: {
...myStyle,
sources: { ...myStyle.sources, ...dockSources(dock.baseUrl) },
layers: [...myStyle.layers, ...dockLayers({ extrude: true })],
},
});The key goes only to requests for the dock API, never to your basemap host or a font server.
Search, reverse, building codes
const { results } = await dock.geocode('Сансар 24', { limit: 5 });
for (const place of results) console.log(place.kind, label(place, 'en'));
const typing = await dock.geocode('Сан', { proximity: [106.9177, 47.9186] });
console.log(typing.results.length);
const streets = await dock.geocode('Сансар', { types: ['street', 'area'] });
console.log(streets.results.length);
const here = await dock.reverse([106.9301, 47.9201], { radius: 50 });
console.log(here.district_en, here.khoroo, here.results.length);
const landmarks = await dock.reverse([106.9301, 47.9201], { radius: 300, types: ['landmark'] });
console.log(landmarks.results.length);
const typed = parseDoorCode('7qsvх 17');
if (typed.ok) {
const door = await dock.door(typed.code, { unit: typed.unit ?? undefined });
console.log(label(door, 'en'), door.entrance.kind, door.point);
} else {
console.log(typed.error);
}
console.log(doorLink('7qsvх', '17'));
const opened = parseDoorLink('https://map.dock.mn/d/7QSVX?u=17');
if (opened?.ok) console.log(opened.code, opened.unit);
console.log(doorQr('7qsvх', '17')?.size);
const qrSvg = doorQrSvg('7qsvх', '17');Points are [lon, lat], as in MapLibre. Building codes accept Cyrillic lookalike keys (К, М, Т, Х, Р, С, В, Н). parseDoorCode(text) reads a typed or pasted code as the API does, with no request: { ok: true, code, unit }, or { ok: false, error } with error 'empty', 'length' (not 5 characters) or 'letters' (a vowel, I, L, O, U, or a sign). Check the field as the person types and show your own words for error. door() refuses such a text the same way, DockError 400 invalid_code, before any request. A code that finds no door still costs a lookup against the daily miss cap.
doorLink(text, unit?) makes the link to share a door: https://map.dock.mn/d/7QSVX?u=17, for a share sheet, a message or a QR. null when the text cannot be a code. It makes no request and does not check that a door has the code. parseDoorLink(text) reads the first door link in a text: a pasted message, or the URL a link opened your app with. null when there is none, otherwise the same answer as parseDoorCode. The address field looks a pasted link up as its door alone. map.dock.mn does not serve the door page yet; the link reads in every SDK now.
doorQrSvg(text, unit?, { px, margin }) draws the door's QR as an SVG: the doorLink link inside, black on white, for an <img>, a share sheet or print. A phone camera opens the same link a message would. Each module is px (default 4), with margin light modules around it (default 4, the standard's minimum). doorQr(text, unit?) gives the modules (modules[y][x] is true for dark, size, version) to draw on a canvas yourself. ISO/IEC 18004, error correction level M (15%). Offline, no library needed. null when the text cannot be a code.
While the person types, pass proximity, where they are or the map centre: among names the typed text starts equally well, nearer places come first. The point goes with that request only. It is not kept on the device, the API does not log it, and telemetry never carries it. Offline answers do not read it.
dock.nearby([lon, lat], ['pharmacy'], { radius, limit }) gives the places of those categories near a point, nearest first, each with category and distance_m. A "Хоол" chip is ['restaurant', 'cafe']. radius 1000 m by default, up to 10000; limit 10, up to 20. The categories are in NEARBY_CATEGORIES. Banks, fuel, pharmacies, restaurants, cafes and shops are businesses matched to the state registry only; until one matches, none. Another category, or none, is refused before a request with DockError 400 invalid_category.
doorAt([lon, lat]) is the tap on the map: the address unit at the point, a хашаа first, else the smallest building that holds it. The answer has id, kind (building or khashaa), the official address and official_line, and code, which is null while the unit has none. No unit at the point, or a restricted site, is DockError 404 not_found. It needs door:read.
dock.entrances(buildingId) lists the numbered doors of a building, for its card, only where the building's owner or its СӨХ has approved them. Otherwise entrances_approved is false and note says "entrances: not approved for this building"; the building's code and official form are on its door answer. Each listed door has letter, kind, point, its level in official_status (matched бүртгэлтэй, partial хэсэгчилсэн, none албан бус) and code. entrances needs geocode:read. 404 not_found for an id that is no current building, 410 retired for a demolished one.
dock.entrancesNear([lon, lat]) lists the approved doors of the building nearest a point, for a long-press on the map. Nearest first, each as entrances lists it, plus building and distance_m. radius in metres, 50 by default, up to 1000; limit 1 by default, up to 20. No approved door that close: results is empty. entrancesNear needs geocode:read.
dock.venue(id) answers a building with an indoor map: its name, outline and floors, lowest first (ordinal 0 ground, -1 basement). Take the id from indoor.venue_id on a geocode row. dock.venueLevel(id, 1) gives one floor as GeoJSON: units, openings and amenities, each with source and confidence. dock.venueSpot(id, unitId) gives what a printed plate says (floor, name or number, a point) and the link its QR holds. venue, venueLevel and venueSpot need door:read. 404 not_found for an unknown venue, floor or unit.
dock.venueRoute(id, [lon, lat], unitId, { fromLevel }) gives the walk inside to one unit, floor by floor: legs (a line per floor, ending in stairs, escalator, elevator or arrive), steps in Mongolian with the floor on each, and distance_m, duration_s. dock.venueDirections(id, [lon, lat], unitId, { mode }) gives the road to the right street entrance and then that walk, in one answer (road, indoor). venueRoute needs door:read, venueDirections needs route:read. Where no corridor is drawn the answer is 422 no_route (no_entrance when no street entrance is drawn); nothing is invented.
venuesNear([lon, lat]) lists the buildings with an indoor map close to a point, nearest first, each with its venue id and distance (within 200 m, at most 10).
dock.directory(buildingId) lists the organizations registered in a building, by floor, for "Энд байгаа байгууллагууд" on an office building or mall card. Only organizations matched to an official registration are listed. Each has name, floor, unit, category, source, source_date; a floor or unit the register does not give is null. The answer carries the building's code, null until one is minted. directory needs geocode:read. An id that is not a current building is 404 not_found, a demolished one 410 retired.
Always show places with label(place, 'mn' | 'en'). It picks the spoken form, then the name, then the long form. Anything without a matched official address ends in "(unofficial)", as the API requires. It never shows an internal id.
Address field (checkout)
One input for a checkout or a pickup form. attachSuggest(input, dock, { onPick }) asks from the third character, 200 ms after the last key (SUGGEST_MIN_CHARS, SUGGEST_DELAY_MS). A newer key cancels the request in flight, so a late answer never replaces the list. Text that reads as a building code (parseDoorCode) is looked up with door first; a code with no door falls through to geocode. Up and down arrows move, Enter or a click chooses, Escape closes.
<input id="address" placeholder="Сансар 24 эсвэл 7QSVX">
<script type="module">
import { Client, attachSuggest } from '@dock/js';
const dock = new Client({ key: 'dk_...' });
attachSuggest(document.getElementById('address'), dock, {
proximity: () => map.getCenter().toArray(),
onPick: (place) => { order.door_code = place.code; order.point = place.point; },
});
</script>Each option reads label(place, lang), so a place without a matched official address says "(unofficial)". The chosen geocode result goes to pick (offline search, and telemetry if the user opted in), then to onPick; a door from a code goes straight to onPick. Options: limit (default 5), proximity ([lon, lat] or a function asked on each request), types (only these kinds, as on geocode: ['street'] for a street picker; a code or a door link is looked up as a door only when types has door; another kind is refused with 400 invalid_types before anything is sent), lang ('mn' or 'en'), list (your own element for the options), onError (a DockError; by default the list just empties). The input gets role="combobox" and the list role="listbox". No styles ship: the list has class dock-suggest, the no-match line dock-suggest-empty, the active option aria-selected="true". destroy() removes the listeners. Suggester is the same logic with no DOM, for your own list or a framework component: new Suggester(dock, options, show, fail), then input(text) on each change.
Route
const trip = await dock.route([106.9177, 47.9186], '7QSVX', { mode: 'driving', via: [[106.924, 47.919]] });
console.log(trip.distance_m, trip.duration_s, trip.to.gap_m, label(trip.to.door, 'en'));
const routeMap = createMap(maplibregl, dock, { container: 'map', zoom: 13 });
routeMap.on('load', () => {
routeMap.addSource(ROUTE_SOURCE, routeSource(trip));
for (const layer of routeLayers()) routeMap.addLayer(layer);
});from and to are each a building code or [lon, lat]. A code of a building or a yard ends at the gate when the map has one, else at the street side: the point of the building or yard outline nearest the street the line ends on, so the walk is the few metres from the street to the fence or wall, not to the middle. to.point is that end; to.unit.street_side is true for a gate, or within about 25 m of the street point. When the walk is over 15 m the last step says "Leave the car and walk 17 m to the gate." ("Машинаа тавиад 17 м алхана уу."), and the arrival names the gate and its side ("Хаалга зүүн талд байна"). mode: driving, walking, cycling. geometry is a GeoJSON line on the road network, ending where the network is nearest the door. The straight walk left from there is to.gap_m metres; it is never drawn as road. Needs route:read, and door:read for a code. duration_s is free-flow. A driving route also has measured (API 0.48.0): duration_s with each road that has a published moving speed (traceRoads, month measured.month) at that speed, roads_m metres of them; null when walking or cycling. With { departAt } (API 0.50.0, a Date or an ISO 8601 string with an offset) measured reads the speeds of its hour band (am_peak, pm_peak, off_peak) and names it in measured.band; without it, all hours and band is null. Coverage: Ulaanbaatar.
{ via } (API 0.67.0) is up to ROUTE_VIA_MAX (10) ends passed on the way, in the order given, each a code or [lon, lat], like Mapbox's waypoints between the first and the last. legs has distance_m and duration_s for each hop, via each via end as a route end, snapped where the line passes it. The line may turn back at a via end. An empty list is none; a longer one, or a code holding ;, is refused before any request with the API's 400 invalid_via. routeSource(trip) makes the route a MapLibre GeoJSON source, credited to the answer's source: the line, then one point per end where the line reaches it, in the order passed, with number (from is 1, then each via, to last), role (from, via, to) and via, its place in the list sent. routeLayers() gives the line and a ring per end. With font, a font stack on your style's glyphs, each ring shows its number; the dock style has no glyphs, so leave font out on it. Add them after the dock layers.
Search control
SearchBox (new SearchBox(client, { lang, onPick })) is the search box on the map: map.addControl(search, 'top-left'). As the person types, dock's search (all three layers: words, meaning, rerank) suggests places near the map's centre first; a building code finds its door. A pick flies the map there, marks it on the SEARCH_SOURCE source and shows its card: the label ("unofficial" unless matched to the official address), the official address, the code. Every target is 44 px.
Turn-by-turn and voice
legs[].steps in a client.route answer (API 0.74.0) give each turn in Mongolian and English. Navigator (new Navigator(route, { onSay, reroute, onRoute })) follows the route fix by fix: navigator.update([lon, lat]) returns the next turn, the metres to it, the metres and seconds left, and the metres off the line. Each turn is said ahead (aheadPhrase: 1000, 500, 300, 200, 100 m) and again at 50 m. More than NAV_OFF_M (40 m) off the line for NAV_OFF_FIXES (3) fixes in a row asks reroute(from) for a new route; offline, it keeps the old one. Within NAV_ARRIVE_M (20 m) of the road's end it says which side the door is on, and the walk when it is far. VoicePlayer (new VoicePlayer(base)) plays the Mongolian phrases (base + phrases.json) one after another, on by default; enabled = false keeps it quiet.
Matrix
const couriers = [[106.9177, 47.9186], [106.9050, 47.9210]];
const times = await dock.matrix(couriers, ['7QSVX', [106.9301, 47.9201]]);
const toFirst = times.durations_s.map((row) => row[0] ?? Infinity);
const nearest = toFirst.indexOf(Math.min(...toFirst));
console.log(nearest, toFirst[nearest], times.distances_m[nearest][0]);matrix(from, to) (API 0.63.0) gives the free-flow time and distance from every end in from to every end in to in one call, for a dispatcher who picks the nearest courier. Each list holds 1 to MATRIX_MAX (10) ends, each a building code or [lon, lat] as in route. durations_s[i][j] and distances_m[i][j] go from from[i] to to[j]; null where no path joins them. from and to come back as route ends, gap_m included. No measured, no departAt, no line: ask route for the one you pick. Closures are avoided as for a route. Needs route:read, and door:read for a code. An empty or longer list, or a code holding ;, is refused before any request with 400 invalid_from or invalid_to. A code that is no door is 404 not_found, named by its place (to[1]). One call, whatever the number of cells. Coverage: Ulaanbaatar.
Isochrone
const zone = await dock.isochrone([106.9177, 47.9186], [10, 20, 30]);
for (const f of zone.features) console.log(f.properties.contour, f.geometry.type);
const map = createMap(maplibregl, dock, { container: 'map', zoom: 12 });
map.on('load', () => {
map.addSource(ISOCHRONE_SOURCE, isochroneSource(zone));
for (const layer of isochroneLayers({ opacity: 0.25 })) map.addLayer(layer);
});isochrone(from, contoursMinutes, { mode? }) (API 0.64.0) gives the area reached from one end within each of 1 to ISOCHRONE_CONTOURS_MAX (4) times, whole minutes from 1 to ISOCHRONE_MINUTES_MAX (60), increasing: a shop's or a depot's delivery zone. from is a building code (from its door) or a point as in route. The answer is GeoJSON: one feature per contour, the largest first, properties.contour in minutes, geometry a Polygon or, where the area is in pieces, a MultiPolygon. isochroneSource(zone) makes it a MapLibre GeoJSON source, the largest zone first and credited to the answer's source; isochroneLayers() gives a fill and an outline for it, drawn in that order, so each nearer zone lies darker on top. Add them after the dock layers. Two zones on one map: give each its own source id. from comes back as a route end, gap_m included. Free-flow speeds, no departAt; closures are avoided as for a route. Needs route:read, and door:read for a code. Other contours are refused before any request with 400 invalid_contours. A point with no road near is 422 no_route. One call, whatever the number of contours. Coverage: Ulaanbaatar.
Map matching
const trip = await dock.matching([[106.9177, 47.9186], [106.92, 47.9188], [106.923, 47.919]], {
timestamps: [1790000000, 1790000030, 1790000060],
});
for (const m of trip.matchings) console.log(m.distance_m, m.duration_s);
console.log(trip.tracepoints.map((t) => (t ? t.matchings_index : null)));
const tripMap = createMap(maplibregl, dock, { container: 'map', zoom: 14 });
tripMap.on('load', () => {
tripMap.addSource(MATCHING_SOURCE, matchingSource(trip));
for (const layer of matchingLayers({ width: 5 })) tripMap.addLayer(layer);
});matching(coordinates, { timestamps?, mode? }) (API 0.65.0) puts a GPS trace on the roads it went along: a courier's trip drawn on the map, where the raw points zigzag. coordinates is 2 to MATCHING_MAX (100) [lon, lat] in the order recorded; timestamps, optional, one whole Unix second per point, never going back; with them two far points seconds apart are a break, not a fast road. The answer has matchings, one per unbroken piece (distance_m, duration_s at free-flow speeds without turn costs, a GeoJSON LineString), and tracepoints, one per point in its order (matchings_index, location, gap_m), null where no road was found or the point is inside a restricted site. matchingSource(trip) makes it a MapLibre GeoJSON source, credited to the answer's source: one line per piece, then one point per tracepoint on a road, with point, its place in the trace sent; null ones are left out. matchingLayers() gives a line and a dot for each matched point. Add them after the dock layers. Closures are not avoided: a trace is where someone went. The trace is neither stored nor logged; to share a trip with the map, use startTrace. Needs route:read. Other counts and timestamps are refused before any request with 400 invalid_coordinates or invalid_timestamps. No road near the trace is 422 no_match; fewer than 2 points outside restricted sites is 422 not_accepted. One call. Coverage: Ulaanbaatar.
Optimized trips
const round = await dock.optimizedTrips([[106.9177, 47.9186], '7QSVX', [106.905, 47.921], [106.925, 47.915]]);
const best = round.trips[0];
// The stops in the order to drive them, each by its place in the list sent.
const order = round.waypoints.map((w, sent) => [w.waypoint_index, sent]).sort((a, b) => a[0] - b[0]).map((p) => p[1]);
console.log(order, best.distance_m, best.duration_s, best.legs.length);
const roundMap = createMap(maplibregl, dock, { container: 'map', zoom: 13 });
roundMap.on('load', () => {
roundMap.addSource(OPTIMIZED_TRIP_SOURCE, optimizedTripSource(round));
for (const layer of optimizedTripLayers()) roundMap.addLayer(layer);
});optimizedTrips(stops, { roundtrip?, mode? }) (API 0.66.0) puts stops in the order with the least time on the road and gives the trip in that order: a courier's round for the day in one call. stops is 2 to OPTIMIZED_TRIPS_MAX (12) ends, each a building code (to its door) or [lon, lat], as in route. The trip starts at the first. roundtrip, true by default, goes back to it; false ends at the last sent. The answer has trips, one trip (distance_m, duration_s at free-flow speeds, a GeoJSON LineString, and legs, one per hop), and waypoints, the stops in the order sent, each a route end with waypoint_index, its place in the trip. Today's closures are avoided. No time at the stops. Needs route:read, and door:read for a code. A short or long list, or a code holding ;, is refused before any request with 400 invalid_coordinates. A stop no road reaches is 422 no_route; 404 not_found names the stop by its place (coordinates[1]: ...). One call. Coverage: Ulaanbaatar. optimizedTripSource(round) makes it a MapLibre GeoJSON source, credited to the answer's source: the trip line, then one point per stop where the trip reaches it, in the order to drive, with number (1 first) and stop, its place in the list sent. optimizedTripLayers() gives the line and a ring per stop. With font, a font stack on your style's glyphs, each ring shows its number; the dock style has no glyphs, so leave font out on it. Add them after the dock layers.
Tilequery
const tapped = await dock.tilequery([106.9177, 47.9186], { radius: 30, limit: 10 });
// Nearest first: the building the point is in, then the entrances within 30 m.
for (const f of tapped.features) console.log(f.properties.tilequery.layer, f.properties.tilequery.distance, f.properties.id);tilequery([lon, lat], { sources?, radius?, limit?, geometry? }) (API 0.68.0) gives the buildings and doors at or near a point, as Mapbox's Tilequery API: what a tap on the map is on. sources is ['building', 'entrance'] by default, or one of them. radius is in metres, 0 to TILEQUERY_RADIUS_MAX_M (1000), sent to the centimetre; without it the API finds only the building the point is inside, and an door needs a radius. limit is 1 to TILEQUERY_LIMIT_MAX (50), 5 by default. geometry (polygon, linestring or point) keeps one type. The answer is GeoJSON, nearest first: each feature is a Point, the part of it nearest the point sent, with its tile fields (a building's name_mn, type, levels; a door's kind, letter) and tilequery: distance in metres (0 inside), geometry, layer. The same rows as the tiles, no building codes: door takes a code. Needs tiles:read; counts against rate_limit and daily_quota, not the tile budgets. Anything the API would refuse is refused before any request with its error: 404 not_found for a source, 400 invalid_point, invalid_radius, invalid_limit. Coverage: Ulaanbaatar.
Road closures
const closures = await dock.closures();
const avoid = excludePolygons(closures, '2026-10-05');
console.log(closures.ids_sha256, avoid.ids, avoid.rings.length);
console.log(closureAt(avoid, [106.9303, 47.9202]) ?? 'no closure at the door');Closures partners reported (flood, washout, bridge down, snow, mud, works), every one not over today, each with the ring around it. No partner name, no note. Confidence 0.5: one partner's word. Keep the answer on the phone: it is the same shape as an offline pack's closures block. excludePolygons(closures, day) works offline: it keeps the items active on the route day (from <= day, and day <= to when to is set; default today, Ulaanbaatar) and gives their rings for your own Valhalla's exclude_polygons. Over Valhalla's limits for one request (10,000 m of ring, 100 vertices) it throws ClosuresOverLimitError: refuse the route, never drop a closure. A day before as_of throws RangeError: fetch again. ids_sha256 changes when a closure is added or lifted. closureAt(avoid, point) names the closure whose ring holds a route end, or gives null: Valhalla drops every road inside a ring, so an end there has no road and your Valhalla answers "No path could be found for input". Check both ends first. GET /v1/route avoids today's closures itself, names them in closures.avoided, and refuses an end inside one with 422 end_in_closure. route:read.
Report a closure
const retryKey = crypto.randomUUID();
const report = await dock.reportClosure(
{ geometry: { type: 'Point', coordinates: [106.9303, 47.9202] }, kind: 'flood', to: '2026-10-09', note: 'Ford under 1 m of water' },
{ idempotencyKey: retryKey },
);
console.log(report.id, report.properties.from, report.properties.to);
const mine = await dock.closure(report.id);
console.log(mine.properties.note);
const open = await dock.liftClosure(report.id);
console.log(open.properties.lifted);reportClosure(report) tells dock that a stretch or a crossing cannot be driven. kind is one of flood, washout, bridge_out, snow, mud, works, other; geometry a Point on the stretch, not on a junction, or a LineString of at most 3000 m. from is the first closed day, today by default; to the last, absent or null until lifted. The report is kept forever, never edited or deleted. Routes on dock avoid it from its from day, and closures() serves it to every partner without your name or note. Confidence 0.5: one partner's word. Give each report its own idempotencyKey and keep it until the answer comes: a retry after a timeout with the same key and the same report gets the first report back, nothing is stored twice. The same key with another report is 422 idempotency_key_reused. A key the API would refuse (1 to 64 of letters, digits and ._:-, as IDEMPOTENCY_KEY_RE) rejects with DockErrorbad_idempotency_key before any request. A note with a run of 6 digits is 400 note_has_phone: no phone numbers. Inside a restricted site, 400 restricted. closure(id) reads one of your reports back with its note: your export. liftClosure(id, { on }) opens the road again from on, today by default, never a later day (400 lift_in_future); the report stays, with lifted set once. Other organizations' ids are 404 not_found. All three need closures:write.
Pack files, offline
const enc = (s) => new TextEncoder().encode(s);
const manifest = { files: [
{ name: 'pois.geojson', bytes: 2, sha256: '44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' },
{ name: 'water.geojson', bytes: 265463, sha256: 'e9973b42c4b0e6827348e501acd798a1fff25206f4b67708b5f06179e3f6c199' },
] };
// In an app: JSON.parse of the pack manifest.json, and the folder the pack was downloaded to
const folder = { 'pois.geojson': enc('{}') };
// In Node: (name) => readFile(join(dir, name)).catch(() => null)
const check = await checkPack(manifest, async (name) => folder[name] ?? null);
for (const f of check.files) console.log(f.name, f.status);
console.log('ok', check.ok, 'bad', check.bad.join(' '));manifest is the pack's manifest.json; only its files rows are read: the name, size and sha256 of each file in the pack folder (tiles.tar, crossings.geojson, pois.geojson, basemap.pmtiles, wetlands.geojson, passes.geojson, sand.geojson, water.geojson in the current pack). checkPack(manifest, read) asks read for each listed file by name, in manifest order, and checks its size, then its sha256 (Web Crypto). status is ok, missing (read gave null), bytes (another size, as a stopped download; not hashed) or sha256 (same size, other bytes). ok is true only when every listed file is ok. bad names the rest: download them again, and use no file from the pack until ok. Files the manifest does not list are not read. A manifest with no files, a name that is not a plain file name (a folder, .., a leading dot), a name listed twice, a bytes that is not a whole number from 0 or a sha256 that is not 64 lower case hex throws RangeError before any file is read, so a manifest from the network cannot name a file outside the pack folder. An error read throws comes through. read gives a whole file, so one file at a time is held in memory; the base map is 113 MB. In a browser, read from the Cache API or IndexedDB. The current pack, 8 files and 180 MB, checks in 0.2 s in Node 24 on an M4 Mac mini. No network, no key.
Route on a pack, offline
const manifest = {
id: 'mongolia-260927-416081b1', bbox: [87.7845, 41.03732, 120.01834, 52.25102],
request: { costing: 'auto', costing_options: { auto: { use_tracks: 0.5 } } }, confidence: 'osm_only',
attribution: '© OpenStreetMap contributors (ODbL 1.0, openstreetmap.org/copyright). Track scores © dock.', note: 'route cost is from track scores, not an ETA',
closures: { method: 'dock_steppe.closures v1', source: 'partner reports', as_of: '2026-10-03', current: 1, ids_sha256: null,
buffer_m: 15, rule: '', active_when: 'from <= route day, and route day <= to when to is set',
valhalla_limits: { perimeter_m: 10000, vertices: 100 },
items: [{ id: 'cl-000002', kind: 'washout', from: '2026-10-04', to: null, confidence: 0.5, perimeter_m: 890, vertices: 5,
ring: [[104.499, 47.499], [104.501, 47.499], [104.501, 47.501], [104.499, 47.501], [104.499, 47.499]] }] },
};
// In an app: JSON.parse of the pack manifest.json
const r = packRoute(manifest, [[106.9176, 47.9187], [102.833, 47.196]], { day: '2026-10-04' });
// In an app: POST r.body to /route on your own Valhalla, the pack tiles.tar as mjolnir.tile_extract
console.log(r.closures.join(' '), r.body.exclude_polygons.length, r.body.costing);
console.log(r.scored ? 'scored' : r.trace_note);
try {
packRoute(manifest, [[106.9176, 47.9187], [104.5, 47.5]], { day: '2026-10-04' });
} catch (e) {
if (e instanceof EndInClosureError) console.log('refused', e.stop, e.closure);
}manifest is the pack's manifest.json. packRoute(manifest, stops, { day, closures }) builds the request for a route on the pack through stops (2 or more [lon, lat]). body is what to POST to /route on your own Valhalla, with the pack's tiles.tar as mjolnir.tile_extract. It carries the manifest's request (costing auto, costing_options.auto.use_tracks 0.5 today), the rings of the closures active on the route day as exclude_polygons (day, default today in Ulaanbaatar), and units km. The closures come from the pack's closures block, or from closures, a newer GET /v1/closures answer. closures in the answer names the ones avoided: show them with the route. A stop outside the pack's bbox throws RangeError. A stop inside an avoided ring throws EndInClosureError (stop, closure): Valhalla has no road there; refuse the route, as GET /v1/route does. Over Valhalla's ring limits it throws ClosuresOverLimitError; a day before the closures' as_of throws RangeError, as excludePolygons. Show attribution with every route. scored is false when the pack has no trace scores (confidence osm_only, the current pack): say trace_note (trace_note_mn in Mongolian) to the driver. It is the pack's own sentence, confidence_note and confidence_note_mn in its manifest (offline v10 and later), when it has both; else the SDK's PACK_TRACE_NOTE. confidence_note (confidence_note_mn) in the answer says what confidence means on any pack: the manifest's, else the SDK's when not scored, else null. On the current pack the body for Ulaanbaatar to Kharkhorin routes 359.8 km in pyvalhalla 3.9.0, as the pack's own check; with a test closure on the middle of that route, the same call goes around it, 690.3 km. Check the pack files first (above). No network, no key.
River crossings, offline
const crossings = {
type: 'FeatureCollection', match_m: 5, note: 'From OSM tags, not field checked.', note_mn: 'OSM-ийн шошгоос, газар дээр шалгаагүй.',
flow: { attribution: 'River flow: GEOGLOWS ECMWF Streamflow Service v2, CC BY 4.0' },
features: [{ type: 'Feature', geometry: { type: 'Point', coordinates: [101.95425, 46.77663] }, properties: {
kind: 'ford', waterway: 'river', name: 'Улаан гол', name_en: 'Ulaan gol', intermittent: false,
flow: { river_id: 320103663, normal_m3s: [0.59, 0.42, 0.63, 0.88, 1.17, 1.37, 2.19, 2.3, 1.57, 1.14, 0.94, 0.8],
high_months: [7, 8], reach: 'agrees', confidence: 0.4 } } }],
};
const line = [[101.94, 46.775], [101.95425, 46.77663], [101.96, 46.78]];
const { warnings, flow_attribution } = crossingsOnRoute(crossings, line, 7);
for (const w of warnings) console.log(w.kind, w.name, w.high_water, w.month_m3s, w.along_m);
console.log(flow_attribution);For a route on your own Valhalla with an offline steppe pack. crossings is the pack's crossings.geojson (manifest files[1]); check its sha256 against the manifest before use, as for the tiles. crossingsOnRoute(crossings, line, month?) gives the fords (kind: 'ford') and the crossings where OSM has no bridge or ford (kind: 'none') that the route line passes within match_m (5 m), in route order. along_m is metres along the route to its nearest point, off_m metres from it. On a river, high_water is true when month (1 to 12, default this month in Ulaanbaatar) is one of high_months, and month_m3s is that month's 1991 to 2020 normal. On a reach marked doubtful (the model reach is 10 times below its river's), and on any reach at confidence 0.1 (LOW_FLOW_CONFIDENCE) or under, month_m3s is null: show the months, not the number. snap says why: none_fits, no model reach follows the river there; main_stem, the channel follows only a small reach and the river's main stem is used. The steppe dashboard does the same. Streams and canals have no flow: high_water is null. On a delta channel, where a river splits into a lake and no model reach says what share a channel carries, the flow is null and delta gives lake, lake_osm and river: say "delta channel, flow unknown". delta is null elsewhere and in packs built before the rule. lake_name_osm is the lake's OSM text when lake is folded to one script, else null; show lake. In the current pack 0 of 3 delta crossings are folded. Packs before national crossings v11 lack it: null. An unnamed river or stream whose two ends lie on one named river is a channel of it: channel_of gives name, name_en and river_way_id; say "unnamed channel of Улаан гол". From the OSM drawing, not checked on the ground. channel_of is null elsewhere and in packs built before that rule. Another unnamed river or stream whose water reaches one named river downstream, by the direction OSM draws the ways, has flows_into: name, name_en, river_way_id and ways, the fewest unnamed ways to that river; say "unnamed, flows into Орхон гол". From the OSM drawing, not checked on the ground; a way drawn the wrong way round gives a wrong river or none. channel_of wins when both are set. flows_into is null elsewhere and in packs built before that rule. road gives the OSM highway and surface tags of the route ways and paved: true when one is paved, false when all are unpaved, null when a way has no surface tag. road is null on a new track alone and in packs built before that rule. A none crossing on a paved road may be a bridge or culvert OSM does not tag; say "paved road, OSM tags no bridge here". The pack's on_paved counts the paved crossings; road_rule and road_rule_mn give the rule. The pack's near_bridge counts the none crossings left out of the pack because a bridge is drawn just off the water; bridge_rule and bridge_rule_mn give the rule. one_point counts the crossings joined into the one beside them because their water way is split into two ways at the road; split_rule and split_rule_mn give the rule. from_seasonal counts the crossings marked intermittent from the OSM seasonal tag alone, with no intermittent tag; intermittent_rule and intermittent_rule_mn give the rule. A ford with ford_node_id set comes from an OSM ford node alone: OSM draws no water line there, so waterway is unknown, with no name and no flow; say "OSM ford, water not drawn". ford_node_id is null on other crossings and in packs built before that rule. The pack's ford_nodes counts them; ford_node_rule and ford_node_rule_mn give the rule. Show note (note_mn in Mongolian) with the warnings: they come from OSM tags, not a field check. Show flow_attribution when it is not null. A line under 2 points or a bad month throws RangeError. No network, no key.
Fuel and ger camps, offline
const pois = {"type":"FeatureCollection","registry_checked":true,
"counts":{"fuel":{"registered":1,"unregistered":1268},"ger_camp":{"registered":0,"unregistered":175}},
"written":1,"rule":"every registered fuel station and ger camp, all Mongolia","rule_mn":"бүртгэлтэй бүх ШТС, гэр бааз",
"source":"OpenStreetMap","note":"From OSM, not field checked. Unregistered places are counted, not shown.",
"note_mn":"OSM-ээс, газар дээр шалгаагүй. Бүртгэлгүй газрыг тоолсон, харуулаагүй.",
"features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[101.9601,46.7801]},"properties":{
"kind":"fuel","osm":"N1","name":"Жишээ ШТС","name_en":"Example fuel station","reg_number":"0000000"}}]};
// In an app: JSON.parse of the pack's pois.geojson
const route = [[101.94, 46.775], [101.95425, 46.77663], [101.96, 46.78]];
const near = poisOnRoute(pois, route);
for (const p of near.pois) console.log(p.kind, p.name, p.reg_number, p.along_m, p.off_m);
console.log(near.registry_checked, near.unlisted.fuel, near.unlisted.ger_camp);pois is the pack's pois.geojson (manifest files[2], pois_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. poisOnRoute(pois, line, nearM?) gives the fuel stations (kind: 'fuel') and ger camps (kind: 'ger_camp') within nearM of the route line, in route order. The default is 2000 m (POI_NEAR_M), as on the steppe route pages. along_m is metres along the route to its nearest point, off_m metres from it, both whole metres. Only places matched to the state registry are in the file, with their registered name and reg_number. The rest are counted by kind for all Mongolia, with no name and no place: unlisted. An empty list is not "no fuel on this route" while unlisted.fuel is above 0. registry_checked false means the registry could not be read when the pack was built: say "not checked", not "none". Show note (note_mn in Mongolian) with the places. Packs built before 2026-10-03 have no pois.geojson. The current pack lists 0 places: 1,268 fuel stations and 175 ger camps counted, none matched to the registry yet. A line under 2 points or a nearM not above 0 throws RangeError. No network, no key.
Wet ground, offline
const wetlands = {"type":"FeatureCollection","written":1,"km":0.4,"areas":1,"counts":{"marsh":{"stretches":1,"km":0.4}},
"match_m":5,"rule":"the part of one OSM car way inside an OSM natural=wetland area",
"rule_mn":"OSM-ийн natural=wetland талбай доторх нэг машин замын хэсэг","source":"OpenStreetMap",
"note":"From OSM tags, not field checked. Soft ground in spring and after rain.",
"note_mn":"OSM-ийн шошгоос, газар дээр шалгаагүй. Хавар, бороо орсны дараа зөөлөн газар.",
"features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[101.94285,46.775326],[101.94855,46.775978]]},
"properties":{"wetland":"marsh","area":"way/1","name":null,"km":0.441,"way_id":2,"new_track":false,
"road":{"highway":["track"],"surface":["ground"],"paved":false}}}]};
// In an app: JSON.parse of the pack's wetlands.geojson
const route = [[101.94, 46.775], [101.95425, 46.77663], [101.96, 46.78]];
const wet = wetlandsOnRoute(wetlands, route);
for (const s of wet.stretches) console.log(s.wetland, s.along_m, s.on_m, s.km);
console.log('wet', wet.on_m);wetlands is the pack's wetlands.geojson (a manifest files row and its wetlands block, wetlands_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. Each stretch is the part of one car way inside an OSM natural=wetland area, cut at the area's edge: soft ground in spring and after rain. wetlandsOnRoute(wetlands, line) gives the stretches the route runs on, in route order. The stretch is cut in pieces of at most match_m (5 m); a piece is on the route when both its ends lie within match_m of the line. A stretch is on the route when those pieces make at least 20 m (WETLAND_MIN_ON_M), or all of the stretch: a route that only crosses a wet way is not on it. along_m is metres along the route to where it meets the stretch, on_m the metres of the stretch it runs on, both whole metres; km is the whole stretch, from the pack. wetland is the OSM kind (marsh, bog, wet_meadow, fen, ...), unknown when the area has no wetland tag; area is the OSM area, as relation/8128712. way_id and road are null on a new track alone. Say "OSM marsh for 1.2 km, soft in spring". Show note (note_mn in Mongolian) with the stretches: from OSM tags, not field checked; an old wetland may be dry now, and one OSM does not draw is not here. An empty list is not "dry all the way". The current pack has 356 stretches, 247.3 km in 86 OSM areas, 208.4 km of it marsh. Packs built before 2026-10-03 have no wetlands.geojson. A line under 2 points throws RangeError. No network, no key.
Mountain passes, offline
const passes = {"type":"FeatureCollection","written":1,"counts":{"mountain_pass":1},"named":1,"with_ele":1,"with_dem":1,"match_m":5,
"rule":"an OSM node tagged mountain_pass=yes or natural=saddle that is a node of a car way",
"rule_mn":"машин замын зангилаа болох, mountain_pass=yes эсвэл natural=saddle шошготой OSM зангилаа","source":"OpenStreetMap",
"note":"From OSM tags, not field checked. A climb, and snow and ice outside summer.",
"note_mn":"OSM-ийн шошгоос, газар дээр шалгаагүй. Өгсүүр зам, зуны бус улиралд цас мөс.",
"features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[101.957125,46.778315]},
"properties":{"kind":"mountain_pass","name":"Улаан даваа","name_en":null,"ele_m":2391,"ele_osm":"2391","node_id":3,"dem_m":2388,
"route_way_ids":[2],"new_track":false,"road":{"highway":["track"],"surface":["ground"],"paved":false}}}],
"dem":{"source":"copernicus-dem-glo30","attribution":"Copernicus DEM GLO-30","tiles":1,"missing":[],
"rule":"the height of the GLO-30 cell the node falls in","rule_mn":"зангилаа байрлах GLO-30 нүдний өндөр",
"note":"A surface model, not field checked.","note_mn":"Гадаргын загвар, газар дээр шалгаагүй."}};
// In an app: JSON.parse of the pack's passes.geojson
const route = [[101.94, 46.775], [101.95425, 46.77663], [101.96, 46.78]];
const over = passesOnRoute(passes, route);
for (const p of over.passes) console.log(p.name ?? p.kind, p.along_m, p.off_m, p.ele_m ?? 'no height in OSM');
console.log('top', over.top_m);
console.log('top DEM', over.top_dem_m, over.dem?.attribution ?? '');passes is the pack's passes.geojson (a manifest files row and its passes block, passes_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. Each pass is an OSM node tagged mountain_pass=yes or natural=saddle that is a node of a car way: a climb, with snow and ice outside summer. passesOnRoute(passes, line) gives the passes the route goes over, in route order: those whose node lies within match_m (5 m) of the line, the rule of the steppe route pages. along_m is metres along the route to its point nearest the pass, whole metres; off_m is metres off the line, to 0.1 m. kind is mountain_pass, or saddle when OSM has only natural=saddle. ele_m is the OSM ele tag read as metres, null when OSM has none or other text, as "7000 ft"; ele_osm is the tag as written. top_m is the highest ele_m of the passes on the route, null when none has a height: not flat, OSM has no number. dem_m is a second height, from the Copernicus GLO-30 DEM: the whole-metre height of the 30 m cell the node falls in, null when the pack has none there. It is never mixed into ele_m. top_dem_m is the highest dem_m on the route, null when none has one. Say "Pass at km 120, 2,391 m (OSM)", or "about 1,911 m (DEM)" when OSM has no number, and show dem.attribution wherever a DEM height shows. Packs with passes v1 have dem null and every dem_m null. road is null on a new track alone. Show note (note_mn in Mongolian) with the passes: from OSM tags, not field checked; a pass OSM does not draw is not here. An empty list is not "flat all the way". The current pack has 74 passes on car ways (68 mountain_pass, 6 saddle), 41 named, 40 with an OSM height, 1,210 to 3,104 m, 74 with a DEM height, 1,214 to 3,202 m. Packs built before 2026-10-03 have no passes.geojson. A line under 2 points throws RangeError. No network, no key.
heights_differ is the pack's flag: true when ele_m and dem_m are both there and more than dem.differ_m (50 m) apart, false within, null when either is missing. true says one of the two is wrong, not which: show both heights, "2,241 m (OSM); about 2,349 m (DEM)", and dem.differ_rule. differ is how many passes on the route have it true, null when the pack has no flag: not checked, not 0. Packs with passes v2 have no flag. In the current pack 1 of 74 is true, 39 false, 34 null.
Sand, offline
const sand = {"type":"FeatureCollection","written":1,"km":0.346,"areas":1,"named":1,"on_paved":0,"match_m":5,
"rule":"the part of one OSM car way inside an OSM natural=sand area; an area also tagged golf is left out",
"rule_mn":"OSM-ийн natural=sand талбай доторх нэг машин замын хэсэг; golf шошготой талбайг тооцохгүй","source":"OpenStreetMap",
"note":"From OSM tags, not field checked. A car can dig in and stop.",
"note_mn":"OSM-ийн шошгоос, газар дээр шалгаагүй. Машин суух аюултай.",
"features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[101.9554,46.777304],[101.95885,46.779326]]},
"properties":{"area":"way/1","name":"Бага элс","km":0.346,"way_id":2,"new_track":false,
"road":{"highway":["track"],"surface":["sand"],"paved":false}}}]};
// In an app: JSON.parse of the pack's sand.geojson
const route = [[101.94, 46.775], [101.95425, 46.77663], [101.96, 46.78]];
const onSand = sandOnRoute(sand, route);
for (const s of onSand.stretches) console.log(s.name ?? s.area, s.along_m, s.on_m, s.km);
console.log('sand', onSand.on_m);sand is the pack's sand.geojson (a manifest files row and its sand block, sand_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. Each stretch is the part of one car way inside an OSM natural=sand area, cut at the area's edge; an area also tagged golf is a bunker and is left out. A car can dig in and stop. sandOnRoute(sand, line) gives the stretches the route runs on, in route order, by the rule of wetlandsOnRoute: the stretch is cut in pieces of at most match_m (5 m); a piece is on the route when both its ends lie within match_m of the line. A stretch is on the route when those pieces make at least 20 m (SAND_MIN_ON_M), or all of the stretch: a route that only crosses a sandy way is not on it. along_m is metres along the route to where it meets the stretch, on_m the metres of the stretch it runs on, both whole metres; km is the whole stretch, from the pack. area is the OSM area, as relation/5149547, name its OSM name or null. way_id and road are null on a new track alone. Some town streets are drawn inside sand: 25 stretches lie on a paved road, 24 of them in Zamyn-Uud; road.paved tells them apart. Say "Sand at km 40, 1.2 km". Show note (note_mn in Mongolian) with the stretches: from OSM tags, not field checked; dunes move, and sand OSM does not draw is not here. An empty list is not "no sand on the way". The current pack has 686 stretches, 669.6 km in 66 OSM areas, 153 in a named one; Bor Khar Els has the most, 70.3 km. Packs built before 2026-10-03 have no sand.geojson. A line under 2 points throws RangeError. No network, no key.
Water points, offline
const water = {"type":"FeatureCollection","written":1,"counts":{"water_well":1},"named":1,"drinkable":{"yes":0,"no":0,"unknown":1},"near_m":2000,
"rule":"an OSM node or way tagged amenity=drinking_water or water_point, man_made=water_well or water_tap, or natural=spring",
"rule_mn":"amenity=drinking_water эсвэл water_point, man_made=water_well эсвэл water_tap, эсвэл natural=spring шошготой OSM зангилаа эсвэл зам",
"near_rule":"within 2000 m of the route line","near_rule_mn":"маршрутын шугамаас 2000 м дотор","source":"OpenStreetMap",
"note":"From OSM tags, not field checked. A well may be dry, broken or locked.",
"note_mn":"OSM-ийн шошгоос, газар дээр шалгаагүй. Худаг ширгэсэн, эвдэрсэн эсвэл цоожтой байж болно.",
"features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[101.951,46.7805]},
"properties":{"kind":"water_well","name":"Хар худаг","name_en":null,"drinkable":null,"drinking_water":null,"pump":null,"osm":"node/4"}}]};
// In an app: JSON.parse of the pack's water.geojson
const route = [[101.94, 46.775], [101.95425, 46.77663], [101.96, 46.78]];
const near = waterOnRoute(water, route);
for (const w of near.water) console.log(w.name ?? w.kind, w.along_m, w.off_m, w.drinkable ?? 'not known');
console.log('drinkable', near.drinkable.yes, 'of', near.water.length);water is the pack's water.geojson (a manifest files row and its water block, water_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. Each point is an OSM node or way tagged amenity=drinking_water or water_point, man_made=water_well or water_tap, or natural=spring; a way counts at a point on its surface. waterOnRoute(water, line) gives the points within near_m of the route line, in route order, as fuel and ger camps: the pack's near_m (2000 m) unless you pass one. along_m is metres along the route to its point nearest the water, off_m metres off the line, both whole metres. kind is the first of drinking_water, water_point, water_tap, water_well, spring the element carries. drinkable is only what OSM says: true for drinking_water=yes or a drinking water point with no such tag, false for drinking_water=no, null when OSM says nothing; it is never guessed from the kind, so null is "not known", not "not drinkable". drinking_water and pump are the OSM tags as written. drinkable on the result counts yes, no and unknown among the points found. Say "Well 1.2 km off the route at km 40". Show note (note_mn in Mongolian) with the points: from OSM tags, not field checked; a well may be dry, broken or locked, and a water point OSM does not draw is not here. An empty list is not "no water on the way". The current pack has 1,064 points (691 wells, 211 springs, 68 drinking water, 48 water points, 46 taps), 200 named; OSM says drinkable for 97, not for 5, nothing for 962; 205 lie south of 46 N. Packs built before 2026-10-03 have no water.geojson. A line under 2 points or a near_m not above 0 throws RangeError. No network, no key.
Names in wetlands.geojson, passes.geojson, sand.geojson, water.geojson and crossings.geojson: OSM sometimes writes a Latin letter inside a Cyrillic word, as "Хонгoрын Элс" with a Latin o. The pack folds such a word to one script when every other letter has a twin that looks the same. name is the folded name, name_osm the OSM text, null when name is the OSM text. Show name. Each answer carries name_osm, and name_rule and name_rule_mn, the pack's rule, null when the pack has none. In the current pack 17 of 686 sand stretches are folded, all "Хонгорын Элс"; 0 in wetlands, passes, water and crossings. Packs before offline v11 (sand), v12 (wetlands, passes, water) and v13 (crossings) have neither: null.
Base map, offline
const basemap = {"method":"dock_steppe.basemap v1","file":"basemap.pmtiles","format":"pmtiles","schema":"openmaptiles",
"schema_version":"3.16.0","tile_type":"mvt","tiles":305104,"minzoom":0,"maxzoom":14,
"bounds":[87.70255,41.50035,120.02014,52.19582],"layers":["boundary","building","landcover","landuse","park","place",
"transportation","transportation_name","water","water_name","waterway"],"excluded_layers":["poi","housenumber"],
"languages":["mn","en"],"osm_time":"2026-09-27T20:23:36Z",
"attribution":"© OpenMapTiles (openmaptiles.org) © OpenStreetMap contributors","rule":"no poi, no housenumber",
"rule_mn":"poi, housenumber давхаргагүй"};
// In an app: JSON.parse of manifest.json in the pack, its basemap block
const style = basemapStyle(basemap, 'https://example.com/packs/mongolia/basemap.pmtiles', { lang: 'en' });
console.log(style.layers.length, style.sources['dock-basemap'].attribution);
// maplibregl.addProtocol('pmtiles', new Protocol().tile); new maplibregl.Map({ container: 'map', style });basemap is the basemap block of the pack's manifest.json; basemap.pmtiles sits next to it (manifest files[3], basemap_sha256 in index.json; check its sha256 as for the tiles). basemapStyle(basemap, url, options?) gives a whole MapLibre style for it: land cover, water and rivers, steppe tracks (brown, dashed; the file has them from zoom 14), roads, rail, country and aimag borders, buildings. The one source reads url as pmtiles:// and carries the block's attribution, so the map shows "© OpenStreetMap contributors"; keep it shown. MapLibre GL JS reads pmtiles:// once the pmtiles package's protocol is added (maplibregl.addProtocol('pmtiles', new Protocol().tile)). The pack has no fonts, so names need glyphs, a font URL of your own ({fontstack}, {range}), and fonts, its font stack (default ['Noto Sans Regular']); then places, water and roads are named in lang ('mn' default, or 'en'), falling back to the OSM name. background sets the background. The file has no business names and no house numbers: a shop name shows only when registered (poisOnRoute), and an address is the official state address. A block with a poi or housenumber layer, or not OpenMapTiles vector PMTiles, a lang the block lacks or an empty url throw RangeError; no block (null, packs built before 2026-10-03) throws TypeError. The current pack: 112.6 MB, 305,104 tiles, zoom 0 to 14, OSM of 2026-09-27. No network, no key.
Report a wrong door
const receipt = await dock.reportDoor({ kind: 'wrong_location', code: '7QSVX', point: [106.9303, 47.9202] });
console.log(receipt.status, receipt.next);A person reviews every report; door data does not change until then. No names or phone numbers in note.
A place again, by id
const { results } = await dock.geocode('Сансар');
const p = await dock.place(results[0].id);
console.log(label(p, 'en'), p.parents?.building);The id from geocode, reverse or changes, never shown to people. parents holds the ids of the building or khashaa, area, street, khoroo and district a door stands in; null for other kinds. A retired id is DockError 410 retired, never reused. geocode:read.
Changes since a time
const copy = new Map();
let page = await dock.changes('1970-01-01T00:00:00Z', { limit: 1000 });
for (;;) {
for (const c of page.changes) {
if (c.state === 'current') copy.set(c.id, await dock.place(c.id));
else copy.delete(c.id);
}
if (page.next === null) break;
page = await dock.changes(null, { limit: 1000, cursor: page.next });
}
console.log(copy.size, page.until);Keeps a copy in sync. One page per call, oldest first; poll again later with since = page.until. With a cursor, since is left out: the cursor keeps its window. limit does not carry over; pass it on every call (default 100). A Date is sent as UTC; a string needs a zone. current: fetch with place(id). retired and hidden: drop it. Partner keys (changes:read).
Driver confirmations
const r = await dock.confirm({
result: 'confirmed', code: '7QSVX', tap: [106.93012, 47.92011], device: [106.9302, 47.9203], driver: 'drv-7f3a',
});
console.log(r.status, r.next);result is confirmed, wrong_location or missing. The door by code, or by entrance (the id in the door tile layer) when it has no code yet. tap is where the driver tapped the door on the map, required except for missing; device is the phone GPS on site, a check only, within 40 m of the tap. driver is your own id for the driver, stored only as a hash: never a phone number, e-mail or name. observed_at (a Date or a zoned string, at most 7 days old) for confirmations queued offline. TypeScript refuses a confirmed without tap or both code and entrance. Map data does not change at once. Partner keys (confirm:write).
The photo of the door: dock.sendConfirmPhoto(r.id, jpeg) after confirm answered, one JPEG (Uint8Array, ArrayBuffer or Blob) up to 2 MB, 320 to 8000 px a side. The API removes its metadata. One photo per confirmation: the same again is unchanged, another is 409 photo_exists. A person reviews it once the door has a door; it is never shown on a public page.
Confirmations per month
const own = await dock.ownConfirmations('2026-10');
console.log(own.closed, own.totals.confirmed, own.drivers, own.flagged.length);ownConfirmations is your dashboard of driver confirmations: your organization's confirmations and door reports in a month (YYYY-MM, Ulaanbaatar), no other organization read. totals and each of days count by result: confirmed, wrong_location, missing, and from door reports duplicate and unmapped. A row counts on the Ulaanbaatar day the driver was on site (observed_at, else when received). drivers is the number of different drivers by your driver ids, never the ids. flagged is the failure points: up to 100 doors your drivers said are wrong, missing or a duplicate, most first; code is null when they named the door. None inside a restricted site. The month you are in is answered too; closed is true from 8 days after the month ends, when no queued confirmation can join it. Needs confirm:read, a read-only scope for a dashboard key: confirm:write does not read it, and confirm:read cannot confirm. A bad month is 400 bad_month.
Door names
const mine = await dock.registerDoorName('Сансар бэйкери', '7QSVX');
const named = await dock.doorByName('сансар-бэйкери');
console.log(mine.status, mine.expires_at, named.door.code, label(named.door, 'en'));A business names its door once; anyone with a key finds the door by that name (API 0.52.0). Every call needs the door_names feature: until dock opens it for your org, each answers 403 feature_off (see features in usage). registerDoorName(name, code) needs names:write. The name is held for a year, no fee yet. Your own name with another code moves to that door (status is moved). 409 name_taken, 410 retired_code, 422 too_many past 50 names. doorByName(name) needs door:read and answers the door as door does, official address first. Any spelling finds it: case, spaces, punctuation, Latin letters that look like Cyrillic ones. A miss counts against the door miss cap. doorNames() lists your own names, A to Z. To hand a name to another org (API 0.53.0): doorNameTransferCode(name) gives a dnt_ code, shown once, good for 7 days. The other org calls claimDoorName(name, transferCode); the door and expiry stay. cancelDoorNameTransfer(name) clears the code. A name with no letter, a phone number or an e-mail is refused with the API's 400 before any request: the name travels in the URL.
Door notes
const sent = await dock.writeDoorNotes('Сансар бэйкери', {
instructions_mn: 'Зүүн талын хаалга',
instructions_en: 'East side, 2nd entrance',
});
const mine = await dock.doorNotes('Сансар бэйкери');
const state = mine.notes[0]?.state ?? 'none';
console.log(sent.status, state);A hotel, host or restaurant that holds a door name writes how guests find its door with writeDoorNotes(name, notes) (API 0.58.0): instructions_mn, instructions_en or both, up to 500 characters each. Needs names:write and the hotel.notes feature, else 403 feature_off. The note waits for a person to review it; door serves the notes the map holds until then. status is queued, then updated or unchanged when you write again. A phone number or an e-mail in a note is 400 personal_data. doorNotes(name) (API 0.59.0) lists your own notes for that door, each waiting, accepted or refused, with reviewed_at; never another org's. A name your org does not hold is 404 not_found. The name is checked as for door names: refused before any request.
Door photo
// A File from <input type="file" accept="image/jpeg"> goes the same way.
const gate = await (await fetch('/gate.jpg')).blob();
const sent = await dock.sendDoorPhoto('Сансар бэйкери', gate);
const shown = await dock.doorPhoto('Сансар бэйкери');
const again = await dock.doorPhoto('Сансар бэйкери', { sha256: shown.sha256 });
console.log(sent.status, shown.jpeg.length, again.jpeg);A host that holds a door name sends a photo of its gate, the door as a guest sees it, with sendDoorPhoto(name, jpeg) (API 0.60.0): one JPEG as a Blob, Uint8Array or ArrayBuffer, up to 2 MB, 320 to 8000 px a side. The API removes its metadata before it stores it: GPS, the phone, the time. The orientation stays. A person reviews it and refuses one that shows a face, a car plate or a phone number. status is queued, then updated or unchanged when you send again. sha256 is the photo as stored, not your file. doorPhoto(name) (API 0.61.0) reads back the newest photo a person accepted, as jpeg bytes and its sha256; never a waiting or refused one, never another org's. Show it inside your app only, never on a public page. Pass { sha256 } you hold: an unchanged photo comes back with jpeg null and no bytes. Needs names:write and the hotel.notes feature, else 403 feature_off. 404 not_found for a name your org does not hold, a retired door, a restricted site or no accepted photo. The name is checked as for door names: refused before any request.
Usage
const usage = await dock.usage({ days: 7 });
console.log(usage.caps.daily_quota, usage.days[0]?.calls);Free with any key. Still answers when the daily quota is used up. features (API 0.51.0) lists the features open to your org, A to Z, like cargo.eta. A route of any other feature answers 403 feature_off. undefined from an older API. endpoints (API 0.62.0) gives calls and door misses per route over the same days, most calls first, like GET /v1/door/:code. undefined from an older API.
Search telemetry
dock.setTelemetry(true);
const { results } = await dock.geocode('Сансар 24');
dock.pick(results[0]);
const sent = await dock.flushTelemetry();
console.log(sent?.stored, sent?.dropped);Off by default. Turn it on only after the user opted in, for example with a checkbox, and off when they untick it. Off drops every search not yet sent. When on, the SDK keeps each geocode search: the text as finally typed (the calls made while typing "Сан", "Сансар" count once), the number of results, and the result passed to pick with its rank. It sends them to POST /v1/telemetry/search 50 at a time, when the page is hidden, and on flushTelemetry(). Not sent: time, point, device, session. The API removes phone numbers and e-mail addresses before storage. Searches correct area names and aliases every week; anything shown outside dock is grouped, at least 5 searches a group. Picks also order suggestions while typing: a place picked 5 or more times in a year, by users of 2 or more partner apps, comes before an equally good match (API 0.35.1). A failed send is dropped: no retry, nothing stored in the browser. telemetry: true in the Client options is the same as setTelemetry(true). Needs geocode:read; one send counts as one call.
Offline search
const dock = new Client({ key: 'dk_...', placeCache: { store: localStorage } });
const { results } = await dock.geocode('Сансар 24');
dock.pick(results[0]);
const answer = await dock.geocode('Сансар');
console.log(answer.offline === true, answer.results.length);
console.log(dock.places().length);
dock.forgetPlaces();Online, answer.offline is not set and the API answers. Each place passed to pick is kept on the device, on by default, with or without telemetry. When geocode gets no answer (DockError code network), it answers from these places with offline: true: every typed word must start a word of the name, long form or code, any case. Most picked first, score is the number of picks, layer 1, near null, the fields as the API last gave them; a later online answer updates them. No matching place: the network error is thrown as before. An abort or an API refusal (429, 401) never answers from the device.
Kept: the place fields only. Not kept: the typed text, time, point, score. 100 places by default (max 1 to 1000); the least picked go first. In memory unless you pass store (localStorage or anything with getItem, setItem, removeItem); a store that refuses or holds a broken value is ignored. Nothing here is sent to dock. placeCache: false turns it off.
Trips
dock.startTrace({ consent: 'opt_in' });
const trips = await dock.stopTrace();
const id = trips[0].id;
const stored = await dock.trace(id);
console.log(stored.geometry?.coordinates.length, stored.properties.purge_after);
await dock.deleteTrace(id);Off until startTrace. Call it only after the user said yes to sharing this trip; consent: 'opt_in' is required. The SDK watches navigator.geolocation (or source, for a native bridge) and keeps [lon, lat, t]: 6 decimals, whole seconds UTC, one point a second at most. Left out: fixes less accurate than maxAccuracyM (default 50 m), fixes outside Mongolia, fixes that go back in time. Accuracy, speed, device and user are never sent. Points stay in memory, never in browser storage. stopTrace() sends the trip to POST /v1/traces, split every 20,000 points, and returns one receipt per part. Keep each id: deleteTrace(id) erases the points now, trace(id) is your export. Under 2 points sends nothing. A failed send rejects and keeps the points in memory: call stopTrace() again to retry, cancelTrace() to drop them. Points inside a restricted site are not stored. Raw points are erased after 90 days. Needs trace:write: use a separate key for the recorder, not the browser map key.
Trip cells
const own = await dock.ownTraceCells('2026-10', [106.7, 47.8, 107.2, 48.0]);
console.log(own.closed, own.features.length, own.features[0]?.properties.trips);
const all = await dock.traceCells('2026-09', [106.7, 47.8, 107.2, 48.0]);
console.log(all.month, all.features.length);
const speeds = await dock.traceSpeeds('2026-09', [106.7, 47.8, 107.2, 48.0]);
console.log(speeds.month, speeds.features.length, speeds.features[0]?.properties.km_h);
const roads = await dock.traceRoads('2026-09', [106.7, 47.8, 107.2, 48.0]);
console.log(roads.month, roads.features.length, roads.features[0]?.properties.km_h, roads.features[0]?.geometry.type);
const peak = await dock.traceRoads('2026-09', [106.7, 47.8, 107.2, 48.0], { band: 'am_peak' });
console.log(peak.rule.hours, peak.features.length);ownTraceCells is your dashboard: your organization's trips per 500 m cell and month, every cell with one of them, no other organization read. The month you are in is answered too and grows as trips arrive; closed is true from 14 days after the month ends, when the counts are final. Needs trace:read, a read-only scope for a dashboard key: it cannot send, export or delete a trip, and trace:write does not read the cells. traceCells is all partners together, the same grid: a cell shows only with trips from 3 or more organizations and 5 or more trips left after taking out the biggest one. No cell means too few, not zero. Test and internal organizations do not count. Whole months only, from 14 days after the month ends (400 month_open before). Needs insights:read. Both take month as YYYY-MM (Ulaanbaatar) and bbox as [west, south, east, north] inside Mongolia, sides at most 1 degree (400 bad_bbox). No ids, times or lines.
traceSpeeds is the moving speed of all partners' trips per cell, the same grid, month and box, under the same rule. A trip is cut into segments between its stored points. A segment counts when its points are 1 to 60 seconds apart, it moves at 3 to 130 km/h, and its midpoint is more than 200 m from the trip's first and last point: parking and the walk to the door are left out. km_h is the counted meters over the counted seconds in the cell, to 0.1, so a cell with more trips weighs more. Per cell; per road is traceRoads, below. No cell means too few trips, not a slow road. No trip count. Needs insights:read.
traceRoads is the same speed per road. Each trip is matched to OSM ways with Valhalla map matching when it is stored, and a segment counts on a way when both its points matched that way, under the traceSpeeds rule. One feature per road: id is the OSM way id as text, the line is dock's current road with that id, and properties has km_h (counted meters over counted seconds on the way, to 0.1), name_mn, name_en and highway, null when unknown. The geometry is a LineString, or a MultiLineString when the road is in pieces. A road shows only under the same rule; no road means too few trips, not a slow road. A way crossing a restricted site is left out. Empty until dock's road lines are loaded. No trip count. Needs insights:read.
band (API 0.49.0) gives one hour band's trips only, by the Ulaanbaatar time of a trip's first stored point: 'am_peak' Monday to Friday 07:00 to 09:59, 'pm_peak' Monday to Friday 17:00 to 19:59, 'off_peak' every other hour, Saturday and Sunday whole. Each band passes the same rule on its own trips, so a road shown for all hours can be missing from a band. Left out: all hours. rule.hours says which.
Arrival minutes
const arrivals = await dock.ownArrivals('2026-10');
console.log(arrivals.closed, arrivals.trips, arrivals.slow, arrivals.median, arrivals.features[0]?.properties.slow);ownArrivals is how long your trips spend in the last 200 m: for each trip, whole minutes from the first point after it was last more than 200 m from its end, to its end; 60 means 60 or more. Circling inside 200 m counts; a trip that never left 200 m of its end is not counted. The minutes include parking and the walk to the door: compare months and places with them. trips, slow (5 minutes or more), median, p75 and p90 (nearest rank, null with no trips) cover the month; histogram is trips per minute, minutes with none left out. features lists up to 100 cells where trips ended, on the trip cells grid, by slow trips, then trips; cells counts them all. Your organization only, no other organization read. The month you are in is answered too; closed is true from 14 days after the month ends. Deleting a trip takes it out; the 90-day erase of the raw points keeps it. Needs trace:read. A bad month is 400 bad_month.
Trip hours
const hours = await dock.ownHours('2026-10');
console.log(hours.closed, hours.trips, hours.by_weekday[0], hours.by_hour[9], hours.slots.length);
const all = await dock.allHours('2026-09');
console.log(all.month, all.slots.length, all.slots[0]?.trips);
const khoroos = await dock.traceKhoroos('2026-09');
console.log(khoroos.month, khoroos.features.length, khoroos.features[0]?.properties.khoroo);
const myKhoroos = await dock.ownKhoroos('2026-10');
console.log(myKhoroos.closed, myKhoroos.trips, myKhoroos.no_khoroo, myKhoroos.features.length, myKhoroos.features[0]?.properties.trips);
const myKinds = await dock.ownKinds('2026-10');
console.log(myKinds.closed, myKinds.trips, myKinds.kinds.length, myKinds.kinds[0]?.kind, myKinds.kinds[0]?.trips);ownHours is when your trips start: each trip counts once, in the Ulaanbaatar weekday (1 Monday to 7 Sunday) and hour (0 to 23) of its first stored point. trips is the month total; by_weekday has 7 counts, Monday first; by_hour has 24, hour 0 first; slots lists every weekday and hour with trips, weekday then hour, and leaves out the ones with none. A trip belongs to the month it started in, as in ownTraceCells. Your organization only, no other organization read. The month you are in is answered too; closed is true from 14 days after the month ends. Deleting a trip takes it out; the 90-day erase of the raw points keeps it. Needs trace:read. A bad month is 400 bad_month.
allHours is all partners together, under the trip cells rule: a weekday and hour shows only with trips from 3 or more organizations and 5 or more trips left after taking out the biggest one. No slot means too few, not zero. There is no total and no weekday or hour sums, so a left-out slot cannot be worked out. Test and internal organizations do not count. Whole months only, from 14 days after the month ends (400 month_open before). Needs insights:read.
traceKhoroos is where and when trips end, all partners together, under the same rule: each trip counts once, in the Ulaanbaatar hour (0 to 23) of its last stored point and the khoroo holding the centre of its end cell. One feature per khoroo: the polygon, district_mn, district_en, the khoroo number and hours, each hour that passed the rule. No hour means too few, not zero. There is no khoroo total. Current khoroo polygons only; a khoroo with no polygon is not shown. Whole months only, from 14 days after the month ends (400 month_open before). Needs insights:read.
ownKhoroos is where and when your own trips end, per khoroo and hour of arrival, read as traceKhoroos but with no group rule: the trips are yours. trips is the month total, no_khoroo the trips that ended in no current khoroo polygon, and each feature has the khoroo's trips and every hour with trips. trips is no_khoroo plus every khoroo's trips. Until khoroo polygons are loaded every trip is in no_khoroo. Your organization only. The month you are in is answered too; closed is true from 14 days after the month ends. Needs trace:read. A bad month is 400 bad_month.
ownKinds is what kind of building your trips end at: each trip counts once, by the building whose footprint holds its last stored point, else the nearest within 30 m, and that building's type when the trip was stored (apartment, ger_house, commercial, public, industrial, garage, other). unknown is a building with no type yet, none no building within 30 m. A building inside a restricted site is not read. trips is the month total and the sum of every kind's trips; kinds lists the kinds with trips, most first. Your organization only, no group rule. The month you are in is answered too; closed is true from 14 days after the month ends. Needs trace:read. A bad month is 400 bad_month.
Errors
try {
await dock.door('7QSVX');
} catch (e) {
if (!(e instanceof DockError)) throw e;
if (e.code === 'not_found') console.log('no door has this code');
else if (e.retryAfter !== null) console.log(`wait ${e.retryAfter} s`);
else console.log(e.status, e.code, e.message);
}status is the HTTP status, code is the API error (invalid_code, not_found, retired_code, rate_limited, quota_exceeded, miss_cap, ...), and network when no response came back. retryAfter is in seconds on 429 and 503, otherwise null. The SDK does not retry on its own.
Deprecated routes
When an answer says its route is deprecated (Deprecation, Sunset and Link headers, see Status), the SDK calls onDeprecation once per route per client with { route, deprecated, sunset, link }. route is the pattern (GET /v1/door/:code), never the URL. Days are Ulaanbaatar days. The default is one console.warn per route; pass your own function to send it to your logs. From the sunset day the route throws DockError with status 410 and code sunset. No route is deprecated today.
Sample apps
Seven apps, one HTML file each, under 200 lines, MIT. In the SDK folder under examples/. Run npm run build, serve the folder over http and open /examples/<file>#key=dk_.... The key stays in the hash, which browsers never send to a server. A free key has the scopes they need.
| File | What it does | Calls |
|---|---|---|
dispatch.html | Delivery dispatch. Paste orders, one per line, as codes or addresses. Each is routed from the depot in the order given. A line with no match stays in the list as not found. | door, geocode, route |
sameday.html | Same-day delivery, one driver. Paste up to 11 orders across the city. dock puts them in the driving order with the least time on the road, from the depot and back, and gives each stop its district and arrival time from the start time and the minutes at each stop. Each leg is timed at its own hour: drivers' measured speeds of that hour band where dock has them, else OSM road speeds with no traffic, and the page says which. | door, geocode, reverse, optimizedTrips, route |
taxi.html | Taxi rider. Tap the pickup, type where to go, pick a suggestion, see the route, the time and the walk to the door. The route names the road closures it went around; a pickup or drop inside a closure is refused with the closure's id. | reverse, geocode, door, route, closures, excludePolygons, closureAt |
checkout.html | E-commerce checkout. The customer types the address as they say it or a code into one attachSuggest field, checks the pin, adds the flat. The order keeps the code, the official address and the point. | attachSuggest, door, setTelemetry, flushTelemetry |
hotel.html | Hotel guest page. The door on a map in Mongolian, English, Japanese, Korean, Chinese or Russian, the hotel's instructions (in English where it wrote none in the guest's language, and the page says so), a card for the taxi driver, a walking route, the door's QR. Opens from a code or a door link. | door, doorByName or geocode, route, doorQrSvg |
emergency.html | Emergency dispatcher. Type or speak what the caller says (a mic where the browser has speech input). Up to three places come back and none is picked: the dispatcher taps the one the caller confirms. Then confidence in large type, red when low or unknown, the door, the route from the base. The call card leaves only on "Илгээх". A code with no door stays not found. | door, geocode, route |
outlet.html | Outlet map. Type a chain's name (a bank, a pharmacy); the places of that name inside the demo district are listed and drawn. A branch is green with its code only where a person confirmed the door; the rest are grey, "not confirmed", never shown as doors. The branches come from the names dock holds, not the chain's own list, and the page says so. The outlets as JSON. | geocode |
driver.html | Driver trip. Start stays off until the driver ticks the opt-in box. Stop sends the trip, the page draws what dock stored, and the driver can delete it. Open with a second key, &trace_key=dk_..., that has trace:write only. | startTrace, stopTrace, cancelTrace, trace, deleteTrace, reverse |
Every place is shown through label(), so anything without a matched official address says "unofficial". make samples opens each app in headless Chrome against the API and drives it; the SDK tests fail until all ten runs pass on the current SDK version. Two rewrite answers, because live data has no such case yet, and their rows say so. One runs the taxi page with one test closure: the API's closure list and route answers are rewritten in the browser. One opens the hotel page from a door name (#name=): no current door holds a name yet, so a name the API does not find gets a test door, marked unofficial.
The steppe demo is a page of its own: the corridor dashboard at dock.mn/steppe, six corridors, one file that opens offline. The facts as PDF: English, Mongolian. Every number is a model's, with its confidence. No track is field checked yet (0 km).
Keys
JSON calls send the key in the Authorization header. Map requests carry it as ?access_token=, like Mapbox: one request per tile and no CORS preflight. The API does not log query strings. The key does not show in console.log or JSON.stringify of the client.
A key in a web page is visible to its users. Give the browser a key with only tiles:read, geocode:read and door:read.
Reference
| Export | |
|---|---|
Client | new Client({ key, baseUrl?, fetch?, onDeprecation?, telemetry?, placeCache? }). Holds the key. baseUrl defaults to DEFAULT_BASE_URL. |
geocode(q, { limit?, proximity?, types? }) | Name or address to places, best first. proximity [lon, lat]: nearer first among equal name matches. types keeps only those kinds, chosen before the limit. With no network, the picked places that match, offline: true. geocode:read. |
reverse([lon, lat], { radius?, limit?, types? }) | Places at and around a point, nearest first, with district and khoroo. types keeps only door, business, building or landmark, chosen before the limit. geocode:read. |
door(code, { unit? }) | A door from its code. door:read. |
route(from, to, { mode?, departAt?, via? }) | A route between points or codes, to the door for a code, through via in order. Points are rounded to 7 decimals. route:read, and door:read for a code. |
isochrone(from, contoursMinutes, { mode? }) | The area reached from one end within 1 to 4 times of up to 60 minutes, as GeoJSON. route:read, and door:read for a code. |
matching(coordinates, { timestamps?, mode? }) | A GPS trace on the roads it went along: one line per unbroken piece, one place per point. 2 to MATCHING_MAX (100) points. route:read. |
optimizedTrips(stops, { roundtrip?, mode? }) | Stops in the order with the least time on the road, and the trip in that order. 2 to OPTIMIZED_TRIPS_MAX (12) stops. route:read, and door:read for a code. |
tilequery([lon, lat], { sources?, radius?, limit?, geometry? }) | The buildings and doors at or near a point, nearest first, as GeoJSON. Radius up to TILEQUERY_RADIUS_MAX_M (1000) m, up to TILEQUERY_LIMIT_MAX (50) features. tiles:read. |
matrix(from, to, { mode? }) | Time and distance from every from to every to in one call, up to MATRIX_MAX (10) a side. route:read, and door:read for a code. |
place(id) | A place again by its id, with parents. geocode:read. |
changes(since, { limit?, cursor? }) | One page of ids changed in [since, until). changes:read. |
confirm(confirmation) | A driver on site confirms a door, or says it is elsewhere or gone. confirm:write. |
sendConfirmPhoto(id, jpeg) | The photo of the door with a confirmation, for review. confirm:write. |
ownConfirmations(month) | Your drivers' confirmations and door reports in a month, by result and day, with failure points; closed once final. confirm:read. |
closures() | Road closures not over today, each with its ring. route:read. |
reportClosure(report, options?) | Report a stretch or crossing that cannot be driven; idempotencyKey makes a retry safe. closures:write. |
closure(id) | One of your closure reports as stored, note included. closures:write. |
liftClosure(id, options?) | The road is open again from on, today by default. closures:write. |
reportDoor(report) | Report a wrong, missing, duplicate or unmapped door. door:read. |
usage({ days? }) | Your organization's caps and last 1 to 90 days. |
setTelemetry(on) | Share searches, only after the user opted in. Off drops what was not sent. |
pick(result) | The user chose this geocode result. Kept on the device for offline search; sent only while telemetry is on. |
places() | Places picked on this device, most picked first. |
forgetPlaces() | Forget them, in memory and in the store. |
flushTelemetry() | Sends the waiting searches now. { stored, dropped }, or null when off or nothing waits. |
startTrace({ consent, source?, maxAccuracyM? }) | Record the user's trip, only after they said yes. consent: 'opt_in'. |
stopTrace() | Stop and send the trip. One { id, points, restricted, duplicate, purge_after } per 20,000 points. trace:write. |
cancelTrace() | Stop and drop the trip unsent. |
tracing | true while a trip is being recorded. |
trace(id) | Your trip as stored, GeoJSON [lon, lat, t]. trace:write. |
deleteTrace(id) | Erase a trip's points now. The id stays, never reused. trace:write. |
startProbes({ source?, maxAccuracyM?, intervalS?, onError? }) | A fleet vehicle's position for live traffic, every 30 s, under the fleet's contract (traffic.probes). A random vehicle token every 15 minutes, no driver. trace:write. |
stopProbes() | Stop and send the rest. { points, restricted, roads, late }, or null. |
probing | true while probes are on. |
traffic(bbox) | Live speed per road, the last 15 minutes that ended, roads 5 or more vehicles passed. insights:read. |
trafficTypical(bbox) | The usual speed per road for the hour band of this moment (am_peak, pm_peak, off_peak, Ulaanbaatar clock). Never live: typical.label is "ердийн". A road with no speed is left out, so the answer can be empty. Needs only tiles:read (a map key); counted like a tile. |
createEvent({ name, bbox, startsAt, endsAt }) | Opens an event for its live page and heat map, under the organizer's contract (events.live). Every point is erased 15 minutes after the end. trace:write. |
event(id) | One of your events: { id, name, bbox, starts_at, ends_at, closes_at, closed }. trace:read. |
startEventPositions(id, { consent, source?, maxAccuracyM?, intervalS?, onError? }) | Sends the participant's position to the event every 10 seconds, only after they said yes. consent: 'opt_in'. A random participant token, no name or bib. Stops on event_closed. trace:write. |
stopEventPositions() | Stops and sends the rest. { points, kept, outside, restricted }, or null. |
sendingPositions | true while positions are being sent. |
eventLive(id) | Live dots: each participant's last point of the last 2 minutes, to 4 decimals, nothing about them; empty under 5 live. trace:read. |
eventHeat(id, { window? }) | Participants per 100 m cell, cells with 5 or more, the whole event or one 15-minute window. trace:read. |
ownTraceCells(month, bbox) | Your own trips per 500 m cell and month, every cell with one; closed once final. trace:read. |
ownArrivals(month) | Your trips' minutes in the last 200 m in a month: median, p75, p90, per minute and per end cell; closed once final. trace:read. |
ownHours(month) | Your trips per weekday and hour in a month, at the first point's Ulaanbaatar time; closed once final. trace:read. |
allHours(month) | All partners' trips per weekday and hour, a published month, only slots from 3 or more organizations. insights:read. |
traceCells(month, bbox) | All partners' trips per cell, a published month, only cells from 3 or more organizations. insights:read. |
traceSpeeds(month, bbox) | All partners' moving speed per cell, a published month, only cells from 3 or more organizations. insights:read. |
traceRoads(month, bbox, { band }) | All partners' moving speed per road, a published month, only roads from 3 or more organizations; band for one hour band. insights:read. |
traceKhoroos(month) | All partners' trips per khoroo and hour of arrival, a published month, only hours from 3 or more organizations. insights:read. |
ownKhoroos(month) | Your trips per khoroo and hour of arrival in a month, and those in no khoroo; closed once final. trace:read. |
ownKinds(month) | Your trips per kind of building at the trip end in a month, most first; closed once final. trace:read. |
tileJsonUrl(source) | TileJSON URL of building or entrance, without the key. |
transformRequest | MapLibre transformRequest that keys requests to the dock API only. |
DockError | Every failure: status, code, message, retryAfter. |
createMap(maplibregl, client, options) | A maplibre-gl Map on dock tiles. |
dockStyle(baseUrl, { extrude?, background?, building?, buildingOutline? }) | The full style createMap uses. |
dockSources(baseUrl) | Sources dock-building and dock-entrance. |
dockLayers({ extrude?, building?, buildingOutline? }) | Building fill or extrusion, door dots. Door colors show confidence and never change. |
isochroneSource(zone) | A GeoJSON source from an isochrone answer, the largest contour first, with the answer's source as attribution. |
isochroneLayers({ source?, color?, opacity? }) | A fill and an outline for that source, ids <source>-fill and <source>-line. opacity 0 to 1, default 0.2. |
matchingSource(trip) | A GeoJSON source from a matching answer: one line per piece, one point per tracepoint on a road, with the answer's source as attribution. |
matchingLayers({ source?, color?, width? }) | A line and a dot per matched point for that source, ids <source>-line and <source>-point. width more than 0, default 4. |
optimizedTripSource(round) | A GeoJSON source from an optimizedTrips answer: the trip line, then one point per stop in the order to drive, number from 1, with the answer's source as attribution. |
optimizedTripLayers({ source?, color?, width?, font? }) | A line and a ring per stop for that source, ids <source>-line, <source>-stop, and with font <source>-number. width more than 0, default 4. |
routeSource(trip) | A GeoJSON source from a route answer: the line, then one point per end, from, each via, to, number from 1, with the answer's source as attribution. |
routeLayers({ source?, color?, width?, font? }) | A line and a ring per end for that source, ids <source>-line, <source>-end, and with font <source>-number. width more than 0, default 4. |
label(place, lang) | Text to show a person, with "(unofficial)" when not matched. |
attachSuggest(input, client, { onPick, limit?, proximity?, types?, lang?, list?, onError? }) | An address input with suggestions from the third character. { list, clear(), destroy() }. |
Suggester | The same without a DOM: new Suggester(client, options, show, fail), input(text), cancel(). |
SUGGEST_MIN_CHARS | 3. |
SUGGEST_DELAY_MS | 200, wait after the last key. |
excludePolygons(closures, day?) | Offline: the rings of the closures active on a route day, for Valhalla's exclude_polygons. |
closureAt(avoid, point) | Offline: the id of the closure in avoid whose ring holds point, or null. |
crossingsOnRoute(crossings, line, month?) | Offline: the fords and unbridged crossings a route line passes, from a steppe pack's crossings.geojson, with high water for the month. |
LOW_FLOW_CONFIDENCE | 0.1. At this flow confidence or under, month_m3s is null. |
poisOnRoute(pois, line, nearM?) | Offline: the registered fuel stations and ger camps within nearM of a route line, from a steppe pack's pois.geojson, with the counts of unregistered ones. |
POI_NEAR_M | 2000, poisOnRoute's default nearM, m. |
wetlandsOnRoute(wetlands, line) | Offline: the stretches through OSM wetland a route line runs on, from a steppe pack's wetlands.geojson, with metres along and on. |
WETLAND_MIN_ON_M | 20, m: a route on less of a stretch only crosses it, unless it runs on all of it. |
passesOnRoute(passes, line) | Offline: the OSM mountain passes a route line goes over, from a steppe pack's passes.geojson, with metres along, metres off, the OSM and DEM heights and the highest of each, and where the two disagree. |
sandOnRoute(sand, line) | Offline: the stretches through OSM sand a route line runs on, from a steppe pack's sand.geojson, with metres along and on. |
SAND_MIN_ON_M | 20, m: a route on less of a stretch only crosses it, unless it runs on all of it. |
waterOnRoute(water, line, nearM?) | Offline: the OSM wells, springs, taps and drinking water within near_m (default the pack's, 2000 m) of a route line, from a steppe pack's water.geojson, with metres along, metres off and what OSM says about drinking it. |
checkPack(manifest, read) | Offline: each file a steppe pack's manifest lists, by size and sha256, { ok, files, bad }; a status per file: ok, missing, bytes, sha256. Async. |
packRoute(manifest, stops, options?) | Offline: the Valhalla request for a route on a steppe pack: the manifest's costing, the route day's closures as exclude_polygons, units km; with the closures avoided, attribution, confidence_note and trace_note when the pack has no trace scores, in the pack's words when its manifest has them. |
PACK_TRACE_NOTE, PACK_TRACE_NOTE_MN | What to say when a pack has no trace scores (confidence osm_only). |
basemapStyle(basemap, url, { lang?, glyphs?, fonts?, background? }) | Offline: a whole MapLibre style for a steppe pack's basemap.pmtiles, from the manifest's basemap block. |
BASEMAP_EXCLUDED | ['poi', 'housenumber'], layers the base map must not carry. |
parseDoorCode(text) | Offline: a typed or pasted building code as the API reads it, { ok, code, unit } or { ok: false, error, reason }. |
DOOR_CODE_ALPHABET | '0123456789BCDFGHJKMNPQRSTVWXYZ', the 30 characters a code uses. |
DOOR_CODE_LENGTH | 5. |
doorLink(text, unit?) | Offline: the link to share a door, https://map.dock.mn/d/7QSVX?u=17, or null when the text cannot be a code. |
parseDoorLink(text) | Offline: the first door link in a text, read as parseDoorCode reads a code and a unit, or null when there is none. |
DOOR_LINK_BASE | 'https://map.dock.mn/d/'. |
doorQr(text, unit?) | Offline: the QR of a door link, { link, version, size, modules }, or null when the text cannot be a code. |
doorQrSvg(text, unit?, options?) | Offline: that QR as an SVG, px per module (4), margin light modules (4), or null. |
isActive(item, day) | from <= day, and day <= to when to is set. |
ubToday() | Today in Ulaanbaatar, YYYY-MM-DD. |
ClosuresOverLimitError | The day's closures are over Valhalla's limits: day, perimeter_m, vertices. Refuse the route. |
EndInClosureError | A packRoute stop inside an avoided closure ring: stop, closure. Refuse the route. |
UB_CENTER | [106.9177, 47.9186], Sükhbaatar Square. |
DEFAULT_BASE_URL | https://geo.dock.mn. |
KEY_RE | The key format: dk_ and 32 characters. |
IDEMPOTENCY_KEY_RE | The Idempotency-Key format: 1 to 64 of letters, digits and ._:-. |
STYLE_ID_RE | The style id format: st_ and 10 characters. |
TRACE_FORMAT | 'dock.trace.v1', the upload format. |
TRACE_MAX_POINTS | 20000, points per upload. |
PROBE_FORMAT | 'dock.probe.v1', the fleet probe format. |
PROBE_MAX_POINTS | 900, points per probe batch. |
PROBE_SEND_S | 30, seconds between probe sends. |
PROBE_WINDOW_S | 900, the traffic window; the vehicle token is new in each. |
ProbeBuffer | One vehicle's points between sends, the buffer startProbes uses. |
probeToken() | A random vehicle token, 24 characters. Never a plate, phone or device id. |
EVENT_POSITION_FORMAT | 'dock.position.v1', the event position format. |
EVENT_MAX_POINTS | 900, points per send. |
EVENT_SEND_S | 10, seconds between sends. |
EventBuffer | One participant's points between sends and their random token, used by startEventPositions. |
PLACES_KEY | 'dock.places.v1', the key in a placeCache store. |
PLACES_MAX | 100, places kept by default. |
MATRIX_MAX | 10, ends per side in one matrix call. |
ISOCHRONE_CONTOURS_MAX | 4, contours in one isochrone call. |
ISOCHRONE_MINUTES_MAX | 60, the longest contour in minutes. |
MATCHING_MAX | 100, points in one matching call. |
OPTIMIZED_TRIPS_MAX | 12, stops in one optimizedTrips call. |
ROUTE_VIA_MAX | 10, via ends in one route call. |
TILEQUERY_RADIUS_MAX_M | 1000, the widest tilequery radius in metres. |
TILEQUERY_LIMIT_MAX | 50, features in one tilequery answer. |
GEOCODE_TYPES | door, business, building, district, khoroo, area, street, landmark: the kinds geocode keeps with types. |
REVERSE_TYPES | door, business, building, landmark: the kinds reverse keeps with types. |
NEARBY_CATEGORIES | the 25 categories nearby takes: bank, cafe, hospital, pharmacy, school, stop and more. |
ISOCHRONE_SOURCE | 'dock-isochrone', the source id isochroneLayers draws by default. |
MATCHING_SOURCE | 'dock-matching', the source id matchingLayers draws by default. |
OPTIMIZED_TRIP_SOURCE | 'dock-optimized-trip', the source id optimizedTripLayers draws by default. |
ROUTE_SOURCE | 'dock-route', the source id routeLayers draws by default. |
VERSION | '0.68.0'. |
Every API type (Door, Place, GeocodeResponse, ReverseResponse, UsageResponse, ...) is exported for TypeScript, checked field by field against the API reference. Every type and signature, with its doc comment, written from the code: JS SDK reference.