UzMap docs

Search and geocoding

Four endpoints answer "where is X" and "what is at this point". /api/search and /api/autocomplete take text; /api/reverse takes a coordinate; /api/lookup resolves a named feature on the basemap to an indexed entry. All four read one in-memory index built by the data pipeline (uzmap index) from data/build/search.sqlite, so results are only as current as the last data build.

Everything here was checked against the server source (services/server/internal/server/server.go and the search, store, textnorm, taxonomy, apikey and usage packages) and the client source. The example responses were recorded from a server built from that source; entry ids and values depend on the data build.

Common behaviour#

AspectDetail
MethodGET. Parameters are read from the query string only; a request body is ignored.
Responseapplication/json; charset=utf-8. HTML characters in names are not escaped.
Base URLhttps://uzmaps.ndc.uz for the hosted service, or your own server's address when self-hosting.
API keyX-API-Key header or ?key= query parameter; the query parameter is checked first. Enforced only when the server runs with --require-key. /api/status stays open either way.
CORSAccess-Control-Allow-Origin: *, methods GET, POST, OPTIONS, allowed request headers Content-Type, Range. OPTIONS returns 204.
Metering/api/search and /api/autocomplete count towards the search product, /api/reverse and /api/lookup towards geocoding. See API keys and quotas.

Parameter parsing is lenient: a numeric parameter that fails to parse takes its default rather than producing an error. lang accepts uz, ru, en and uz-Cyrl; any other value becomes uz.

The server's own CORS preflight allows only Content-Type and Range as request headers. A browser on a different origin that sends X-API-Key triggers a preflight, and that header is not on the list; ?key= sends no custom header and needs no preflight. This describes the Go server itself, not any proxy in front of the hosted service.

Languages and names#

lang controls name, label and category_label in every result.

langname is the first non-empty of
uz (default)names.uz, primary OSM name
uz-Cyrlnames.uz-Cyrl, names.ru, primary OSM name
runames.ru, names.uz-Cyrl, primary OSM name
ennames.en, names.uz, primary OSM name

An entry with no name at all gets its category label as name. Category labels exist in uz, ru and en only; for uz-Cyrl the English label is used, because taxonomy.Label falls back to en and then uz.

The result object#

/api/search, /api/autocomplete and /api/lookup return results in this shape, and /api/reverse uses it for its place field. It is resultJSON in server.go and SearchResult in @uzmaps/api.

FieldTypeMeaning
idintegerIndex entry id, for /api/place/{id}. Ids are assigned sequentially when the index is built, so they change between data builds.
kindstringplace, street, poi, address or nature.
categorystringCategory id, e.g. food.cafe, place.district, street, address. /api/categories lists them.
groupstringThe category's group, e.g. food, transport, place.
iconstringSprite icon name for the category, falling back to the group's icon, then poi.
namestringDisplay name in the requested language (table above).
labelstringSecondary line for a result list, e.g. Metro station · Gulbozor mahallasi, Toshkent: the category (or area) label, then address parts joined with , . Parts equal to name, empty or repeated are dropped.
category_labelstringCategory label in the requested language.
lat, lonnumberWGS84 degrees.
bbox[minlon, minlat, maxlon, maxlat]Extent in degrees. For point features all four values repeat the point.
distancenumber, optionalMetres from the reference point (lat/lon on search; the query point on lookup and reverse). Absent when there is no reference point, and also when the distance is exactly 0, because the field is omitted for a zero value.
addressobjectSee below. Always present, possibly {}.
namesobject, optionalEvery stored name variant. Possible keys: uz, uz-Latn, uz-Cyrl, ru, en, kaa, tg, kk, alt_name, old_name, official_name, int_name, short_name, etymology. Only keys the entry has are present; the object is absent when there are none.
matchedstring[], optionalNormalised query tokens that matched (search only). For an address query the house number is appended.
ranknumberImportance in [0.02, 1], computed at index time from category, population, road class and tag richness.
reasonstring, optionalWhich matching stage produced the result: exact, fuzzy, mixed, trigram, address, street, category, name or lookup. Absent on /api/reverse's place.

Address#

All fields are strings and are omitted when empty.

FieldMeaning
streetStreet name as indexed (the primary OSM name).
housenumberHouse number.
neighbourhoodNeighbourhood (mahalla).
suburbSuburb (mavze / massiv).
cityCity or town.
districtDistrict (tuman).
regionRegion (viloyat).
postcodePostcode.

GET /api/search#

Free-text search over places, streets, points of interest, addresses and natural features.

Parameters#

ParameterTypeDefaultNotes
qstring""Query text, any script. Leading and trailing whitespace is trimmed. An empty query returns an empty result list, not an error.
languz ru en uz-CyrluzLanguage for name, label and category_label.
limitinteger10Maximum results. Capped at 50. Zero, negative or unparseable values become 10.
lat, lonnumbernoneReference point for distance and proximity ranking. Both must be non-zero; if either is missing or 0 the request runs without a reference point rather than failing.
biasnumber0.5How much proximity counts, on a 0–1 scale. 0 is treated as unset and becomes 0.5. Values are not clamped.
kindscomma-separatednoneRestrict to these kinds: place, street, poi, address, nature.
categorystringnoneRestrict to one category id (food.cafe) or a whole group (group:food). Overrides any category detected in q. With no name words in q this becomes a category listing (see Category browsing).
bboxminlon,minlat,maxlon,maxlatnoneOnly entries inside the box. Must parse as exactly four numbers; otherwise it is ignored silently.

Response#

FieldTypeMeaning
querystringq after trimming.
resultsresult[]Ranked results, best first. [] when nothing matched.
took_msnumberMilliseconds spent searching and building the list, before JSON encoding.
intentobject, optionalPresent when q contains a category word. category is the category id or group:<id>; browse is true when nothing but the category word was typed; label and icon describe that category in lang. Reflects q only — the category parameter does not appear here.

Example#

bash
curl -s -G 'https://uzmaps.ndc.uz/api/search' \
  -H "X-API-Key: $UZMAPS_KEY" \
  --data-urlencode 'q=Chorsu' \
  --data-urlencode 'lat=41.2995' --data-urlencode 'lon=69.2401' \
  --data-urlencode 'limit=2' --data-urlencode 'lang=en'
json
{
  "query": "Chorsu",
  "results": [
    {
      "id": 1608,
      "kind": "poi",
      "category": "transport.metro",
      "group": "transport",
      "icon": "metro",
      "name": "Chorsu",
      "label": "Metro station · Gulbozor mahallasi, Toshkent",
      "category_label": "Metro station",
      "lat": 41.3258649,
      "lon": 69.23681520000001,
      "bbox": [69.23681520000001, 41.3258649, 69.23681520000001, 41.3258649],
      "distance": 2944.4561914300443,
      "address": {
        "neighbourhood": "Gulbozor mahallasi",
        "city": "Toshkent",
        "district": "Shayhontohur Tumani"
      },
      "names": { "en": "Chorsu", "ru": "Чорсу", "uz": "Chorsu" },
      "matched": ["chorsu"],
      "rank": 1,
      "reason": "exact"
    }
  ],
  "took_ms": 0.534
}

The second result — the Chorsu neighbourhood, place.neighbourhood — is omitted above.

A category word on its own lists nearby members of that category and adds intent:

bash
curl -s -G 'https://uzmaps.ndc.uz/api/search' \
  -H "X-API-Key: $UZMAPS_KEY" \
  --data-urlencode 'q=kafe' \
  --data-urlencode 'lat=41.2995' --data-urlencode 'lon=69.2401' \
  --data-urlencode 'limit=2'
json
{
  "intent": { "browse": true, "category": "food.cafe", "icon": "cafe", "label": "Kafe" },
  "query": "kafe",
  "results": [
    {
      "id": 340437,
      "kind": "poi",
      "category": "food.cafe",
      "group": "food",
      "icon": "cafe",
      "name": "B&B Coffee House",
      "label": "Kafe · улица Сеульская, 7/2 стр, Toshkent",
      "category_label": "Kafe",
      "lat": 41.29852889,
      "lon": 69.24581904,
      "bbox": [69.24581904, 41.29852889, 69.24581904, 41.29852889],
      "distance": 489.8090594998979,
      "address": {
        "street": "улица Сеульская, 7/2 стр",
        "neighbourhood": "Tepa mahallasi",
        "city": "Toshkent",
        "district": "Yakkasaroy Tumani",
        "postcode": "100000"
      },
      "rank": 0.6000000000000001,
      "reason": "category"
    }
  ],
  "took_ms": 2.191
}

The second result (265 m away, rank 0.5) is omitted; it ranks below the first because a listing weighs importance and distance equally.

With @uzmaps/api#

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

const api = new UzMapClient({
  baseUrl: 'https://uzmaps.ndc.uz',
  apiKey: process.env.UZMAPS_KEY,
  language: 'en',
});

const { results, intent, took_ms } = await api.search('Chorsu', {
  near: { lat: 41.2995, lon: 69.2401 },
  limit: 2,
});

search(query, options) maps options onto the parameters above: nearlat/lon; bias, limit, category as they are; kinds (array, joined with commas); bbox (four-number array, joined with commas); language (overrides the client default); signal (an AbortSignal). Undefined options are not sent. Requests are aborted after timeout milliseconds (constructor option, default 15 000).

GET /api/autocomplete#

The same handler and parameters as /api/search, with one difference: limit is capped at 12 instead of 50. Matching is by token prefix, so a partial word such as chil already returns entries whose name tokens start with it (Chilanzar District, the Chilanzar suburb, and so on).

bash
curl -s -G 'https://uzmaps.ndc.uz/api/autocomplete' \
  -H "X-API-Key: $UZMAPS_KEY" \
  --data-urlencode 'q=chil' --data-urlencode 'limit=3' --data-urlencode 'lang=en'
js
const { results } = await api.autocomplete('chil', { limit: 3 });

autocomplete(query, options) takes the same options as search and sends limit=8 unless you pass one.

How matching and ranking work#

This describes what services/server/internal/search/query.go and internal/textnorm/textnorm.go implement; there is nothing beyond it.

Normalisation#

Every name in the index and every query goes through the same steps (textnorm.Norm):

  1. Lower-case.
  2. Transliterate Cyrillic to Uzbek-style Latin (чch, шsh, хx, қq, ғg, ҳh, ўo, …) and fold Latin diacritics and apostrophes (Koʻchakocha).
  3. Split into alphanumeric tokens.
  4. Drop generic words — kochasi, ulitsa, street, tumani, rayon, mahalla, shahar, prospekt, mfy and the like — unless the whole query consists of them, in which case they are kept.

Each remaining token also gets a phonetic skeleton (textnorm.Fuzzy): digraphs are collapsed, x/khh, qk, wv, vowels after the first letter are dropped and repeated letters merged. Chilonzor, Chilanzar and Чиланзар all produce the same skeleton, which is what lets a Russian spelling find an Uzbek name.

Query parsing#

Parse extracts two things from the token list before matching:

  • House number. Scanning from the end, a token that looks like a house number (12, 12a, 12/1, 12к1: starts with a digit, at most 8 characters, 1–4 digits), is not the first token, and is not preceded by another house-number-like token, is removed and kept as the house number.
  • Category phrase. The longest run of 1–3 consecutive tokens equal to a category synonym or label (kafe, dorixona, аптека, savdo markazi) or a group label (Ovqatlanish, Еда) sets intent.category. The comparison is on whole normalised tokens, so bozor is a category word but bozori is not. If no other tokens remain, the query is a pure category browse.

Candidate retrieval#

Candidates are the entries matching every significant query token. Four stages are tried in order; the first one that returns anything wins, and its name is returned as reason.

reasonStage
exactEvery token matches an index token by prefix.
fuzzyEvery token's phonetic skeleton matches an index skeleton by prefix.
mixedMulti-token queries only: each token is matched by exact prefix if possible, otherwise by skeleton prefix, and the sets are intersected.
trigramOnly when the joined query is at least 4 characters. Character trigrams of the query are compared with those of each entry's primary name; entries with Dice similarity ≥ 0.42 are candidates. This is the fallback for typos that change the skeleton.

In the exact and fuzzy stages a single-letter token in a multi-token query (A. Navoiy) does not restrict the candidate set. A prefix that expands to more than about 4,000 distinct index terms is cut off there, which is why a one-letter autocomplete query is not exhaustive.

Scoring#

Each candidate gets a text score. For every query token the best relation to any of the entry's tokens is taken:

RelationScore
identical token1.0
entry token starts with the query token0.8 + 0.15 × len(query token) ÷ len(entry token)
same phonetic skeleton0.72
entry skeleton starts with the query skeleton (query skeleton ≥ 2 characters)0.55 + 0.1 × length ratio
query token (≥ 4 characters) appears inside the entry token0.5

A query token with no relation at all removes the entry (single letters are skipped instead). The per-token scores are averaged, then: +0.06 when the first query token matches the entry's first token; −0.06 per extra significant token in the entry's name, capped at −0.18 (so Chilonzor outranks Chilonzor bozori for the query Chilonzor); +0.12 when the query equals the whole primary name.

The final score is

text × 0.6 + rank × 0.3 + kind prior + bias × 0.35 × proximity − 0.15 if the entry has no name

Kind priors: place 0.12, street 0.06, poi 0.05, nature 0.04, address 0. Proximity is 1 ÷ (1 + distance ÷ scale) and applies only when lat/lon were given. The scale is 4 km for POIs and addresses, 12 km for streets, 25 km for places (120 km for cities, towns, districts and regions) and 60 km for natural features — larger things stay findable from further away.

Address queries#

When a house number was parsed, only address and poi entries carrying a house number are considered. The house number must match exactly (1.0) or by prefix (0.75); the text score becomes text × 0.7 + house × 0.3, is scored as above plus 0.1, and the result carries reason address with the house number appended to matched. Streets whose text score exceeds 0.7 are appended as a fallback with 0.2 subtracted and reason street, so Amir Temur 12 still returns the avenue when no house 12 is indexed on it.

Category browsing#

A pure category query (kafe, аптека, or category= with no name words) is a listing rather than a text match. Each member of the category gets reason category and the score rank × 0.5 + 0.5 ÷ (1 + distance ÷ 2500) when a reference point was given, or rank alone otherwise; unnamed entries lose 0.1. The search area is bbox if given; otherwise the grid cells around lat/lon (cells are 0.01°, about 1.1 km), expanded to ±1, ±2, ±4, ±8, ±16 and ±32 cells until at least 3 × limit members are found; otherwise the whole index.

Because a category word can also be part of a proper name, a five-result name search with a high text threshold (0.95) runs alongside. Hits that are not members of the browsed category are re-scored on the browse scale plus 0.15 and merged in with reason name. Hits that are members are dropped, since the listing already ranks them by distance.

Deduplication#

Results with the same normalised name, kind and city within a radius collapse to the best-scored one: 250 m for POIs and addresses, 3 km for streets and small places, 5 km for natural features, 15 km for towns, 40 km for cities and districts, 250 km for regions and countries.

Filters#

kinds, category and bbox are applied to candidates before scoring, so a filter never changes the order among the entries that pass it.

GET /api/reverse#

Resolves a coordinate to an address, the nearest street, the nearest named point of interest and the administrative areas containing the point.

Parameters#

ParameterTypeDefaultNotes
lat, lonnumberrequiredWGS84 degrees. If either is missing, 0 or unparseable the response is 400 {"error":"lat and lon required"}.
languz ru en uz-CyrluzLanguage for label, short, street.name and place.

Response#

FieldTypeMeaning
lat, lonnumberThe query point, echoed.
addressaddressAssembled as described below. Fields are omitted when empty. address.street is the primary OSM name; street.name is the localised one.
labelstringstreet[, housenumber] or neighbourhood; then district (when it differs from the city); then city, or region when there is no city — joined with , . When nothing was found: Xaritadagi nuqta (uz and uz-Cyrl), Точка на карте (ru), Dropped pin (en).
shortstringstreet[, housenumber], or the first non-empty of neighbourhood, district, city, region.
streetobject, optionalNearest street within range: id, name (in lang), distance (see note), and lon/lat of the point on the street nearest to the query point.
placeresult, optionalA named POI within 45 m, otherwise the nearest address point within 140 m. distance is in metres and omitted when it is exactly 0. No reason.
adminsarray or nullEvery administrative polygon containing the point, ascending by level, as { "level": 6, "name": "Chilonzor Tumani", "osm_id": 2441810 }. null (not []) when the point is inside none.

street.distance is the value used to choose the street: the distance in metres multiplied by a road-class weight — 0.8 for motorway, trunk, primary and secondary; 1.0 for tertiary, residential, living street, unclassified and pedestrian; 1.8 for service and road-class; 2.5 for tracks, footways, paths, cycleways and steps; 1.2 otherwise. The weight makes a named avenue win over an adjacent service lane, so for minor roads the figure is larger than the geometric distance. It is rounded to one decimal place.

How the address is assembled#

  1. Administrative polygons containing the point are collected and processed from the lowest level upwards:
LevelSets
4city if the area is tagged place=city, otherwise region.
5, 6city if tagged place=city and no city yet; otherwise district if none yet.
7, 8city if tagged place=city or place=town and no city yet; otherwise district if none yet; otherwise suburb if none yet.
9suburb.
10 and aboveneighbourhood.

Levels 2 and 3 appear in admins but set no address field.

  1. The nearest named POI within 45 m becomes place; the nearest address point within 140 m is remembered.
  2. The nearest street within 160 m (class-weighted) becomes street and sets address.street.
  3. If the address point is within 60 m, or no street was found, or it lies on that street, its street and house number replace address.street/housenumber, and it becomes place if no POI was.
  4. If place is a POI with a house number and none has been set, the POI's street and house number are used.
  5. If city or neighbourhood is still empty, they are copied from any indexed entry within 1.5 km (city) or 500 m (neighbourhood).

Example#

bash
curl -s "https://uzmaps.ndc.uz/api/reverse?lat=41.2995&lon=69.2401&lang=en" \
  -H "X-API-Key: $UZMAPS_KEY"
json
{
  "address": {
    "street": "Seul ko'chasi",
    "neighbourhood": "Charx Kamolon-1 mahallasi",
    "city": "Toshkent",
    "district": "Chilonzor Tumani"
  },
  "admins": [
    { "level": 2, "name": "Oʻzbekiston", "osm_id": 196240 },
    { "level": 4, "name": "Toshkent", "osm_id": 2216724 },
    { "level": 6, "name": "Chilonzor Tumani", "osm_id": 2441810 }
  ],
  "label": "Seul ko'chasi, Chilonzor Tumani, Toshkent",
  "lat": 41.2995,
  "lon": 69.2401,
  "place": {
    "id": 340329,
    "kind": "poi",
    "category": "shopping.clothes",
    "group": "shopping",
    "icon": "clothes",
    "name": "Marina Rinaldi",
    "label": "Clothes · Botir Zakirov St. 7, Tashkent",
    "category_label": "Clothes",
    "lat": 41.2995,
    "lon": 69.2401,
    "bbox": [69.2401, 41.2995, 69.2401, 41.2995],
    "address": {
      "street": "Botir Zakirov St. 7",
      "neighbourhood": "Kamolon Darvoza mahallasi",
      "city": "Tashkent",
      "district": "Chilonzor Tumani",
      "postcode": "100000"
    },
    "rank": 0.44999999999999996
  },
  "short": "Seul ko'chasi",
  "street": {
    "distance": 121,
    "id": 343669,
    "lat": 41.298155,
    "lon": 69.239765,
    "name": "Seoul Street"
  }
}

place has no distance here because the POI sits exactly on the query point and a zero distance is omitted.

With @uzmaps/api#

js
const rev = await api.reverse(41.2995, 69.2401);
console.log(rev.label, rev.street?.name, rev.place?.name);

reverse(lat, lon, { language, signal }) — latitude first. Coordinates are sent rounded to six decimal places.

GET /api/lookup#

Resolves a feature the user clicked on the basemap — a name and a position taken from the vector tile — to the indexed entry it most likely is, so that /api/place/{id} can be called for it. Names are compared by phonetic skeleton, so the tile label may be in a different script from the index.

Parameters#

ParameterTypeDefaultNotes
lat, lonnumberrequiredWGS84 degrees. Missing, 0 or unparseable → 400 {"error":"lat and lon required"}.
namestring""Feature name, any script. Trimmed. With an empty name only entries that have no name are considered.
radiusnumber120Search radius in metres. No upper bound is enforced.
languz ru en uz-CyrluzLanguage for the result's name, label and category_label.

Response#

{ "result": <result> }, or { "result": null } when nothing within radius matched. The result carries reason: "lookup" and distance in metres (equirectangular approximation); matched is not set.

Matching#

Every entry within radius is scored and the best one returned. The name is normalised, reduced to significant tokens and then to phonetic skeletons; hits is how many of those skeletons appear among the entry's name variants. Entries with no hit are skipped. The score is

hits ÷ tokens + 0.3 ÷ (1 + distance ÷ 30) + rank × 0.2 + 0.1 for poi, place and nature

Example#

bash
curl -s "https://uzmaps.ndc.uz/api/lookup?lat=41.3258649&lon=69.2368152&name=Chorsu&lang=en" \
  -H "X-API-Key: $UZMAPS_KEY"
json
{
  "result": {
    "id": 1608,
    "kind": "poi",
    "category": "transport.metro",
    "group": "transport",
    "icon": "metro",
    "name": "Chorsu",
    "label": "Metro station · Gulbozor mahallasi, Toshkent",
    "category_label": "Metro station",
    "lat": 41.3258649,
    "lon": 69.23681520000001,
    "bbox": [69.23681520000001, 41.3258649, 69.23681520000001, 41.3258649],
    "distance": 1.1866594788557642e-9,
    "address": {
      "neighbourhood": "Gulbozor mahallasi",
      "city": "Toshkent",
      "district": "Shayhontohur Tumani"
    },
    "names": { "en": "Chorsu", "ru": "Чорсу", "uz": "Chorsu" },
    "rank": 1,
    "reason": "lookup"
  }
}

The same request with name=Чорсу returns the same entry.

With @uzmaps/api#

js
const { result } = await api.lookup(41.3258649, 69.2368152, 'Chorsu');
if (result) {
  const details = await api.place(result.id);
}

lookup(lat, lon, name, { radius, language, signal }).

/api/geocode#

There is no /api/geocode endpoint. It is not registered in routes() in server.go, and a request to it returns the server's plain-text 404. Forward geocoding is /api/search (with kinds=address, or a house number in q); reverse geocoding is /api/reverse. The path does appear in the usage classifier — usage.Classify would file it under geocoding; /api/suggest also appears in the classifier but is filed under search, not geocoding. Neither path is served.

Errors#

Two error shapes exist, depending on which layer refused the request.

Handler errors carry only error:

StatusBodyWhen
400{"error":"lat and lon required"}/api/reverse or /api/lookup without a usable lat and lon.
503{"error":"search index not built"}data/build/search.sqlite was missing when the server started. Applies to all four endpoints.

Key-enforcement errors — only when the server runs with --require-key — carry error (the HTTP status text), code, a human-readable message and docs, and are sent with Cache-Control: no-store:

StatusmessageWhen
401missing API key — pass ?key=… or an X-API-Key headerNo key sent.
401unknown API keyKey not in the key file.
403this API key is disabledKey revoked.
403this API key is restricted to specific origins and the request sent noneKey has an origin allowlist and the request carried no Origin or Referer.
403origin <origin> is not allowed for this API keyOrigin not on the allowlist.
429rate limit exceededThe per-key token bucket (requests per minute, with a one-minute burst) is empty.
429monthly quota of N requests exhausted; it resets on <date> (UTC)Monthly allowance spent. The body also has quota: { limit, used, resets }, and the response carries Retry-After, RateLimit-Limit, RateLimit-Remaining: 0 and RateLimit-Reset (seconds until the reset).

With no key:

json
{"code":401,"docs":"https://uzmaps.ndc.uz/docs/api-keys","error":"Unauthorized","message":"missing API key — pass ?key=… or an X-API-Key header"}

@uzmaps/api throws UzMapApiError for any non-2xx status. Its status is the HTTP status and its message is the body's error field — which for key errors is the status text (Unauthorized), not the sentence in message. A request that exceeds the client's timeout (default 15 000 ms) is aborted.

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

try {
  await api.search('Chorsu');
} catch (e) {
  if (e instanceof UzMapApiError && e.status === 429) {
    // back off
  }
}

/api/nearby (entries within a radius, no text) and /api/place/{id} (full details for a result) are documented under Places; they are metered as the places product. The client is described in full at @uzmaps/api.

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