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:
| Form | Example | Use it for |
|---|---|---|
X-API-Key header | X-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.
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:
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 });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.
| Gated | Not 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:
- The
Originheader, unless it is empty or the literal stringnull. - Otherwise the
Refererheader, reduced toscheme://host. - 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.
| Entry | Matches | Does not match |
|---|---|---|
https://acme.uz | https://acme.uz exactly | https://app.acme.uz, http://acme.uz, https://acme.uz:8443 |
https://*.acme.uz | https://app.acme.uz, https://deep.app.acme.uz, and the bare https://acme.uz | http://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 sends | every 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 omittedwww.. - A wildcard never crosses schemes.
https://*.acme.uzdoes not admithttp://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 limit | Monthly quota | |
|---|---|---|
| Key file field | rate_limit (int, requests per minute) | monthly_quota (int64, requests per calendar month) |
CLI flag on keys add | --rate N | --quota N |
| Zero means | use the server default (--key-rate, 600/min) | unlimited |
| Purpose | smooths bursts so one caller cannot swamp the server | the allowance a plan sells |
| What counts | every gated request whose key is known, enabled and (if restricted) from an allowed origin — billable or not | admitted, billable requests only |
| Period | continuous; a token bucket | the 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) |
| Refusal | 429, no Retry-After | 429 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_quotaapplies 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.
| Status | message | Cause |
|---|---|---|
| 401 | missing API key — pass ?key=… or an X-API-Key header | no key in query or header |
| 401 | unknown API key | key not in the key file |
| 403 | this API key is disabled | disabled: true on the key |
| 403 | this API key is restricted to specific origins and the request sent none | key has origins, request had no Origin/Referer |
| 403 | origin https://evil.example is not allowed for this API key | origin not on the allowlist |
| 429 | rate limit exceeded | token bucket empty; no Retry-After is sent |
{
"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:
| Header | Value |
|---|---|
Retry-After | seconds until the month resets (at least 1) |
RateLimit-Limit | the monthly quota |
RateLimit-Remaining | 0 |
RateLimit-Reset | same seconds as Retry-After |
{
"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:
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.
| Product | Paths |
|---|---|
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/* |
other | any other gated path |
meta | /api/usage, /api/status — not 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#
| Traffic | Treatment | Reason |
|---|---|---|
/api/usage (product meta) | never recorded, never charged against quota | a customer refreshing their dashboard must not consume the quota the dashboard reports on |
/api/status, /health, /v1/* | not gated, so never reach the classifier | liveness and capability probes have to work before a key is presented |
| Refused requests | recorded as denied, never as requests; never consume quota | a 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 key | not recorded at all | they 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#
| Parameter | Type | Default | Effect |
|---|---|---|---|
days | integer 1–366 | absent | rolling window ending now instead of the current calendar month |
series | the literal 1 | absent | adds 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).
| Field | Type | Meaning |
|---|---|---|
key | string | the presented key |
name | string | the key's name |
from, to | RFC 3339 UTC | the window; to is exclusive |
requests | integer | billable requests admitted across all products |
denied | integer | requests refused |
products | array of { key, product, requests, denied } | per-product totals; an empty array, never null, for a quiet key |
series | array 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.
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{
"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": "…" }.
| Status | error |
|---|---|
| 503 | usage metering is not enabled on this server |
| 503 | API keys are not configured on this server |
| 401 | a valid API key is required to read its usage |
| 400 | days 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:
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 quotaThe key file and hot reload#
The key file is a JSON array (Key struct, apikey.go). Entries with an empty key are ignored.
| Field | Type | Notes |
|---|---|---|
key | string | 32 hex characters when generated by the CLI |
name | string | project the key belongs to; required by keys add |
origins | string[] | omitted or empty means unrestricted |
rate_limit | int | requests per minute; 0 = server default |
monthly_quota | int64 | billable requests per UTC month; 0 = unlimited |
disabled | bool | refused with 403 while true; no CLI flag sets it |
note | string | free text |
created_at | RFC 3339 | set by keys add |
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,nameanddisabledtake 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: ….