Search
Search
Free-text search over Navvo's place catalogue. One query box handles Arabic, English, Arabizi (Arabic written in Latin letters and digits, such as saydaliyeh or mat3am) and mixed queries. Navvo recognises category words (pharmacy, صيدلية), areas (cafe in Abdoun), "near me" and "open now" phrases, tolerates typos, and ranks by how well a place's name matches before it weighs distance and popularity.
Query understanding is deterministic: it is a set of rules and curated vocabularies, not an AI model, so the same query always reads the same way and never waits on a model. Everything runs on Navvo's own infrastructure.
Typo tolerance covers short words too. Take an English word of four to six letters that no place name uses but that is one letter away from a word place names do use. Navvo also searches its corrected form next to the query as typed, and ranks the two result sets together: cafr finds cafés and hotl finds hotels. The correction comes from the words in Navvo's own place names and favours the commonest typing slips, such as a neighbouring key or two swapped letters. Arabizi words are never corrected this way, because they are Arabic written in Latin letters, not misspellings.
Scope required: search
| Endpoint | Use it for |
|---|---|
GET /search | The results list. |
GET /search/suggestions | Autocomplete suggestions while the user types. |
GET /search/similar | "More like this" on a place page. |
POST /search/events | Reporting which result the user opened, with their consent. |
localeis now honoured and echoed back. It used to be dropped, so every response saiden.lat/lonare range-checked, and a coordinate off the globe returns400.qis truncated to 200 characters.categoryincludes sub-categories:category=food-and-drinkreturns cafés, restaurants and bakeries.understandingcomes from the deterministic parser and is always available. Its old model-generated contents are gone.
GET /search
Search for places by free-text query.
curl -s "https://navvo.io/api/v1/search?q=coffee&lat=31.9539&lon=35.9106&limit=10" \
-H "x-api-key: nvvo_YOUR_KEY"
Query parameters
coffee in Abdoun, صيدلية مناوبة قريبة, saydaliyeh, Abdali Mall. It must not be blank. Anything past 200 characters is ignored.en or ar, or a full language tag such as ar-JO or en-US (up to 35 characters). Tags that start with ar resolve to ar and everything else to en, and the response echoes the resolved value. locale never filters results: Arabic and English names both match whichever you send.1; values below 1 are raised to 1.25 and is clamped to 1–100. Out-of-range values are clamped, not rejected.west,south,east,north. Only places inside the box are returned, and the response echoes bbox. A malformed value is ignored.−90 to 90. Send it together with lon. Nearer places rank higher within the same match band (see Ranking), but the origin does not filter anything unless you also send radiusKm. The response echoes near: { lat, lng }. A non-numeric or out-of-range value returns 400.−180 to 180. lng is accepted as an alias, and sending both with different values returns 400.lon.device (default) means the user's own position. map means the centre of the map they are looking at: distance then counts for less and fades over a wider radius, and items carry no distanceKm. Send map when you bias a plain-text search toward the viewport rather than toward the user.category=food-and-drink covers cafés, restaurants and bakeries; category=pharmacy covers pharmacies only. Send several slugs separated by commas to match any of them: category=cafe,coffee-shop (up to 10). Slugs come from GET /places/categories; a value that is not a category matches nothing. The response echoes category.submit (default) for a search the user asked for, e.g. by pressing Enter. typeahead for requests fired while they type: prefix matches count as strong, popular places weigh more (the user is probably typing a well-known name), and meaning-based matching never runs. It also tells Navvo's analytics which requests were keystrokes.auto (default), on or off. See Meaning-based matching. With an API key, auto behaves like off, so send on to opt in.category, amenities, or both. Unknown names are ignored. See Facets.true, only places that are open right now are returned. This is evaluated in Jordan time and accounts for public holidays. Places without opening-hours data are left out.0–5.1,2): 1 up to JOD 5 per person, 2 JOD 5–10, 3 JOD 10–20, 4 more. A place matches when the midpoint of its listed price range, converted to dinars, falls in one of the levels. Places without a listed price are left out, and only a few thousand places list one (mostly cafés and restaurants), so a price filter returns far fewer results.relevance (default), distance (nearest first; needs lat/lon) or rating (highest first). distance and rating replace the relevance order entirely.lat/lon, from 0.1 to 500. It needs an origin.wheelchair-accessible-entrance,delivery. They are the same option slugs as GET /places/facets → options[].slug, or you can take them from facets=amenities.false and Navvo's search analytics keep no record of this search. It is the same as the X-Navvo-Analytics: off header, and the right setting for tests, health checks and bulk jobs.highlight, openNow, analytics) read true or 1 as true. Any other value, including false, 0 or yes, counts as false. Because analytics defaults to on, any value other than true/1 switches it off.Request headers
These headers are all optional. The first four control what Navvo records; Search analytics & privacy explains the model behind them.
POST /search/events can be linked to this search, and a burst of keystroke searches counts as one. Format: 16–64 characters from A–Z a–z 0–9 _ -, with no run of six or more digits. Any other value is ignored. Never derive it from a user id, email address, phone number or device identifier. Navvo stores only a keyed hash of it computed together with the date, so hashes from different days can't be linked.off keeps this search out of Navvo's search analytics, the same as analytics=false.1 means the user's browser asks not to be tracked (Global Privacy Control). Navvo then ignores X-Navvo-Session and rejects events for this search. The search record is still kept, but without a session link.1 is treated exactly like Sec-GPC: 1. If you proxy searches through your own server, forward both headers from your user's request.sdk marks requests from a client SDK built on Navvo; other values are reserved for Navvo's own apps. API-key requests without sdk are reported as api. Navvo's own SDKs send sdk automatically: Web and Node from 0.2.1, Python, Go and Java from 0.2.0. If the browser SDK reaches the API through a proxy of your own, allow this request header in the proxy's CORS policy.Response
| Field | Type | Description |
|---|---|---|
query | string | Your query, echoed. |
locale | string | The resolved locale: en or ar. |
queryId | string | A 24-character id for this result list. Pass it to POST /search/events when the user opens a result. It is opaque: it contains no query text or user data, and a new one is issued on every request. |
understanding | object | How the query was read. See Query understanding. |
places.items[] | array | The ranked results (see below). Empty when nothing matches: Navvo never pads the list with unrelated places. |
places.total | number | How many places match. On the index this is an estimate, capped at 1,000, so treat it as "about N results". It is 0 only on a genuine zero-match. |
places.page / places.limit | number | Pagination echo. |
facets | object | Only when you asked for facets. See Facets. |
suggestions[] | array | Only when places.items is empty: popular places near the origin to offer as "no results — try these". Same shape as places.items[]. See No results. |
didYouMean | string | Only on a zero-match that looks like a misspelling: a corrected query taken from Navvo's vetted vocabulary. Plain text. |
bbox / category / near | — | Echo of the bbox, category and origin you sent. |
index | object | Search index status: { provider, configured, available, mode }. See Index status & monitoring. |
Every response carries Cache-Control: no-store. Each response has a fresh queryId, so a cached copy would tie the user's clicks to someone else's search. Don't cache /search responses in a service worker or CDN.
Each places.items[] element:
| Field | Type | Description |
|---|---|---|
id | string | Place id. |
slug | string | URL slug, for GET /places/{slug}. |
nameEn / nameAr | string | null | Names. |
nameEnTransliterated | boolean | Present, as true, only when nameEn was produced by spelling the Arabic name in Latin letters because no English name is known (about 35,000 places). Show nameAr first for these, and don't present nameEn as an official name. |
categories | string | Canonical category slugs, primary category first. |
canonicalCategorySlug | string | null | Primary category. |
rawCategories | string | Original, un-normalised categories. |
rating | number | Rating, or 0 if there is none. |
sponsored | boolean | A sponsored listing, or a paid placement from a Navvo rule. |
promoted | boolean | Present, as true, only when a Navvo editorial or sponsored rule placed this result. Label it in your UI (for example "Promoted"). |
center | string | Location as WKT POINT(<lng> <lat>), longitude first. |
lat / lng | number | null | The same coordinates as numbers, or null when center is missing. |
iconLightUrl / iconDarkUrl | string | null | Category pin icons, as absolute URLs. See Places → Category marker icons. |
imageUrl | string | null | The place's picture, as an absolute URL, or null when there is none. See Places → Place image. |
distanceKm | number | Straight-line distance from lat/lon, in kilometres. Present only with a device origin (originType=device, the default). |
highlight | object | With highlight=true: { nameEn?, nameAr? }. See Highlights. |
reviewCount | number | How many reviews rating is based on. Present when known. |
categoryName | string | The primary category's display name in the response locale, such as Coffee Shop or مقهى. |
priceRange | string | The usual spend per person as listed, such as JOD 5–10. Present when known. |
priceLevel | number | 1–4, the level price filters on, worked out from priceRange (like $ to $$$$). Present when priceRange can be read. |
address | string | A short street address for one line, in the response locale's script when the place has one, such as Al-Mutanabbi St. 22. |
area | string | The neighbourhood or district, such as Zahran. |
hours | object | Today's opening state in Jordan time. See Result details. Absent when the place has no opening hours. |
highlights | string | Up to three things the place is known for, such as Great coffee or Rooftop seating. In English. |
{
"query": "coffee",
"locale": "en",
"queryId": "AaDxmIYgiEC1I4ZdHCWiERmR",
"understanding": {
"unavailable": false,
"purpose": "search_understanding",
"parsed": { "language": "en", "intent": "place", "category": "Coffee Shop", "categories": ["coffee-shop", "cafe", "coffee-stand", "cafeteria", "cat-cafe", "tea-house"] }
},
"places": {
"items": [
{
"id": "e3bacbd9-1e5d-474c-b84e-2fe7e1556541",
"slug": "coffee-factory-co-jabal-amman-a3ddbd",
"nameEn": "Coffee Factory & Co. - Jabal Amman",
"nameAr": null,
"categories": ["coffee-shop"],
"canonicalCategorySlug": "coffee-shop",
"rawCategories": ["Coffee shop"],
"rating": 4.6,
"sponsored": false,
"center": "POINT(35.905152 31.952061)",
"lat": 31.952061,
"lng": 35.905152,
"iconLightUrl": "https://navvo.io/uploads/category-icons/coffee-shop-light.svg",
"iconDarkUrl": "https://navvo.io/uploads/category-icons/coffee-shop-dark.svg",
"imageUrl": "https://navvo.io/uploads/place-images/e3/e3bacbd9-1e5d-474c-b84e-2fe7e1556541-dc3949fa.webp",
"distanceKm": 0.55
}
],
"total": 1000,
"page": 1,
"limit": 10
},
"near": { "lat": 31.9539, "lng": 35.9106 },
"index": { "provider": "navvo-search", "configured": true, "available": true, "mode": "keyword" }
}
Query understanding
understanding tells you how Navvo read the query. The results already reflect it, so you don't have to act on it. Use it for interface hints instead, such as showing an "Open now" chip when the user typed "open now". A set of rules produces it with no model involved, so identical queries always read identically and unavailable is always false. The field is kept for compatibility.
Everything useful sits under understanding.parsed:
| Field | Type | Description |
|---|---|---|
language | string | ar, en, arabizi, mixed or unknown. |
intent | string | category: the whole query names a category (pharmacy, صيدلية قريبة). food: a dish or type of food (shawarma, كنافة). place: anything else, such as a name, a mix, or a category word inside a longer query. |
category | string | Display name of the main category the query refers to. It also appears when a category word sits inside a longer query. |
categories | string | Up to 6 category slugs the query matched. |
near | string | "me" when the query asks for nearby places (near me, قريب مني, 2reeb). |
openNow | boolean | true when the query asks for places that are open (open now, مفتوح). |
locality | string | An area named in the query, such as Abdoun in cafe in Abdoun. |
qualifier | string | best, cheap, open24 (open around the clock), onDuty (on-duty pharmacies, مناوبة) or lateNight. |
Keys appear only when they apply. For cafe in Abdoun:
"parsed": { "language": "en", "intent": "category", "category": "Coffee Shop", "categories": ["coffee-shop", "cafe", "coffee-stand", "cafeteria", "cat-cafe", "tea-house"], "locality": "Abdoun" }
saydaliyeh is also tried as Arabic script, and an Arabic query is also tried in Latin letters, because many places in the catalogue have transliterated English names. You never need to transliterate before calling.Facets
facets=category,amenities adds counts you can turn into filter chips:
"facets": {
"category": [
{ "slug": "coffee-shop", "name": { "en": "Coffee Shop", "ar": "مقهى" }, "count": 812 },
{ "slug": "cafe", "name": { "en": "Cafe", "ar": "مقهى" }, "count": 108 },
{ "slug": "coffee-roastery", "name": { "en": "Coffee Roastery", "ar": "محمصة قهوة" }, "count": 10 }
],
"amenities": [
{ "slug": "wheelchair-accessible-entrance", "count": 301 },
{ "slug": "delivery", "count": 188 }
]
}
- Each list is sorted by
count, highest first. A category'sname.enorname.arcan benull. - Counts cover places that match the query's words and your filters.
itemscan also include nearby or meaning-based results, so the counts guide a filter UI rather than break down the list exactly. - Pass a
slugstraight back ascategory=oramenities=to apply the filter. facetsis absent when you didn't ask for it, and can be absent when the fallback answered (index.available: false).
Highlights
With highlight=true, an item whose name matches the query carries highlight.nameEn and/or highlight.nameAr. Each is the name, HTML-escaped, with the matched words wrapped in <em>…</em>. A key is present only when that name contains a match:
"highlight": { "nameEn": "<em>Coffee</em> Factory & Co. - Jabal Amman" }
The string is safe to insert as HTML. The most robust approach, though, is to render it as text nodes: split on the tags and decode the entities yourself, and never pass it to innerHTML or dangerouslySetInnerHTML:
// "<em>Coffee</em> Factory & Co." -> [{ text: "Coffee", match: true }, { text: " Factory & Co.", match: false }]
function highlightSegments(html) {
const decode = (s) => s
.replace(/</g, "<").replace(/>/g, ">")
.replace(/"/g, '"').replace(/'/g, "'")
.replace(/&/g, "&");
return html.split(/(<em>.*?<\/em>)/g).filter(Boolean).map((part) => {
const m = part.match(/^<em>(.*)<\/em>$/);
return { text: decode(m ? m[1] : part), match: Boolean(m) };
});
}
didYouMean and every field of GET /search/suggestions are plain text and carry no markup.
Result details
An item can carry what a results list or a map card shows: reviewCount, categoryName, priceRange, priceLevel, address, area, hours and highlights. Each comes from the place record and is left out when the place has no such data, so check that a field is there before you use it. The same fields appear on zero-result suggestions and on similar places.
hours describes today in Jordan time. It is worked out when the search runs, so don't cache a response for more than a few minutes.
| Field | Type | Description |
|---|---|---|
state | string | open, closing (closes within 45 minutes) or closed. |
alwaysOpen | boolean | true when the place is open around the clock: every day, or at least for the next 24 hours (several 24-hour days in a row). There is no closesAt then. |
closesAt | string | HH:MM: when an open place closes. |
opensAt | string | HH:MM: when a closed place next opens, within the coming 7 days. |
opensDay | string | The weekday of that opening, sunday to saturday, when it is not today. |
hoursMayVary | boolean | true during a public holiday in the place's country: today's hours may differ. |
"hours": { "state": "closed", "opensAt": "09:00", "opensDay": "sunday" }
A place that closes at 2 a.m. is still open after midnight, with "closesAt": "02:00".
Ranking
Every candidate first lands in a match band. Bands never mix, so a better band always ranks above a worse one:
- Exact. The query is the place's name or one of its alternative names, including after a spelling variant (an Arabizi query converted to Arabic, for example).
- Strong. The name contains every word of the query spelled correctly, or, with
mode=typeahead, starts with what the user typed. - Complete. Every word matches, allowing for typos, or the words match across the name, category and neighbourhood (
cafe abdounfinds a café in Abdoun), or the place belongs to the category the query asks for. - Partial. Only some of the words match.
- Meaning-based. The place was found only by meaning-based matching.
Within a band, Navvo combines distance from the origin, popularity (review volume and map prominence), text relevance, typos, and editorial boosts. A few rules shape that order:
- Category searches level the field. For
pharmacyorصيدلية قريبة, a place literally named "Pharmacy" gets no head start. Nearby, popular places in the category lead. - Areas move the origin. In
cafe in Abdoun, results rank around Abdoun rather than aroundlat/lon. Without a connecting word (cafe abdoun), your origin stays and places in Abdoun get a boost. If the whole query is an area, Navvo searches for it by name. - Supporting features step back. Bus stops, car parks, taxi stands, gates, entrances and ATMs rank below the main place unless the query asks for them.
- Jordan first. Without an origin, places in Jordan rank ahead of equally good matches abroad.
- A far branch doesn't outrank nearby ones. With an origin, an exact name match more than 25 km away competes on distance with nearer places whose names contain the query, so
carrefourfrom Amman lists Amman's branches first. A major place keeps its lead however far it is (Petra,Al-Aqsa Mosque). - "Open now" in the query demotes places known to be closed (open first, then unknown hours, then closed) even without
openNow=true.openNow=trueremoves everything that isn't open. - Sponsored places that are relevant to the query get a small boost within their band. They never jump to a better band. Results pinned by a Navvo rule carry
promoted: true, and alsosponsored: truewhen the placement is paid.
sort=distance and sort=rating replace this order entirely.
Meaning-based matching
Keyword matching finds places whose names and categories contain the query's words. Meaning-based matching also finds places that fit a description, such as quiet place to study or مكان للعشاء مع العائلة, even when no word matches, including across Arabic and English. It runs entirely on Navvo's own infrastructure, so query text never goes to a third party.
- It is only a fallback. It adds results only when keyword matching finds few strong matches. A query that already has good name matches is not changed, and meaning-based results always rank below keyword results (band 5).
- It needs some evidence in the query: at least one word must match the catalogue (typos allowed), or the query must name a category or carry a filter. Random letters and off-topic questions get no meaning-based results.
- It needs a query of at least two complete words,
mode=submit, and a query that isn't a pure category search. It never runs fortypeahead. - Navvo waits at most 350 ms for it. If it is slower or unavailable, you get the keyword results without it and no error.
semantic=offdisables it.semantic=onallows it on API-key requests, whereautobehaves likeoff.index.modeon the response ishybridwhen meaning-based matching contributed to that response, andkeywordotherwise.
No results
When nothing matches, places.items is empty and places.total is 0. Navvo never fills the list with unrelated places. Instead:
suggestions[]lists popular places near the origin, or popular places in Jordan when there is no origin, in the same shape as the items. Show them under a heading such as "No results — popular places", not as results.didYouMeanappears when the query looks like a misspelling of something in Navvo's vetted vocabulary (category names and well-known place names). Offer it as a link that re-runs the search.
Pagination
page and limit page through one ranked list. Each query ranks at most its top 200 candidates, so stop when a page comes back with fewer than limit items. Don't compute the last page from places.total, which is an estimate.
To fetch a place's full details (hours, photos, entrances), call GET /places/{slug} with the slug from a result.
Errors
| Status | When |
|---|---|
400 | q is missing or blank; a parameter is outside its allowed values or range (originType, mode, semantic, sort, minRating, radiusKm, a locale longer than 35 characters, lat outside −90…90, lon/lng outside −180…180); lon and lng were both sent with different values; or sort=distance or radiusKm was sent without lat/lon. |
401 / 403 | The key is missing or invalid, lacks the search scope, or is over quota (QUOTA_EXCEEDED). |
429 | Too many requests from one client IP (RATE_LIMITED). |
If the search index is unreachable, /search doesn't fail. A simpler database fallback answers instead, and index.available is false. Error bodies follow the standard shape in Conventions.
Metering & rate limits
Each search request counts as one request against your key's per-minute and per-day quota, and as weighted requests for your plan's billing (see plans & metering). An unlimited key has no quota, but its requests are still weighted:
| Endpoint | Weighted requests | Limits |
|---|---|---|
GET /search | 2 | Your key's per-minute and per-day quota (Rate limits). |
GET /search/suggestions | 1 | Your key's quota. |
GET /search/similar | 2 | Your key's quota. |
POST /search/events | 0 (not metered) | 60 requests per minute per client IP (not for an unlimited key); up to 20 events and 16 KB per request. |
Meaning-based matching does not change the weight of a request. Requests without an API key get meaning-based matching within an approximate hourly budget per client IP address: about 120 searches in which meaning-based matching actually runs. Beyond it they silently use keyword matching only.
mode=typeahead, and cancel the previous request when a new keystroke arrives. On Enter, send one mode=submit request. When the user picks a place from the list, open it directly rather than firing another search.Index status & monitoring
Every response carries an index object:
| Field | Type | Description |
|---|---|---|
provider | string | Always navvo-search. |
configured | boolean | Whether the search index is configured on this deployment. |
available | boolean | Whether the index answered this request. false means a simpler database fallback answered: results still come back, but typo tolerance, facets and meaning-based matching are unavailable. |
mode | string | hybrid when meaning-based matching contributed to this response, keyword otherwise. |
GET /api/v1/health now reports search.engine as navvo-search when the full index is answering, or fallback when the degraded database fallback is. Earlier responses used engine-specific labels. If a monitor or alert compares this string, update it to the new values. search.configured is unchanged.