UzMap docs

API keys, quotas and usage metering

Keys are off by default. A server checks them when started with --keys FILE, refuses requests without a valid one only when also started with --require-key, and meters traffic per key only when started with --usage FILE. Enforcement and metering are independent of each other, but both need --keys: without a key file there is nothing to enforce or to attribute traffic to, and the gate passes everything through untouched. The sections below say which behaviour depends on which.

Presenting a key#

A request may carry the key in either of two places:

FormExampleUse it for
X-API-Key headerX-API-Key: 3f9c…API calls, server-side code
?key= query parameter/tiles/14/11343/6124.pbf?key=3f9c…Tiles, glyphs, sprites, photo images: requests the browser issues itself, where you cannot set a header

The server reads ?key= first and falls back to the header, so when both are present the query parameter wins.

bash
curl -H "X-API-Key: $KEY" "https://uzmaps.ndc.uz/api/search?q=Chilonzor"
curl "https://uzmaps.ndc.uz/tiles/14/11343/6124.pbf?key=$KEY"

The SDKs attach the key for you:

js
import { UzMapClient } from '@uzmaps/api';

// Sends X-API-Key on every API call; photoUrl() appends ?key= because that URL ends up in an <img src>.
const api = new UzMapClient({ baseUrl: 'https://uzmaps.ndc.uz', apiKey: process.env.UZMAP_KEY });
js
import { UzMap } from '@uzmaps/engine';

// API calls carry the header; every resource MapLibre fetches from serverUrl (tiles, glyphs, sprites,
// terrain, TileJSON) gets ?key= appended unless the URL already carries key=.
// URLs pointing elsewhere are left alone, so the key is not leaked to a third-party source.
const map = new UzMap({ container: 'map', serverUrl: 'https://uzmaps.ndc.uz', apiKey: 'YOUR_KEY' });

The server's CORS response lists only Content-Type and Range in Access-Control-Allow-Headers; X-API-Key is not in that list, so a cross-origin page that sets the header should expect the browser's preflight to fail. Use ?key= from pages on a different origin to the server.

Which paths need a key#

gatedPath in server.go decides. Everything the map consumes is gated; liveness, the capability probe and the shell that has to load before it can present a key are not.

GatedNot gated
/api/* (including /api/usage), /tiles/*, /terrain/*, /fonts/*, /sprites/*, /styles/*/health, /api/status, /v1/* (the browser SDK bundle), the app shell

Without --require-key the checks still run and the outcome is metered, but nothing is refused — a request whose key would fail under enforcement is served and recorded as served.

The origin allowlist#

A browser key ships inside the page, so it cannot be a secret. What protects it is the origins list on the key: a copied key used from another site sends a different Origin and is refused. A key with no origins is unrestricted and accepted from anywhere, including from requests with no origin at all, so it is only appropriate server-side.

How the request's origin is determined#

requestOrigin in apikey.go:

  1. The Origin header, unless it is empty or the literal string null.
  2. Otherwise the Referer header, reduced to scheme://host.
  3. Otherwise no origin.

The result is lower-cased and a trailing / is removed. The host part keeps its port, so http://localhost:5173 is a different origin from http://localhost. An Origin: null (sandboxed iframes, file:// pages) counts as no origin.

A restricted key with no origin is refused with 403 — otherwise the allowlist could be bypassed by stripping headers.

Matching rules#

originAllowed in apikey.go. Each entry is lower-cased, trimmed and stripped of a trailing / before comparison.

EntryMatchesDoes not match
https://acme.uzhttps://acme.uz exactlyhttps://app.acme.uz, http://acme.uz, https://acme.uz:8443
https://*.acme.uzhttps://app.acme.uz, https://deep.app.acme.uz, and the bare https://acme.uzhttp://app.acme.uz, https://acme.uz.evil.com, https://notacme.uz
*every origin
acme.uz (no scheme)only a request whose Origin header is literally acme.uz, which no browser sendsevery browser origin — an entry without :// is compared by exact equality only and is not treated as a wildcard

Two rules are worth stating outright:

  • A *. wildcard matches the apex domain as well as subdomains. Otherwise every customer would file a bug the first time a visitor omitted www..
  • A wildcard never crosses schemes. https://*.acme.uz does not admit http://app.acme.uz, because an allowlist that did would let a downgraded page use the key.

The wildcard is only recognised as a leading *. on the host; https://app.*.uz or https://acme.* are not patterns.

Rate limits and monthly quotas#

These are different knobs and a key can have either, both or neither (Key struct, apikey.go; usage/quota.go).

Rate limitMonthly quota
Key file fieldrate_limit (int, requests per minute)monthly_quota (int64, requests per calendar month)
CLI flag on keys add--rate N--quota N
Zero meansuse the server default (--key-rate, 600/min)unlimited
Purposesmooths bursts so one caller cannot swamp the serverthe allowance a plan sells
What countsevery gated request whose key is known, enabled and (if restricted) from an allowed origin — billable or notadmitted, billable requests only
Periodcontinuous; a token bucketthe UTC calendar month; resets at 00:00 UTC on the 1st
Enforced when--require-key--require-key and --usage (the enforcer is created only when the metering database opened)
Refusal429, no Retry-After429 with Retry-After and RateLimit-* headers

Rate limit mechanics#

Store.allow in apikey.go keeps one token bucket per key. The bucket starts full at limit tokens, each admitted request takes one, and it refills at limit/60 tokens per second up to limit. A key can therefore burst a full minute's worth at once and then sustain limit per minute. Buckets are per key, so one customer cannot throttle another, and they survive a key-file reload.

/api/usage is on a gated path, so it is subject to the rate limit even though it is not billable.

Quota mechanics#

Quotas.Allow in usage/quota.go, called from requireKey in server.go:

  • The quota is consulted only after the key, origin and rate checks have passed, and only for billable products. A refused request never consumes allowance.
  • The check is against the month-to-date count of admitted billable requests, read from the metering database and cached per key for --usage-flush (30s by default), with everything served since added from the in-process counter. On a single server the limit is exact: the request that reaches the limit is the last one through. With several servers sharing one key, the overshoot is bounded by what the other servers served within one refresh interval.
  • Usage recorded before the process started counts; a restart does not hand out a fresh allowance.
  • If the metering database cannot be read, the request is allowed. Refusing paying customers over a counter file would turn an accounting problem into an outage.
  • The limit is passed on every check, not cached, so a changed monthly_quota applies to the next request after the key file reloads.

Order of checks#

For a gated path: key present → key known → key not disabled → origin allowed → rate limit → monthly quota. The first failure decides the response.

Refusals#

Two body shapes exist. The gate and the quota refusal send error, code, message and docs (the quota refusal adds quota); endpoint handlers that use writeErr send only error.

From the gate (--require-key only)#

All carry Content-Type: application/json; charset=utf-8 and Cache-Control: no-store. error is the standard status text.

StatusmessageCause
401missing API key — pass ?key=… or an X-API-Key headerno key in query or header
401unknown API keykey not in the key file
403this API key is disableddisabled: true on the key
403this API key is restricted to specific origins and the request sent nonekey has origins, request had no Origin/Referer
403origin https://evil.example is not allowed for this API keyorigin not on the allowlist
429rate limit exceededtoken bucket empty; no Retry-After is sent
json
{
  "error": "Forbidden",
  "code": 403,
  "message": "origin https://evil.example is not allowed for this API key",
  "docs": "https://uzmaps.ndc.uz/docs/api-keys"
}

Over quota#

Sent when --require-key and --usage are both on and the key's monthly_quota is spent. Headers, in addition to the two above:

HeaderValue
Retry-Afterseconds until the month resets (at least 1)
RateLimit-Limitthe monthly quota
RateLimit-Remaining0
RateLimit-Resetsame seconds as Retry-After
json
{
  "error": "Too Many Requests",
  "code": 429,
  "message": "monthly quota of 100000 requests exhausted; it resets on 1 October 2026 (UTC)",
  "quota": { "limit": 100000, "used": 100000, "resets": "2026-10-01T00:00:00Z" },
  "docs": "https://uzmaps.ndc.uz/docs/api-keys"
}

Retry-After can be weeks. A truthful long value is preferred to a short one that invites a client to keep hitting a limit that has not moved.

What the SDK surfaces#

UzMapClient throws UzMapApiError with status and a message taken from the body's error field, falling back to the HTTP status text (get/post, packages/api/src/index.ts). For gate refusals that field is the status text, so the human-readable message and the quota block are not available through the SDK. Read them with fetch when you need them:

js
const res = await fetch('https://uzmaps.ndc.uz/api/search?q=Chilonzor', { headers: { 'X-API-Key': key } });
if (res.status === 429) {
  const body = await res.json();
  console.log(body.message, body.quota, res.headers.get('Retry-After'));
}

Billable products#

Every gated request with a known key is classified into one product before it is counted. Product names appear in /api/usage and are intended to be stable.

ProductPaths
tiles/tiles/*, /terrain/*, /fonts/*, /sprites/*, /styles/*
search/api/search, /api/autocomplete, /api/suggest
geocoding/api/reverse, /api/lookup, /api/geocode
places/api/nearby, /api/categories, /api/districts, /api/place/*, /api/geometry/*, /api/photo, /api/photo/img/*
routing/api/route
matrix/api/matrix
isochrone/api/isochrone
static/static/*
otherany other gated path
meta/api/usage, /api/statusnot billable

/api/suggest, /api/geocode and /static/* are recognised by the classifier but have no handler in the current server; /static/* is also not a gated path, so static does not occur in practice.

Counting happens at the gate, before the handler runs, so a request to a gated path that then returns 404 is still counted under other.

What is not billable, and why#

TrafficTreatmentReason
/api/usage (product meta)never recorded, never charged against quotaa customer refreshing their dashboard must not consume the quota the dashboard reports on
/api/status, /health, /v1/*not gated, so never reach the classifierliveness and capability probes have to work before a key is presented
Refused requestsrecorded as denied, never as requests; never consume quotaa customer must not pay for calls the server rejected, and retries must not push a spent allowance further past its limit
Requests with no key or an unknown keynot recorded at allthey cannot be attributed to an account, and recording them would let anyone grow the database by guessing keys

Metering mechanics#

Enabled with --usage /path/usage.sqlite; independent of --require-key. Counters are aggregated in memory per (key, product, UTC hour) and flushed every --usage-flush (30s default) as an additive upsert, so a crash loses at most one interval and a retry cannot double-count. The server flushes once more on shutdown. If the database cannot be opened at startup, metering is logged as disabled and the server still starts.

Reading usage: GET /api/usage#

A key reads its own usage and nothing else — the key is the credential, so no login or admin role is involved. The key must be known to the server; on a server without --require-key that is the only condition. Reading usage is not billable and does not consume quota, so it keeps working after a quota 429. It is still on a gated path, so a rate-limit 429 applies to it like any other request.

Requires both --keys and --usage; otherwise it returns 503.

Parameters#

ParameterTypeDefaultEffect
daysinteger 1–366absentrolling window ending now instead of the current calendar month
seriesthe literal 1absentadds the hourly series array

Without days, the window runs from 00:00 UTC on the 1st of the current month to the end of the hour in progress. With days=N, it runs from N days before that end. Any other days value returns 400.

Response#

Cache-Control: no-store is set on every successful response (the error responses below do not carry it).

FieldTypeMeaning
keystringthe presented key
namestringthe key's name
from, toRFC 3339 UTCthe window; to is exclusive
requestsintegerbillable requests admitted across all products
deniedintegerrequests refused
productsarray of { key, product, requests, denied }per-product totals; an empty array, never null, for a quiet key
seriesarray of { hour, product, requests, denied }present only with series=1; hour is RFC 3339 UTC, ordered by hour then product
quota{ limit, used, remaining, resets }present only when the key has a monthly_quota and the metering database could be read — an unlimited key has no quota field

quota is reported whether or not the server enforces it, so a customer can see where they stand before enforcement is switched on. It is read with Quotas.Peek, which does not spend allowance.

bash
curl -H "X-API-Key: $KEY" "https://uzmaps.ndc.uz/api/usage"                 # current UTC month
curl -H "X-API-Key: $KEY" "https://uzmaps.ndc.uz/api/usage?days=7&series=1"  # last 7 days, hourly
json
{
  "key": "3f9c…",
  "name": "Acme delivery",
  "from": "2026-09-01T00:00:00Z",
  "to": "2026-09-05T13:00:00Z",
  "requests": 41230,
  "denied": 12,
  "products": [
    { "key": "3f9c…", "product": "tiles", "requests": 40100, "denied": 0 },
    { "key": "3f9c…", "product": "search", "requests": 1130, "denied": 12 }
  ],
  "quota": { "limit": 100000, "used": 41230, "remaining": 58770, "resets": "2026-10-01T00:00:00Z" }
}

Errors#

These use the short body shape, { "error": "…" }.

Statuserror
503usage metering is not enabled on this server
503API keys are not configured on this server
401a valid API key is required to read its usage
400days must be a whole number between 1 and 366

On a server with --require-key, the gate's own 401/403/429 apply first.

From the SDK#

UzMapClient.usage() in packages/api/src/index.ts:

js
import { UzMapClient } from '@uzmaps/api';

const api = new UzMapClient({ baseUrl: 'https://uzmaps.ndc.uz', apiKey: process.env.UZMAP_KEY });
const report = await api.usage({ days: 30, series: true });   // { days?, series?, signal? } → UsageReport

report.requests;             // billable total
report.denied;               // refused, never billed
report.products;             // UsageTotal[]
report.series;               // UsagePoint[], only because series: true
report.quota?.remaining;     // undefined when the key has no monthly quota

The key file and hot reload#

The key file is a JSON array (Key struct, apikey.go). Entries with an empty key are ignored.

FieldTypeNotes
keystring32 hex characters when generated by the CLI
namestringproject the key belongs to; required by keys add
originsstring[]omitted or empty means unrestricted
rate_limitintrequests per minute; 0 = server default
monthly_quotaint64billable requests per UTC month; 0 = unlimited
disabledboolrefused with 403 while true; no CLI flag sets it
notestringfree text
created_atRFC 3339set by keys add
bash
uzmap keys add    --keys /etc/uzmap/keys.json --name "Acme delivery" \
                  --origins "https://acme.uz,https://*.acme.uz" --rate 300 --quota 100000
uzmap keys list   --keys /etc/uzmap/keys.json
uzmap keys revoke --keys /etc/uzmap/keys.json --key <key>

keys add prints the new key on stdout and, when --origins is empty, warns on stderr that the key works from anywhere.

Reload#

The server does not need a restart to see changes:

  • Every --key-reload (5s default) the server stats the file and re-reads it when the modification time or size has changed. Polling rather than inotify, because the file arrives over a Docker bind mount and inotify does not see host-side writes.
  • Added keys become valid, revoked keys stop working, and edited origins, rate_limit, monthly_quota, name and disabled take effect at the next check.
  • Rate-limit buckets and in-process counters are kept across a reload, so issuing an unrelated key does not hand every customer a fresh burst. Buckets for keys that disappeared are dropped.
  • A file that is unreadable or not valid JSON is logged and ignored; the keys already in memory continue to be served, because a half-written file must not revoke every customer at once.
  • The watcher skips a file it cannot stat, so deleting the file does not revoke keys until the next restart, at which point a missing file loads as an empty set: with --require-key, every request is then refused.

A changed file is logged as api keys: reloaded, N issued; a failed one as api keys: reload failed, keeping N key(s) already loaded: ….

Map data © OpenStreetMap contributors, licensed under the ODbL. © 2026 National Development Community.