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#
| Aspect | Detail |
|---|---|
| Method | GET. Parameters are read from the query string only; a request body is ignored. |
| Response | application/json; charset=utf-8. HTML characters in names are not escaped. |
| Base URL | https://uzmaps.ndc.uz for the hosted service, or your own server's address when self-hosting. |
| API key | X-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. |
| CORS | Access-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.
lang | name is the first non-empty of |
|---|---|
uz (default) | names.uz, primary OSM name |
uz-Cyrl | names.uz-Cyrl, names.ru, primary OSM name |
ru | names.ru, names.uz-Cyrl, primary OSM name |
en | names.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.
| Field | Type | Meaning |
|---|---|---|
id | integer | Index entry id, for /api/place/{id}. Ids are assigned sequentially when the index is built, so they change between data builds. |
kind | string | place, street, poi, address or nature. |
category | string | Category id, e.g. food.cafe, place.district, street, address. /api/categories lists them. |
group | string | The category's group, e.g. food, transport, place. |
icon | string | Sprite icon name for the category, falling back to the group's icon, then poi. |
name | string | Display name in the requested language (table above). |
label | string | Secondary 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_label | string | Category label in the requested language. |
lat, lon | number | WGS84 degrees. |
bbox | [minlon, minlat, maxlon, maxlat] | Extent in degrees. For point features all four values repeat the point. |
distance | number, optional | Metres 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. |
address | object | See below. Always present, possibly {}. |
names | object, optional | Every 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. |
matched | string[], optional | Normalised query tokens that matched (search only). For an address query the house number is appended. |
rank | number | Importance in [0.02, 1], computed at index time from category, population, road class and tag richness. |
reason | string, optional | Which 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.
| Field | Meaning |
|---|---|
street | Street name as indexed (the primary OSM name). |
housenumber | House number. |
neighbourhood | Neighbourhood (mahalla). |
suburb | Suburb (mavze / massiv). |
city | City or town. |
district | District (tuman). |
region | Region (viloyat). |
postcode | Postcode. |
GET /api/search#
Free-text search over places, streets, points of interest, addresses and natural features.
Parameters#
| Parameter | Type | Default | Notes |
|---|---|---|---|
q | string | "" | Query text, any script. Leading and trailing whitespace is trimmed. An empty query returns an empty result list, not an error. |
lang | uz ru en uz-Cyrl | uz | Language for name, label and category_label. |
limit | integer | 10 | Maximum results. Capped at 50. Zero, negative or unparseable values become 10. |
lat, lon | number | none | Reference 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. |
bias | number | 0.5 | How much proximity counts, on a 0–1 scale. 0 is treated as unset and becomes 0.5. Values are not clamped. |
kinds | comma-separated | none | Restrict to these kinds: place, street, poi, address, nature. |
category | string | none | Restrict 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). |
bbox | minlon,minlat,maxlon,maxlat | none | Only entries inside the box. Must parse as exactly four numbers; otherwise it is ignored silently. |
Response#
| Field | Type | Meaning |
|---|---|---|
query | string | q after trimming. |
results | result[] | Ranked results, best first. [] when nothing matched. |
took_ms | number | Milliseconds spent searching and building the list, before JSON encoding. |
intent | object, optional | Present 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#
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'{
"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:
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'{
"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#
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: near → lat/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).
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'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):
- Lower-case.
- Transliterate Cyrillic to Uzbek-style Latin (
ч→ch,ш→sh,х→x,қ→q,ғ→g,ҳ→h,ў→o, …) and fold Latin diacritics and apostrophes (Koʻcha→kocha). - Split into alphanumeric tokens.
- Drop generic words —
kochasi,ulitsa,street,tumani,rayon,mahalla,shahar,prospekt,mfyand 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/kh→h, q→k, w→v, 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,Еда) setsintent.category. The comparison is on whole normalised tokens, sobozoris a category word butbozoriis 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.
reason | Stage |
|---|---|
exact | Every token matches an index token by prefix. |
fuzzy | Every token's phonetic skeleton matches an index skeleton by prefix. |
mixed | Multi-token queries only: each token is matched by exact prefix if possible, otherwise by skeleton prefix, and the sets are intersected. |
trigram | Only 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:
| Relation | Score |
|---|---|
| identical token | 1.0 |
| entry token starts with the query token | 0.8 + 0.15 × len(query token) ÷ len(entry token) |
| same phonetic skeleton | 0.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 token | 0.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 nameKind 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#
| Parameter | Type | Default | Notes |
|---|---|---|---|
lat, lon | number | required | WGS84 degrees. If either is missing, 0 or unparseable the response is 400 {"error":"lat and lon required"}. |
lang | uz ru en uz-Cyrl | uz | Language for label, short, street.name and place. |
Response#
| Field | Type | Meaning |
|---|---|---|
lat, lon | number | The query point, echoed. |
address | address | Assembled as described below. Fields are omitted when empty. address.street is the primary OSM name; street.name is the localised one. |
label | string | street[, 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). |
short | string | street[, housenumber], or the first non-empty of neighbourhood, district, city, region. |
street | object, optional | Nearest street within range: id, name (in lang), distance (see note), and lon/lat of the point on the street nearest to the query point. |
place | result, optional | A 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. |
admins | array or null | Every 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#
- Administrative polygons containing the point are collected and processed from the lowest
levelupwards:
| Level | Sets |
|---|---|
| 4 | city if the area is tagged place=city, otherwise region. |
| 5, 6 | city if tagged place=city and no city yet; otherwise district if none yet. |
| 7, 8 | city if tagged place=city or place=town and no city yet; otherwise district if none yet; otherwise suburb if none yet. |
| 9 | suburb. |
| 10 and above | neighbourhood. |
Levels 2 and 3 appear in admins but set no address field.
- The nearest named POI within 45 m becomes
place; the nearest address point within 140 m is remembered. - The nearest street within 160 m (class-weighted) becomes
streetand setsaddress.street. - 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 becomesplaceif no POI was. - If
placeis a POI with a house number and none has been set, the POI's street and house number are used. - If
cityorneighbourhoodis still empty, they are copied from any indexed entry within 1.5 km (city) or 500 m (neighbourhood).
Example#
curl -s "https://uzmaps.ndc.uz/api/reverse?lat=41.2995&lon=69.2401&lang=en" \
-H "X-API-Key: $UZMAPS_KEY"{
"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#
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#
| Parameter | Type | Default | Notes |
|---|---|---|---|
lat, lon | number | required | WGS84 degrees. Missing, 0 or unparseable → 400 {"error":"lat and lon required"}. |
name | string | "" | Feature name, any script. Trimmed. With an empty name only entries that have no name are considered. |
radius | number | 120 | Search radius in metres. No upper bound is enforced. |
lang | uz ru en uz-Cyrl | uz | Language 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 natureExample#
curl -s "https://uzmaps.ndc.uz/api/lookup?lat=41.3258649&lon=69.2368152&name=Chorsu&lang=en" \
-H "X-API-Key: $UZMAPS_KEY"{
"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#
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:
| Status | Body | When |
|---|---|---|
| 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:
| Status | message | When |
|---|---|---|
| 401 | missing API key — pass ?key=… or an X-API-Key header | No key sent. |
| 401 | unknown API key | Key not in the key file. |
| 403 | this API key is disabled | Key revoked. |
| 403 | this API key is restricted to specific origins and the request sent none | Key has an origin allowlist and the request carried no Origin or Referer. |
| 403 | origin <origin> is not allowed for this API key | Origin not on the allowlist. |
| 429 | rate limit exceeded | The per-key token bucket (requests per minute, with a one-minute burst) is empty. |
| 429 | monthly 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:
{"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.
import { UzMapApiError } from '@uzmaps/api';
try {
await api.search('Chorsu');
} catch (e) {
if (e instanceof UzMapApiError && e.status === 429) {
// back off
}
}Related endpoints#
/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.