Places
Places
List places and fetch rich detail for a single place, including reviews, photos, Q&A, and arrival intelligence (the recommended entrance and last-50-meters guidance).
Scope required: places
center and each entrance's location are WKT strings, POINT(<lng> <lat>) — longitude first. Inside arrivalIntelligence, parsed coordinates are returned as { "lng": …, "lat": … } objects. The rating field is a number (e.g. 4.6) on both list and detail responses, consistent with /search and /places/thumb.GET /places
List places, paginated, sponsored-first.
curl -s "https://navvo.io/api/v1/places?q=cafe&page=1&limit=20" \
-H "x-api-key: nvvo_YOUR_KEY"
Query parameters
nameEn or nameAr.1 (minimum 1).50, maximum 100.true/1/yes/on) and falsy (false/0/no/off) values are parsed correctly — sponsored=false genuinely returns only non-sponsored places. A blank or unrecognized value behaves like an omitted param (no filter).canonicalCategorySlugor any entry in its categories array. Discover slugs via GET /places/categories. When set, total reflects the filtered set and the response echoes category.wheelchair-accessible-entrance,free-parking-lot). A place matches only if all requested amenities are present and enabled. A slug may be an option (wi-fi) or a whole group (parking = has any parking). When set, total reflects the filtered set and the response echoes the normalized amenities array. Discover available slugs via GET /places/facets.true, return only places that are currently open (or closing soon), evaluated by the opening-hours engine in Jordan time. Places with no hours data are excluded. openNow=false (or a blank value) disables the filter, same as omitting it. total reflects the filtered set and the response echoes openNow: true. Combine with amenities (e.g. "open cafés with parking right now").relevance (default — sponsored, then rating, then newest), rating (highest-rated first), name (A→Z), newest, or oldest. The response echoes the resolved sort.0–5). total reflects the filtered set and the response echoes minRating. Combine with the other filters — e.g. ?minRating=4.5&openNow=true&sort=rating.lat,lng origin (e.g. 31.9539,35.9106). Each result is annotated with distanceKm and the list is sorted nearest-first (this overrides sort). Combine with radiusKm and any filter.near, return only places within this many kilometres. The response echoes near and radiusKm.Places at the same distance keep a stable relative order across pages, so paging through a
dense area never shows the same place twice or skips one. This matters more than it sounds: in a
city centre a dozen places can sit within ten metres of one origin.Response
{
"items": [
{
"id": "31c5a4e2-9b1f-4c0a-bb10-2f0c2b8e7a11",
"slug": "ardh-cafe-ard-kafyh-2bd24a",
"nameEn": "Ardh cafe",
"nameAr": "مقهى جوردن بينز",
"logoUrl": "/uploads/places/31c5a4e2-9b1f-4c0a-bb10-2f0c2b8e7a11/logo.webp",
"imageUrl": "https://navvo.io/uploads/places/31c5a4e2-9b1f-4c0a-bb10-2f0c2b8e7a11/a7f3.webp",
"imageSource": "photo",
"imageWidth": 408,
"imageHeight": 544,
"categories": ["cafe", "food"],
"canonicalCategorySlug": "cafe",
"iconLightUrl": "https://navvo.io/uploads/category-icons/cafe-light.svg",
"iconDarkUrl": "https://navvo.io/uploads/category-icons/cafe-dark.svg",
"center": "POINT(35.913 31.953)",
"contact": { "phone": "+962790000000", "website": "https://example.com" },
"openingHours": { "weekly": { "sunday": "08:00-23:00" } },
"sponsored": false,
"claimStatus": "verified",
"rating": 4.6,
"entrances": [
{ "id": "a1b2c3d4-…", "type": "main", "label": "Main Entrance", "location": "POINT(35.9131 31.9529)" }
]
}
],
"total": 137,
"page": 1,
"limit": 50
}
List items include the entrances relation but not reviews/photos/questions. Ordering is always sponsored DESC, rating DESC, createdAt DESC.
Place image
Every place-shaped response carries one resolved image, already chosen for you, as an absolute URL:
| Field | Type | Notes |
|---|---|---|
imageUrl | string | null | The place's picture. Absolute URL — drop it straight into an <img>. |
imageSource | string | null | Which tier answered: logo, photo, or street. |
imageWidth | number | null | Intrinsic pixel width. |
imageHeight | number | null | Intrinsic pixel height. |
The image is resolved server-side, ahead of the request, in this order: the business's own logo → its best approved photo (featured first, then oldest) → a street-level view from the Navvo imagery network where one is available. Reading it costs the API nothing — it is a column on the row the query already reads, so adding it did not change list latency.
About the street tier (imageSource: "street"). Where a place has no logo and no photo, Navvo
looks for a street-level frame captured near it and serves that instead. Two things are worth
knowing:
- Neighbouring places do not share a picture. Frames are assigned competitively, not independently, so two places on the same block get different photographs — and a 360° frame is cropped toward each place in turn, so the same source frame yields a different view aimed at each shopfront. Without this, every shop on a street would show one identical image.
- It is a view of the location, not a portrait of the business. The frame was captured from the
road, typically within 60 m, and may be several years old. It answers "what does it look like
there?", not "what does this shop's front look like?" Treat it as context, and prefer a logo or
photo whenever
imageSourcesays one exists.
Use imageWidth/imageHeight to reserve space before the bytes arrive. Place photos are not a
fixed aspect ratio — real ones run from 224 × 298 to 513 × 240 — so a grid that does not reserve
space visibly reflows as each image lands.
imageUrl is null when a place has no picture of any kind. Render your own fallback then; the
iconLightUrl / iconDarkUrl category pin below is a good one, and it is never null for a place
with a category.
imageUrl is absolute, like the icon fields and unlike the older relative logoUrl and photo
url. It is a separate, additive field: logoUrl and GET /places/thumb's url / source keep
their existing meaning and are unchanged.Category marker icons
Every place-shaped response carries iconLightUrl and iconDarkUrl — the map-pin icon for that place's category, as absolute URLs you can drop straight into an <img> or a MapLibre icon-image without joining a base onto them.
| Field | Type | Notes |
|---|---|---|
iconLightUrl | string | null | Pin icon for light basemaps. Absolute URL, SVG. |
iconDarkUrl | string | null | Pin icon for dark basemaps. Absolute URL, SVG. |
Two variants ship because a single icon reads poorly against one of the two basemaps — pick by the style your map is rendering, not by the OS theme.
The icon is resolved from the place's canonicalCategorySlug, falling back to the first entry of categories that resolves. Both fields are null when a place has no resolvable category — render your own fallback pin in that case rather than assuming a URL is always present.
transport resolves to transportation-light.svg and restaurants to restaurant-light.svg. String-templating the slug produces URLs that 404 for a large share of the catalogue. Always use the iconLightUrl / iconDarkUrl the API returns, or look the slug up in GET /map/config → categories[].logoUrl and photo url values are relative site paths (/uploads/…), unlike the icon fields. Join them onto https://navvo.io yourself. Only the icon fields are absolute.GET /places/thumb
Lightweight thumbnail + open/closed status for a place — ideal for map cards.
This endpoint also returns the four place image fields. Prefer imageUrl for new
work: it is absolute and covers the street tier. url and source are kept exactly as they were
for existing integrations — source stays logo | photo | null and never reports street.
curl -s "https://navvo.io/api/v1/places/thumb?slug=ardh-cafe-ard-kafyh-2bd24a" \
-H "x-api-key: nvvo_YOUR_KEY"
Query parameters
Response
{
"url": "https://navvo.io/uploads/abc123.webp",
"source": "logo",
"rating": 4.6,
"openState": "open",
"openText": "Open · until 23:00"
}
| Field | Type | Description |
|---|---|---|
url | string | null | Best image — the logo, else a featured photo, else null. |
source | string | null | "logo", "photo", or null. |
rating | number | null | Rating if > 0, else null. |
openState | string | null | "open", "closing", "closed", or null (computed in Asia/Amman time). |
openText | string | null | Human-readable status. |
An unknown slug returns 200 with all fields null (not a 404).
GET /places/facets
The available amenity facets across places — grouped (Accessibility, Parking, Payments, Amenities, Service options, …) with per-option counts. Use it to build a "filter by amenity" UI, then pass the chosen slugs to GET /places?amenities=. Only enabled options are counted.
| Query | Type | Notes |
|---|---|---|
q | string | optional — narrow the facet counts to places whose name matches |
curl -s "https://navvo.io/api/v1/places/facets" \
-H "x-api-key: $NAVVO_API_KEY"
{
"scanned": 735,
"capped": false,
"groups": [
{ "id": "accessibility", "name": "Accessibility", "total": 201, "options": [
{ "slug": "wheelchair-accessible-entrance", "label": "Wheelchair-accessible entrance", "count": 72 },
{ "slug": "wheelchair-accessible-car-park", "label": "Wheelchair-accessible car park", "count": 63 }
]},
{ "id": "parking", "name": "Parking", "total": 90, "options": [
{ "slug": "free-parking-lot", "label": "Free parking lot", "count": 27 }
]}
]
}
| Field | Type | Notes |
|---|---|---|
scanned | number | places examined |
capped | boolean | true if the scan hit its cap (more places exist than were aggregated) |
groups[].id / name | string | facet group (e.g. accessibility) |
groups[].total | number | total enabled options counted in the group |
groups[].options[] | array | { slug, label, count }, sorted by count — the slug is what you pass to ?amenities= |
GET /places/categories
The full category taxonomy — a hierarchical tree (parent → children) with each category's display name, icon, color, and place counts (placeCount = directly in that category, totalPlaces = rolled up across its subtree). Use it to build a category browse/filter UI; the slug of any category is what places carry as canonicalCategorySlug.
curl -s "https://navvo.io/api/v1/places/categories" \
-H "x-api-key: $NAVVO_API_KEY"
{
"provider": "navvo-places", "count": 500, "rootCount": 38,
"categories": [
{ "slug": "restaurants", "nameEn": "Restaurants", "nameAr": "مطاعم",
"iconName": "restaurant", "color": "#386BFF",
"placeCount": 9, "totalPlaces": 9, "children": [] },
{ "slug": "automotive", "nameEn": "Automotive", "placeCount": 0, "totalPlaces": 0,
"children": [ { "slug": "car-rental", "nameEn": "Car Rental", "placeCount": 0, "totalPlaces": 0, "children": [] } ] }
]
}
| Field | Type | Notes |
|---|---|---|
count | number | total categories in the taxonomy |
rootCount | number | number of top-level categories |
categories[] | array | top-level nodes, each recursive with children |
…placeCount | number | places directly assigned to this category |
…totalPlaces | number | rolled-up count including all descendant categories |
POST /places/reachable
"What's reachable" — every place you can get to from an origin within a travel-time budget. Navvo computes the isochrone (the reachability polygon) for the time/mode, then returns the places that fall inside it — not just within a circle, so it respects the real road & footpath network. Optional name/category/amenity filters apply on top.
| Field | Type | Notes |
|---|---|---|
origin | {lat,lng} | required — where you're starting from |
minutes | number | travel-time budget (default 15; max 60) |
costing | string | travel mode: pedestrian (default), auto, bicycle |
q | string | optional name filter |
categories | string | optional comma-separated category keywords |
amenities | string | optional comma-separated amenity slugs (all required, enabled) |
limit | number | max results (default 100; max 500) |
curl -s -X POST "https://navvo.io/api/v1/places/reachable" \
-H "x-api-key: $NAVVO_API_KEY" -H "content-type: application/json" \
-d '{ "origin": {"lat":31.9539,"lng":35.9106}, "minutes": 15, "costing": "pedestrian", "categories": ["cafe"] }'
{
"provider": "navvo-places", "available": true,
"origin": { "lat": 31.9539, "lng": 35.9106 }, "minutes": 15, "costing": "pedestrian",
"candidatesInBbox": 12, "count": 9,
"reachable": [
{ "id": "…", "slug": "…", "name": "…", "category": "cafe", "rating": 4.6, "lat": 31.95, "lng": 35.91 }
],
"isochrone": { "type": "Polygon", "coordinates": [ /* … */ ] }
}
count is how many places are genuinely inside the reachability polygon (candidatesInBbox is the looser bounding-box prefilter — count ≤ it). The isochrone GeoJSON is returned so you can draw the reachable area on the map.
GET /places/{slug}
Full place detail: approved reviews & photos, questions + answers, and arrivalIntelligence.
curl -s "https://navvo.io/api/v1/places/ardh-cafe-ard-kafyh-2bd24a" \
-H "x-api-key: nvvo_YOUR_KEY"
Path parameters
Response
In addition to all the fields from a list item, the detail response includes:
| Field | Type | Description |
|---|---|---|
reviews[] | array | Approved reviews (rating, body, userId, …). |
photos[] | array | Active, approved photos (url, caption, featured, …). |
questions[] | array | open/answered questions with nested answers[]. Admin-hidden (status: "hidden") threads are excluded. |
reports | array | Always [] (never exposed). |
arrivalIntelligence | object | Recommended entrance and last-50-meters guidance (see below). |
{
"slug": "ardh-cafe-ard-kafyh-2bd24a",
"nameEn": "Ardh cafe",
"center": "POINT(35.913 31.953)",
"rating": 4.6,
"entrances": [ { "id": "a1b2c3d4-…", "type": "main", "location": "POINT(35.9131 31.9529)" } ],
"reviews": [ { "id": "r1…", "rating": 5, "body": "Great coffee.", "status": "approved" } ],
"photos": [ { "id": "p1…", "url": "https://navvo.io/uploads/photo1.webp", "featured": true } ],
"questions": [
{ "id": "q1…", "question": "Is there parking?", "status": "answered",
"answers": [ { "id": "ans1…", "answer": "Yes, in the rear lot.", "ownerVerified": true } ] }
],
"reports": [],
"arrivalIntelligence": {
"version": "navvo-arrival-v1",
"placeCenter": { "lng": 35.913, "lat": 31.953 },
"recommendedEntranceId": "a1b2c3d4-…",
"candidates": [
{
"entranceId": "a1b2c3d4-…",
"type": "main",
"coordinates": { "lng": 35.9131, "lat": 31.9529 },
"distanceFromPlaceMeters": 12,
"score": 99,
"recommended": true,
"lastMetersGuidance": "12 meters from the place pin. Use this as the default arrival point for visitors.",
"routeTarget": { "lat": 31.9529, "lng": 35.9131 }
}
],
"microMap": { "radiusMeters": 50, "title": "Last 50 meters", "guidance": "…" }
}
}
arrivalIntelligence.candidates[0].routeTarget as the destination coordinate for routing instead of the place center — it points to the recommended entrance.An unknown slug (or id) returns 404 Not Found.
Errors
| Status | When |
|---|---|
400 | Validation (e.g. limit > 100). |
401 / 403 | Missing/invalid key, or key lacks the places scope. |
GET /places/{slug}/hours
The opening-hours engine: a place's normalized weekly schedule, its live open/closed state, and the next open/close transition — all evaluated in Jordan time (Asia/Amman) and holiday-aware.
Navvo accepts hours in two shapes and normalizes both: our structured { "weekly": { "monday": "09:00-17:00", … } } map, and the raw OSM-style opening_hours string that imported POIs carry (e.g. "Mo-Fr 09:00-17:00; Sa 10:00-14:00; Su off", "24/7", lunch breaks like "Mo-Fr 08:00-12:00,13:00-18:00"). The engine returns a single canonical weekly map either way.
| Query | Type | Notes |
|---|---|---|
at | ISO datetime | optional — evaluate at a future instant (trip planning); defaults to now |
curl -s "https://navvo.io/api/v1/places/ardh-cafe-ard-kafyh-2bd24a/hours" \
-H "x-api-key: $NAVVO_API_KEY"
{
"slug": "ardh-cafe-ard-kafyh-2bd24a",
"hasHours": true,
"at": "2026-06-23T07:30:00.000Z",
"weekly": {
"monday": "09:00-17:00", "tuesday": "09:00-17:00", "wednesday": "09:00-17:00",
"thursday": "09:00-17:00", "friday": "09:00-17:00", "saturday": "10:00-14:00", "sunday": "Closed"
},
"openState": "open",
"openText": "Open · until 17:00",
"nextChange": { "type": "closes", "dayOffset": 0, "weekday": "tuesday", "atMinutes": 1020, "time": "17:00", "label": "Open · closes 17:00" },
"holiday": null
}
| Field | Type | Notes |
|---|---|---|
hasHours | boolean | false (with null fields) when the place has no hours data |
weekly | object | null | canonical schedule — per day a comma-separated HH:MM-HH:MM list, or "Closed". "00:00-24:00" = all day |
openState | string | null | "open", "closing" (≤45 min to close), "closed", or null |
openText | string | null | human label, e.g. "Open · until 17:00", "Closing soon · 17:00". A place open every day 00:00-24:00 (incl. the OSM 24/7 string) reads "Open 24 hours" with nextChange: null (a holiday overlay still annotates, e.g. "Open 24 hours · Eid al-Adha"). |
nextChange | object | null | the next transition within 7 days: type (opens/closes), weekday, time, dayOffset (0 = today, 1 = tomorrow), and a ready label |
holiday | object | null | active public holiday (hours annotated "… hours may vary", never hard-closed) |
?at= in the future evaluates the schedule for that moment — useful for "will it be open when I arrive?" planning. Cross-midnight ranges (18:00-02:00) and Ramadan/holiday overlays are handled automatically.GET /places/{slug}/popular-times
Google-style busyness for a place: the weekly hour-by-hour grid, the live "now" level, today's curve with its peak, and the quietest open hour ("best time to visit"). All evaluated in Jordan time. Busyness is 0–100.
curl -s "https://navvo.io/api/v1/places/ardh-cafe-ard-kafyh-2bd24a/popular-times" \
-H "x-api-key: $NAVVO_API_KEY"
{
"slug": "ardh-cafe-ard-kafyh-2bd24a", "hasData": true, "name": "Ardh cafe",
"now": { "day": "Saturday", "hour": 19, "busyness": 50, "label": "A bit busy", "relative": "typical" },
"today": {
"day": "Saturday", "peakHour": 10,
"curve": [18,31,31,43,50,50,43,31,37,43,43,37,31,37,50,75,81,93,75,62,62,43,31,25],
"bestTimeToVisit": { "hour": 5, "busyness": 25, "label": "Not too busy" }
},
"weekly": { "Sunday": [/* 24 values */], "Monday": [/* … */] }
}
| Field | Type | Notes |
|---|---|---|
hasData | boolean | false (with null fields) when no popular-times exist for the place |
now | object | live busyness (0–100) + human label + relative (near peak/typical/quieter than usual) |
today.curve | number24 | busyness per hour 0–23 for today; peakHour is its busiest hour |
today.bestTimeToVisit | object | the quietest open hour today — { hour, busyness, label } |
weekly | object | per-day 24-value busyness arrays (Sunday…Saturday) |
Labels: Not busy (<25), Not too busy (<50), A bit busy (<70), Busy (<85), As busy as it gets (≥85).
GET /places/{slug}/attributes
The full structured "About" attributes for one place — every group (Accessibility, Parking, Payments, Service options, Amenities, …) with each option and whether it's enabled. This is the per-place detail; for catalogue-wide facets use GET /places/facets, and to filter places by amenity use ?amenities=.
curl -s "https://navvo.io/api/v1/places/ardh-cafe-ard-kafyh-2bd24a/attributes" \
-H "x-api-key: $NAVVO_API_KEY"
{
"slug": "ardh-cafe-ard-kafyh-2bd24a", "hasData": true, "name": "Ardh cafe",
"groupCount": 2, "enabledCount": 4,
"groups": [
{ "id": "payments", "name": "Payments", "options": [
{ "slug": "credit-cards", "label": "Credit cards", "enabled": true },
{ "slug": "nfc-mobile-payments", "label": "NFC mobile payments", "enabled": true }
]},
{ "id": "parking", "name": "Parking", "options": [
{ "slug": "on-site-parking", "label": "On-site parking", "enabled": true }
]}
]
}
| Field | Type | Notes |
|---|---|---|
hasData | boolean | false (with empty groups) when the place has no structured attributes |
groups[].id / name | string | attribute group (e.g. accessibility) |
groups[].options[] | array | { slug, label, enabled } — enabled:false means the place explicitly lacks that feature (e.g. "Wheelchair-accessible entrance: no") |
enabledCount | number | total enabled options across all groups |
GET /places/{slug}/reviews-summary
The rating breakdown for a place — average, total review count, the most common star level, and the full 5★→1★ distribution with counts and percentages. Ideal for a ratings bar chart without fetching every review.
curl -s "https://navvo.io/api/v1/places/ardh-cafe-ard-kafyh-2bd24a/reviews-summary" \
-H "x-api-key: $NAVVO_API_KEY"
{
"slug": "ardh-cafe-ard-kafyh-2bd24a", "hasData": true, "name": "Ardh cafe",
"average": 4.6, "totalReviews": 128, "mostCommonStars": 5,
"distribution": [
{ "stars": 5, "count": 96, "percent": 75 },
{ "stars": 4, "count": 20, "percent": 15.6 },
{ "stars": 3, "count": 6, "percent": 4.7 },
{ "stars": 2, "count": 3, "percent": 2.3 },
{ "stars": 1, "count": 3, "percent": 2.3 }
]
}
| Field | Type | Notes |
|---|---|---|
hasData | boolean | false when the place has no rating/reviews |
average | number | null | the place's average rating (0–5) |
totalReviews | number | total number of reviews counted |
mostCommonStars | number | the star level with the most reviews |
distribution[] | array | { stars, count, percent } for 5★ down to 1★ (percent of total) |
GET /places/{slug}/photos
The photo gallery for a place — approved, active photos, featured first, paginated. Use it to render a place's image carousel.
| Query | Type | Notes |
|---|---|---|
page | number | 1-based page (default 1) |
limit | number | page size (default 30, max 100) |
curl -s "https://navvo.io/api/v1/places/ardh-cafe-ard-kafyh-2bd24a/photos?limit=20" \
-H "x-api-key: $NAVVO_API_KEY"
{
"slug": "ardh-cafe-ard-kafyh-2bd24a", "name": "Ardh cafe",
"total": 6, "page": 1, "limit": 20, "count": 6,
"photos": [
{ "id": "…", "url": "/uploads/places/…/a59e624b.webp", "caption": "Latest", "featured": true, "mediaType": "image", "createdAt": "2026-05-…" }
]
}
| Field | Type | Notes |
|---|---|---|
total | number | total approved photos for the place |
count | number | photos on this page |
photos[].url | string | image URL (relative to the Navvo host) |
photos[].featured | boolean | featured photos are returned first |
photos[].caption | string | null | optional caption |
Only approved, active photos are returned (moderation applies). An unknown slug returns total: 0 with an empty photos array.
GET /places/{slug}/nearby
Other places near this one — a "similar places nearby" / "more like this" list, sorted by distance and excluding the place itself. Optionally restrict to the same category.
| Query | Type | Notes |
|---|---|---|
radiusKm | number | search radius (default 3, max 50) |
sameCategory | boolean | 1/true → only places sharing the anchor's category |
limit | number | max results (default 20, max 100) |
curl -s "https://navvo.io/api/v1/places/ardh-cafe-ard-kafyh-2bd24a/nearby?radiusKm=3&sameCategory=1" \
-H "x-api-key: $NAVVO_API_KEY"
{
"slug": "ardh-cafe-ard-kafyh-2bd24a", "center": { "lat": 31.95, "lng": 35.91 },
"radiusKm": 3, "sameCategory": true, "anchorCategory": "cafe", "count": 2,
"places": [
{ "slug": "…", "name": "…", "category": "cafe", "rating": 4.5, "distanceKm": 0.4 }
]
}
| Field | Type | Notes |
|---|---|---|
anchorCategory | string | null | the category of the place you asked about |
places[].distanceKm | number | straight-line distance from the anchor place |
GET /places/{slug}/entrances
The place's entrances/gates as a lightweight standalone read — the companion to the entrances[] embedded on the place detail, so a navigation client can refresh gates without refetching the whole place.
curl -s "https://navvo.io/api/v1/places/ardh-cafe-ard-kafyh-2bd24a/entrances" \
-H "x-api-key: $NAVVO_API_KEY"
{
"slug": "ardh-cafe-ard-kafyh-2bd24a",
"items": [
{ "id": "a1b2c3d4-…", "type": "main", "label": "Main Entrance", "location": "POINT(35.9131 31.9529)" }
],
"total": 1
}
items is empty (total: 0) when no dedicated entrances are configured — route to the place pin in that case. An unknown slug returns 404.
GET /places/{slug}/questions
The public Q&A thread for a place — questions with their answers, newest first. Read-only for API consumers; asking and answering are signed-in user features.
curl -s "https://navvo.io/api/v1/places/ardh-cafe-ard-kafyh-2bd24a/questions" \
-H "x-api-key: $NAVVO_API_KEY"
[
{
"id": "…", "question": "Is there outdoor seating?", "status": "answered", "createdAt": "…",
"answers": [
{ "id": "…", "answer": "Yes — a shaded terrace.", "ownerVerified": true, "createdAt": "…" }
]
}
]
The response is a plain array (empty when there are no questions). answers[].ownerVerified marks replies from the verified business owner.
POST /places/suggest
"Add a missing place" — let signed-in users submit a place that isn't on the map yet. Submissions land in Navvo's moderation queue and are reviewed by an editor; nothing appears on the map until approved.
Requires a signed-in Navvo user (Authorization: Bearer <accessToken> — see Users & Sync), not an API key.
| Field | Type | Notes |
|---|---|---|
name | string | required — place name (English) |
nameAr | string | optional Arabic name |
lat / lng | number | required — location |
category | string | optional category slug (see GET /places/categories) |
phone | string | optional |
website | string | optional |
note | string | optional — anything the reviewer should know |
curl -s -X POST "https://navvo.io/api/v1/places/suggest" \
-H "authorization: Bearer $NAVVO_USER_TOKEN" -H "content-type: application/json" \
-d '{ "name": "Abu Ahmad Falafel", "nameAr": "فلافل أبو أحمد", "lat": 31.9539, "lng": 35.9106, "category": "restaurants" }'
{ "ok": true, "id": "…", "status": "quarantined", "note": "Thanks! Your suggestion is queued for review." }
status is always quarantined on intake — the suggestion is held for review. Missing name or non-numeric lat/lng returns 400.
User-authored content
Writing reviews, photos, questions, and reports is a first-party, end-user feature — those endpoints require a logged-in user session (Bearer JWT), not an API key, and are not part of the integration API. If your product needs user-generated content, contact Navvo to discuss partner access.
For completeness, these are the endpoints in that family. All of them answer 401 to an API key alone:
| Endpoint | Purpose |
|---|---|
POST /places/{slug}/reviews | Write a review for a place. |
POST /places/{slug}/photos | Attach an already-uploaded photo to a place. |
POST /places/upload-photo | Upload the image file itself (multipart) and get back its stored URL. |
POST /places/{slug}/questions | Ask a question on a place. |
POST /places/questions/{id}/answers | Answer someone's question. |
POST /places/{slug}/reports | Report a place as closed, wrong, or duplicated. |
Contributor standing from this activity is summarised by GET /community/reputation/{userId}, which is public: it returns { "userId", "points", "solved", "level", "events" }, where level is a coarse band such as starter. Calling it with a Bearer token for your own id (or as an admin) adds the detailed events history; anonymous callers get the summary only.
Admin: bulk place imports (CSV)
Bulk-load places from a CSV export. A file is either uploaded or picked up from an allow-listed path already on the server; either way it is streamed into the same quarantine → approve → publish review pipeline the other import sources use, and the client polls a run id for progress.
admin or super_admin role, so they need an admin Bearer JWT. An unauthenticated request to GET /admin/place-imports/files returns 401.GET /admin/place-imports/files
Lists the .csv files discovered in the allow-listed roots, newest first, so an admin UI can offer a picker.
curl -s "https://navvo.io/api/v1/admin/place-imports/files" \
-H "authorization: Bearer $NAVVO_ADMIN_TOKEN"
{
"roots": ["/home/navvo/public_html/data/imports", "/home/navvo/imports"],
"files": [
{ "path": "/home/navvo/public_html/data/imports/amman.csv", "name": "amman.csv", "size": 48210934, "modifiedAt": "2026-07-26T09:12:44.000Z" }
]
}
POST /admin/place-imports/upload
multipart/form-data, exactly one file, whose filename must end in .csv (otherwise 400 Only .csv files are accepted). The upload is streamed straight to disk and is never buffered in memory. If the size limit is hit mid-stream the partial file is deleted and the request returns 400 Upload exceeded the configured size limit.
curl -s -X POST "https://navvo.io/api/v1/admin/place-imports/upload" \
-H "authorization: Bearer $NAVVO_ADMIN_TOKEN" \
-F "file=@/path/to/places.csv"
POST /admin/place-imports/from-path
Imports a file that is already on the server, without re-uploading it.
.csv file. It is resolved and must sit inside an allow-listed root — anything outside returns 400 with Path must be inside one of: <roots>. A path that does not exist (or is not a file) returns 400 No such file: <path>.curl -s -X POST "https://navvo.io/api/v1/admin/place-imports/from-path" \
-H "authorization: Bearer $NAVVO_ADMIN_TOKEN" -H "content-type: application/json" \
-d '{ "path": "/home/navvo/public_html/data/imports/amman.csv" }'
GET /admin/place-imports/{runId}
Poll the run created by either intake route.
{
"runId": "…",
"status": "running",
"notes": "Uploaded places.csv",
"progress": {
"received": 128000, "staged": 121450, "skipped": 6550,
"percent": 62, "bytesRead": 301989888, "bytesTotal": 483183820,
"done": false, "error": null,
"fileName": "1753526400000-places.csv",
"startedAt": "2026-07-26T09:20:11.512Z", "finishedAt": null
}
}
An unknown id returns 400 Import run not found.
Behaviour
| Topic | Detail |
|---|---|
| Size limit | 2 GB (2,147,483,648 bytes) by default, enforced both as the HTTP server's body limit and as the multipart handler's per-file limit. Tunable on the deployment via the NAVVO_CSV_MAX_BYTES environment variable. One file per request. |
| Allow-listed roots | /home/navvo/public_html/data/imports and /home/navvo/imports, plus any absolute paths added (colon-separated) via NAVVO_CSV_IMPORT_DIRS. Anything outside these is rejected — this is what prevents arbitrary server file reads. |
| Detached parsing | Both intake routes create the import run row and return immediately; the streaming parse is fired without being awaited, so no request or proxy timeout bounds the import, however large the file is. The server also runs with request/connection timeouts disabled and a 15-minute keep-alive, so a long upload cannot be cut short. |
| Progress | Track progress.percent (or bytesRead vs bytesTotal) until progress.done is true, or progress.error becomes non-null. The run status moves from running to staged (or failed). |
| Nothing auto-publishes | Rows are written as staged records into the existing quarantine → approve → publish review pipeline under the import source place_csv, which is explicitly not trusted for auto-publish. Records are flushed in batches as the file streams. |
404. CSV import is the supported bulk-ingest path.