Navvo
API Reference

Places

Place listings and rich place detail — categories, hours, photos, ratings, and arrival guidance.

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

Geometry format. A place's 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

q
string
Case-insensitive substring match against nameEn or nameAr.
page
number
1-based page number. Defaults to 1 (minimum 1).
limit
number
Page size. Defaults to 50, maximum 100.
sponsored
boolean
Filter to sponsored (or non-sponsored) places. Both truthy (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).
category
string
Filter to a single category slug — matches a place's 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.
amenities
string
Comma-separated amenity facet slugs (e.g. 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.
openNow
boolean
When 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").
sort
string
Result ordering: relevance (default — sponsored, then rating, then newest), rating (highest-rated first), name (A→Z), newest, or oldest. The response echoes the resolved sort.
minRating
number
Only places with a rating ≥ this value (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.
near
string
"Near me" — a 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.
radiusKm
number
With 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:

FieldTypeNotes
imageUrlstring | nullThe place's picture. Absolute URL — drop it straight into an <img>.
imageSourcestring | nullWhich tier answered: logo, photo, or street.
imageWidthnumber | nullIntrinsic pixel width.
imageHeightnumber | nullIntrinsic 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 imageSource says 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.

FieldTypeNotes
iconLightUrlstring | nullPin icon for light basemaps. Absolute URL, SVG.
iconDarkUrlstring | nullPin 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.

Do not build these URLs yourself from the category slug. The slug is not the filename: 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

slug
string required
Place slug.

Response

{
  "url": "https://navvo.io/uploads/abc123.webp",
  "source": "logo",
  "rating": 4.6,
  "openState": "open",
  "openText": "Open · until 23:00"
}
FieldTypeDescription
urlstring | nullBest image — the logo, else a featured photo, else null.
sourcestring | null"logo", "photo", or null.
ratingnumber | nullRating if > 0, else null.
openStatestring | null"open", "closing", "closed", or null (computed in Asia/Amman time).
openTextstring | nullHuman-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.

QueryTypeNotes
qstringoptional — 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 }
    ]}
  ]
}
FieldTypeNotes
scannednumberplaces examined
cappedbooleantrue if the scan hit its cap (more places exist than were aggregated)
groups[].id / namestringfacet group (e.g. accessibility)
groups[].totalnumbertotal 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": [] } ] }
  ]
}
FieldTypeNotes
countnumbertotal categories in the taxonomy
rootCountnumbernumber of top-level categories
categories[]arraytop-level nodes, each recursive with children
…placeCountnumberplaces directly assigned to this category
…totalPlacesnumberrolled-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.

FieldTypeNotes
origin{lat,lng}required — where you're starting from
minutesnumbertravel-time budget (default 15; max 60)
costingstringtravel mode: pedestrian (default), auto, bicycle
qstringoptional name filter
categoriesstringoptional comma-separated category keywords
amenitiesstringoptional comma-separated amenity slugs (all required, enabled)
limitnumbermax 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

slug
string required
The unique place slug.

Response

In addition to all the fields from a list item, the detail response includes:

FieldTypeDescription
reviews[]arrayApproved reviews (rating, body, userId, …).
photos[]arrayActive, approved photos (url, caption, featured, …).
questions[]arrayopen/answered questions with nested answers[]. Admin-hidden (status: "hidden") threads are excluded.
reportsarrayAlways [] (never exposed).
arrivalIntelligenceobjectRecommended 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": "…" }
  }
}
Arrival intelligence is what makes Navvo useful for delivery and ride-hail. Use 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

StatusWhen
400Validation (e.g. limit > 100).
401 / 403Missing/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.

QueryTypeNotes
atISO datetimeoptional — 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
}
FieldTypeNotes
hasHoursbooleanfalse (with null fields) when the place has no hours data
weeklyobject | nullcanonical schedule — per day a comma-separated HH:MM-HH:MM list, or "Closed". "00:00-24:00" = all day
openStatestring | null"open", "closing" (≤45 min to close), "closed", or null
openTextstring | nullhuman 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").
nextChangeobject | nullthe next transition within 7 days: type (opens/closes), weekday, time, dayOffset (0 = today, 1 = tomorrow), and a ready label
holidayobject | nullactive public holiday (hours annotated "… hours may vary", never hard-closed)
A ?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": [/* … */] }
}
FieldTypeNotes
hasDatabooleanfalse (with null fields) when no popular-times exist for the place
nowobjectlive busyness (0–100) + human label + relative (near peak/typical/quieter than usual)
today.curvenumber24busyness per hour 0–23 for today; peakHour is its busiest hour
today.bestTimeToVisitobjectthe quietest open hour today — { hour, busyness, label }
weeklyobjectper-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 }
    ]}
  ]
}
FieldTypeNotes
hasDatabooleanfalse (with empty groups) when the place has no structured attributes
groups[].id / namestringattribute 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")
enabledCountnumbertotal 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 }
  ]
}
FieldTypeNotes
hasDatabooleanfalse when the place has no rating/reviews
averagenumber | nullthe place's average rating (0–5)
totalReviewsnumbertotal number of reviews counted
mostCommonStarsnumberthe 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.

QueryTypeNotes
pagenumber1-based page (default 1)
limitnumberpage 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-…" }
  ]
}
FieldTypeNotes
totalnumbertotal approved photos for the place
countnumberphotos on this page
photos[].urlstringimage URL (relative to the Navvo host)
photos[].featuredbooleanfeatured photos are returned first
photos[].captionstring | nulloptional 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.

QueryTypeNotes
radiusKmnumbersearch radius (default 3, max 50)
sameCategoryboolean1/true → only places sharing the anchor's category
limitnumbermax 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 }
  ]
}
FieldTypeNotes
anchorCategorystring | nullthe category of the place you asked about
places[].distanceKmnumberstraight-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.

FieldTypeNotes
namestringrequired — place name (English)
nameArstringoptional Arabic name
lat / lngnumberrequired — location
categorystringoptional category slug (see GET /places/categories)
phonestringoptional
websitestringoptional
notestringoptional — 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:

EndpointPurpose
POST /places/{slug}/reviewsWrite a review for a place.
POST /places/{slug}/photosAttach an already-uploaded photo to a place.
POST /places/upload-photoUpload the image file itself (multipart) and get back its stored URL.
POST /places/{slug}/questionsAsk a question on a place.
POST /places/questions/{id}/answersAnswer someone's question.
POST /places/{slug}/reportsReport 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-only — an API key does not grant access. These routes live on the admin controller behind a JWT auth guard plus a roles guard requiring the 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.

path
string required
Absolute path to the .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

TopicDetail
Size limit2 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 parsingBoth 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.
ProgressTrack 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-publishesRows 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.
The previous scraper-based bulk-import endpoints have been removed and now return 404. CSV import is the supported bulk-ingest path.
Copyright © 2026