Map data over HTTP
The uzmap server serves everything a MapLibre style needs from one origin: vector tiles from PMTiles archives, terrain-RGB rasters, SDF glyphs, sprite sheets, pre-built style JSON and the browser SDK. This page documents those endpoints as the server implements them. The JSON API (/api/*) is documented separately.
Every section ends with the source file the behaviour was confirmed in.
Endpoints#
| Path | Serves | Cache-Control | Key required |
|---|---|---|---|
/tiles/tilejson.json | TileJSON for the basemap archive | no-store | yes |
/tiles/{z}/{x}/{y}.pbf | one basemap tile (.mvt also accepted) | public, max-age=86400 | yes |
/tiles/uzbekistan.pmtiles | the whole basemap archive, range requests | public, max-age=3600 | yes |
/tiles/{name}/tilejson.json | TileJSON for a supplementary archive | no-store | yes |
/tiles/{name}/{z}/{x}/{y}.pbf | one supplementary tile | public, max-age=86400 | yes |
/tiles/{name}.pmtiles | a supplementary archive, range requests | public, max-age=3600 | yes |
/terrain/tilejson.json | TileJSON for the terrain rasters | public, max-age=3600 | yes |
/terrain/{z}/{x}/{y}.png | one terrain-RGB tile | public, max-age=86400 | yes |
/fonts/{fontstack}/{range}.pbf | one SDF glyph range | public, max-age=86400 | yes |
/sprites/sprite.json, .png, @2x.json, @2x.png | the sprite sheet | public, max-age=86400 | yes |
/styles/uzmap-{theme}-{lang}.json | a pre-built style | public, max-age=86400 | yes |
/v1/uzmaps.js | the browser SDK entry point | public, max-age=3600, stale-while-revalidate=86400 | no |
/v1/*.map | source maps | public, max-age=3600 | no |
/v1/* (anything else) | MapLibre worker chunks | public, max-age=31536000, immutable | no |
"Key required" means the path is gated when the server runs with --require-key. Without that flag every path is open, whether or not a key file is loaded.
Presenting a key#
When --require-key is on, every request under /tiles/, /terrain/, /fonts/, /sprites/ and /styles/ must carry a key. The server accepts it two ways, checked in this order:
?key=in the query string- an
X-API-Keyrequest header
For map data from a browser, use ?key=. MapLibre issues tile, glyph and sprite requests itself, some as worker or image loads where you cannot set headers, and the server's CORS preflight only allows the Content-Type and Range request headers, so a cross-origin request carrying X-API-Key does not pass preflight. @uzmaps/engine does this for you: with apiKey set, its transformRequest appends ?key= to every URL that starts with serverUrl and does not already contain key=.
Every one of these requests counts against the key's per-minute rate limit (default 600, --key-rate) and is metered under the tiles product. A map pans through hundreds of tiles a minute, so size rate limits for tiles, not for API calls.
A refused request returns JSON with Cache-Control: no-store:
| Status | Meaning |
|---|---|
| 401 | no key, or unknown key |
| 403 | key disabled, or the request's Origin/Referer is not on the key's allowlist |
| 429 | rate limit or monthly quota exceeded |
{"error":"Unauthorized","code":401,"message":"missing API key — pass ?key=… or an X-API-Key header","docs":"https://uzmaps.ndc.uz/docs/api-keys"}CORS#
One middleware wraps every route, including key refusals, and sets:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Range
Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-RangesOPTIONS requests get an empty 204 from this middleware before the key check runs, so preflights never need a key.
Range is allowed and Content-Range/Accept-Ranges are exposed because the pmtiles:// protocol reads the archive cross-origin with byte-range requests and has to see those headers to work.
Access control is not done here. Allow-Origin: * is deliberate: the per-key origin allowlist is what restricts who may use a key, and it is enforced by the key check, not by CORS.
Vector tiles#
URL template#
/tiles/{z}/{x}/{y}.pbf?v=<token>/tiles/tilejson.json returns a TileJSON 3.0.0 document whose tiles[0] is that template made absolute. The scheme is https when the request arrived over TLS or with X-Forwarded-Proto: https, otherwise http; the host is the request's Host. Behind a proxy that does not set X-Forwarded-Proto, the URLs come out as http:// and an https page will refuse them as mixed content.
Fields in the document:
| Field | Value |
|---|---|
tilejson | "3.0.0" |
tiles | [template] |
minzoom, maxzoom | from the archive header |
bounds | [minLon, minLat, maxLon, maxLat] from the archive header |
center | [lon, lat, zoom] from the archive header |
format | always "pbf" |
name, attribution, description, vector_layers, version | copied from the archive's metadata when present; attribution falls back to an OpenStreetMap credit |
v is the build token: base-36 of the archive file's modification time XOR its size. Tiles are cached for a day, so without it a rebuilt tileset would stay invisible to every browser that had already loaded the area. The token changes the URL instead of the bytes behind it. The tile handler itself ignores the query string.
The TileJSON is no-store for the same reason: it is the document that carries the token, and a stale copy pins a client to old tiles indefinitely. The symptom is a layer that loads, tiles that load, and nothing rendering.
Tile responses#
| Case | Status | Headers |
|---|---|---|
| tile present | 200 | Content-Type: application/x-protobuf, Content-Encoding: gzip, Cache-Control: public, max-age=86400 |
| tile absent from the archive | 204 | Cache-Control: public, max-age=86400, empty body |
path is not {z}/{x}/{y} | 404 | |
| archive not built | 404 | |
| read error | 500 | JSON {"error": …} |
Tile bytes are served exactly as stored in the archive, which for every archive in the pipeline is gzip. The server sets Content-Encoding rather than decompressing; a client that does not decode content encodings (for example curl without --compressed) gets gzip bytes.
There is no zoom validation. A request above the archive's maxzoom finds nothing and returns 204; overzooming is the client's job, driven by maxzoom in the TileJSON.
Both .pbf and .mvt suffixes are accepted.
Zoom range and layers of the basemap#
The basemap is built by Planetiler with --maxzoom=16 into data/build/uzbekistan.pmtiles, OpenMapTiles schema, with names in uz, uz-Cyrl, uz-Latn, ru, en, kaa, kk, tg, de, fr and transliteration on. The built archive's header says z0–16, tile type MVT, gzip. Its vector_layers (OpenMapTiles 3.16.0), as reported by /tiles/tilejson.json:
| Layer | Zooms | Notable fields |
|---|---|---|
water | 0–16 | class, id, intermittent |
boundary | 0–16 | admin_level, adm0_l, adm0_r, disputed, maritime |
place | 1–16 | class, capital, iso_a2, rank, name, name:uz, name:uz-Cyrl, name:ru, name:en, name:kaa, name:latin, name:nonlatin |
landcover | 2–16 | class, subclass |
landuse | 4–16 | class |
park | 4–16 | class, rank, name and name:* |
transportation | 4–16 | class, subclass, brunnel, oneway, ramp, service, surface, toll, layer, level, access, bicycle, foot |
waterway | 4–16 | class, brunnel, intermittent, name and name:* |
transportation_name | 6–16 | class, ref, network, route_1_* … route_5_*, name and name:* |
water_name | 6–16 | class, intermittent, name and name:* |
mountain_peak | 7–16 | class, ele, ele_ft, rank, name and name:* |
aerodrome_label | 8–16 | class, iata, icao, ele, name and name:* |
aeroway | 10–16 | class, ref |
poi | 12–16 | class, subclass, rank, indoor, layer, level, name and name:* |
building | 13–16 | colour, render_height, render_min_height |
housenumber | 14–16 | housenumber |
The generated style (@uzmaps/cartography) references every one of these layers; do not rely on this table for a different build — read vector_layers from the TileJSON of the deployment you are talking to.
Reading the archive directly#
/tiles/uzbekistan.pmtiles (and /tiles/{name}.pmtiles for supplementary archives) streams the raw file through Go's http.ServeFile with Accept-Ranges: bytes, so a client can fetch only the directory and tile bytes it needs. The response carries Last-Modified and answers If-Modified-Since with 304; Cache-Control is one hour, with no build token, because the file's own modification time does that job here.
@uzmaps/engine uses this when constructed with tiles: 'pmtiles': the style's source URL becomes pmtiles://<serverUrl>/tiles/uzbekistan.pmtiles instead of the TileJSON URL.
Supplementary tilesets#
At start-up the server opens every data/build/*.pmtiles. uzbekistan.pmtiles is the basemap; each other file becomes a named source, {name} being the filename without the extension:
/tiles/{name}/tilejson.json
/tiles/{name}/{z}/{x}/{y}.pbf?v=<token>
/tiles/{name}.pmtilesThe first path segment decides: numeric means basemap, anything else is looked up as a name and returns 404 if no such archive is loaded. The TileJSON, token and tile responses behave exactly as for the basemap. The TileJSON reports format: "pbf" regardless of the archive's tile type, though Content-Type on the tiles themselves follows the archive header (application/x-protobuf, image/png, image/jpeg, image/webp, image/avif).
Discovery#
/api/status (not gated) tells a client what this deployment has; the map-data fields of its response are:
{
"tiles": true,
"bounds": [55.97708, 37.16636, 73.1746299, 45.618841],
"maxzoom": 16,
"sources": {
"tiles": ["furniture", "places-overture", "uzbekistan"],
"terrain": true,
"places": true,
"buildings_ml": false,
"furniture": true
}
}sources.tiles lists every loaded archive by name. The booleans test for the three names the engine knows (places-overture, buildings-ml, furniture) and for build/terrain/meta.json. @uzmaps/engine calls this once and, unless sources: { mlBuildings, places, furniture } says otherwise, adds the matching TileJSON URLs to the style.
Archives produced by the pipeline#
| Name | Built by | Source layer | Zooms | Properties |
|---|---|---|---|---|
places-overture | data/pipeline/places.mjs (tippecanoe -Z12 -z14) | poi | 12–14 | name, name:ru (only when the Overture record carries a Russian name; no feature in the current build has one), icon (sprite name without the poi- prefix), group, category, rank (lower is more prominent), src ("custom" on operator-dataset features, absent on Overture ones) |
furniture | data/pipeline/furniture.mjs (tippecanoe -Z13 -z16) | furniture | 13–16 | kind: crossing, traffic_signals, bus_stop, tree (points); crossing_way, tree_row (lines) |
buildings-ml | data/pipeline/buildings.mjs (tippecanoe -Z13 -z16) | building | 13–16 | src: "ml", height in metres when the source footprint has one |
In places-overture, which features survive at z12 and z13 is decided by a rank filter written at build time; z14 keeps every feature. The current build keeps rank <= 3 at both z12 and z13.
Terrain#
Terrain tiles are 256×256 8-bit RGBA PNGs in data/build/terrain/{z}/{x}/{y}.png, XYZ scheme (gdal2tiles.py --xyz), served as static files:
/terrain/{z}/{x}/{y}.pngA tile outside the built set is a plain 404. There is no build token on these URLs, so a rebuilt terrain set is not seen by a browser that has the old tiles cached until the day expires.
Encoding#
The pipeline reprojects the Copernicus DEM (GLO-90 by default; UZMAP_DEM=30 selects GLO-30) to Web Mercator, fills no-data with 0, and encodes each height h in metres exactly as Mapbox Terrain-RGB:
v = (h + 10000) * 10
R = v >> 16
G = (v >> 8) & 255
B = v & 255So to decode a pixel:
const height = (r * 65536 + g * 256 + b) / 10 - 10000; // metresNodata cells encode as height 0, not as transparency. Tiles are produced for zooms 4 to UZMAP_TERRAIN_MAXZ, which defaults to 11 for GLO-90 and 12 for GLO-30. The build on this machine is 4–11.
TileJSON#
/terrain/tilejson.json reads data/build/terrain/meta.json (written by the pipeline with encoding, minzoom, maxzoom, source, attribution) and returns:
{
"tilejson": "3.0.0",
"tiles": ["https://uzmaps.ndc.uz/terrain/{z}/{x}/{y}.png"],
"minzoom": 4,
"maxzoom": 11,
"encoding": "mapbox",
"attribution": "© DLR/Airbus, Copernicus DEM (EU/ESA)",
"bounds": [55, 37, 73.5, 46]
}bounds is fixed in the server, not read from the build. The scheme and host are derived the same way as for the vector TileJSON. If meta.json is missing the response is 404 with {"error":"terrain tiles not built (run npm run data:terrain)"}. The document is cached for an hour rather than no-store, because unlike the vector TileJSON it carries nothing that has to be fresh.
The cartography adds a raster-dem source with tileSize: 256 and encoding: 'mapbox' from this URL, and leaves maxzoom to the TileJSON so it never asks for DEM tiles the build did not produce. @uzmaps/engine enables it with terrain: true, using terrainUrl if given and ${serverUrl}/terrain/tilejson.json otherwise.
Fonts#
/fonts/{fontstack}/{range}.pbfThis is the value of glyphs in the generated style. Three stacks are shipped, each a directory under data/fonts/:
{fontstack} | URL form |
|---|---|
Noto Sans Regular | /fonts/Noto%20Sans%20Regular/{range}.pbf |
Noto Sans Medium | /fonts/Noto%20Sans%20Medium/{range}.pbf |
Noto Sans Italic | /fonts/Noto%20Sans%20Italic/{range}.pbf |
{range} is a 256-codepoint block written start-end: 0-255, 256-511, … 65280-65535. All 256 ranges of the Basic Multilingual Plane are present for each stack. Noto Sans covers Latin including Uzbek oʻ/gʻ and Cyrillic including ў, қ, ғ, ҳ; the Cyrillic block is 1024-1279.
The server does not composite stacks. A comma-joined {fontstack} (Noto Sans Regular,Noto Sans Medium) names a directory that does not exist and returns 404. The generated style only ever names one font per layer, so this does not arise with it; if you write your own style, do the same.
The download script writes an empty file for any range the upstream does not have, so such a range would be served as 200 with an empty body rather than 404. In the current build no range is empty.
Files are served by Go's file server: Last-Modified is set, If-Modified-Since returns 304, and Cache-Control is one day.
Sprites#
/sprites/spriteThis is the value of sprite in the generated style; MapLibre appends .json, .png, @2x.json and @2x.png. All four are present, built at pixel ratio 1 and 2.
Each entry in sprite.json has x, y, width, height, pixelRatio and sdf; icons with a nine-patch definition also carry content, stretchX and stretchY. Badge icons (a coloured circle with a white glyph) are named poi-<name>; the icon property on places-overture features is <name> without that prefix. Hand-drawn icons keep their own names (marker, route-start, route-end, user-location, dot, peak, oneway, entrance, metro-m, …). SDF icons can be tinted at runtime with icon-color. The current sheet has 104 icons.
Styles#
/styles/uzmap-{theme}-{lang}.json theme: light | dark lang: uz | ru | enSix complete MapLibre style documents, produced by node data/pipeline/emit-style.mjs from the same buildStyle() the engine uses, for consumers that cannot run the TypeScript builder (native MapLibre SDKs, a plain maplibregl.Map).
Two things to know before pointing a client at one:
- The tile, glyph and sprite URLs inside are absolute and are baked in at emit time from
UZMAP_SERVER_URL, defaulting tohttp://localhost:8080. Emit them with the public origin set, or the style will send every tile request to localhost. - They carry no key. A client that must present one appends
?key=to each request itself, as in the example below.
The source is uzmap (type: vector, url: <server>/tiles/tilejson.json); glyphs and sprite are the URLs above. Layer ids are namespaced uz-*, and three invisible anchor layers (uz-anchor-overlay-fill, uz-anchor-overlay-line, uz-anchor-overlay-symbol) mark where overlays belong.
The browser SDK at /v1/#
/v1/ is served only when uzmap serve --sdk <dir> names a directory containing uzmaps.js; the container image starts with --sdk /app/sdk. Without it the prefix is not mounted. The directory holds what npm run build:sdk emits:
| File | Cache-Control |
|---|---|
uzmaps.js | public, max-age=3600, stale-while-revalidate=86400 |
uzmaps.js.map | public, max-age=3600 |
maplibre-gl-worker.mjs, maplibre-gl-shared.mjs | public, max-age=31536000, immutable |
The entry point cannot be immutable. /v1/uzmaps.js is the URL embedded in pages nobody will edit again, so a fix has to be able to reach them; an hour with stale-while-revalidate keeps it fast and lets a fix propagate within the hour. Source maps are only fetched by devtools. Everything else takes a year, immutable. Note that the chunk filenames are not fingerprinted, so a MapLibre upgrade changes the bytes behind an immutable URL; a browser holding a cached worker keeps it until the year is up.
The handler sets Access-Control-Allow-Origin: * itself because the bundle is loaded cross-origin by definition and the worker it starts inherits that. The path is never gated by a key: the script has to load before it can present one, and a JSON 401 where a page expects JavaScript is a syntax error, not a diagnosis. Everything the SDK then fetches is gated as usual.
The script reads its own tag: the key from ?apikey= (or ?key=), the server from the script's origin, and the worker from the directory it sits in.
<script src="https://uzmaps.ndc.uz/v1/uzmaps.js?apikey=YOUR_KEY"></script>The version prefix is the compatibility promise: a breaking change ships as /v2/ and /v1/ keeps working.
Behind the production proxy#
In the reference deployment an nginx side-car sits between Nginx Proxy Manager and the server. It changes nothing above except:
^/tiles/.*tilejson\.json$getsCache-Control: no-storeadded at the edge as well, so a misconfigured cache in between cannot resurrect a stale token./tiles/,/terrain/,/fonts/,/sprites/are proxied with access logging off and buffering on./v1/is proxied with no caching directive of its own, so the server's per-file rules stand.X-Forwarded-Protois forwarded from the TLS terminator, which is what makes the TileJSON URLs come out ashttps://./styles/is not in the map-data block and falls to the catch-alllocation /, which addsCache-Control: no-store. nginx'sadd_headerappends rather than replaces, so a style response from that deployment carries both the server's one-day header andno-store.
Examples#
Fetch the TileJSON and one tile with curl. Tashkent at z12 is tile 12/2835/1531.
KEY=YOUR_KEY
curl -s "https://uzmaps.ndc.uz/tiles/tilejson.json?key=$KEY" | jq '{tiles, minzoom, maxzoom, layers: [.vector_layers[].id]}'
# The body is stored gzip-compressed and served with Content-Encoding: gzip; --compressed decodes it.
curl -s --compressed -D - -o 12-2835-1531.pbf "https://uzmaps.ndc.uz/tiles/12/2835/1531.pbf?key=$KEY"Plain MapLibre against a served style, adding the key the way the engine does (assumes the styles were emitted with UZMAP_SERVER_URL=https://uzmaps.ndc.uz):
const KEY = 'YOUR_KEY';
const server = 'https://uzmaps.ndc.uz';
const map = new maplibregl.Map({
container: 'map',
style: `${server}/styles/uzmap-light-uz.json?key=${KEY}`,
center: [69.2401, 41.2995],
zoom: 12,
transformRequest: (url) =>
url.startsWith(server) && !url.includes('key=')
? { url: url + (url.includes('?') ? '&' : '?') + 'key=' + encodeURIComponent(KEY) }
: { url },
});@uzmaps/engine, choosing how the map data is read:
import { UzMap } from '@uzmaps/engine';
const map = new UzMap({
container: 'map',
serverUrl: 'https://uzmaps.ndc.uz',
apiKey: 'YOUR_KEY',
terrain: true, // raster-dem source from /terrain/tilejson.json
tiles: 'pmtiles', // range-read /tiles/uzbekistan.pmtiles instead of /tiles/{z}/{x}/{y}.pbf
sources: { furniture: false }, // the other supplementary archives are still auto-detected via /api/status
});Building a style yourself with the cartography package and explicit endpoints:
import { buildStyle } from '@uzmaps/cartography';
const style = buildStyle({
theme: 'dark',
language: 'ru',
serverUrl: 'https://uzmaps.ndc.uz',
tilesUrl: 'https://uzmaps.ndc.uz/tiles/tilejson.json',
glyphsUrl: 'https://uzmaps.ndc.uz/fonts/{fontstack}/{range}.pbf',
spriteUrl: 'https://uzmaps.ndc.uz/sprites/sprite',
terrainUrl: 'https://uzmaps.ndc.uz/terrain/tilejson.json',
terrainEncoding: 'mapbox',
});