Navvo
API Reference

Users & Sync

Accounts, profiles, saved places, history, and live location sharing — the personal-data API for building a signed-in consumer app on Navvo.

Users & Sync

Everything a signed-in consumer app needs: register/login, profile & privacy settings, device/session management, GDPR export & deletion, saved places and shareable lists, search/visit history, and live location sharing with ETA. This is the same API the Navvo map app itself runs on.

Authentication model. Unlike the rest of the API reference, these endpoints act on behalf of an end user, so they authenticate with the user's Bearer JWT (Authorization: Bearer <accessToken> from POST /auth/login) — not your x-api-key. Endpoints marked public need no credentials at all (the share key/slug is the secret).

Accounts & profile

POST /auth/register

Create a user account. Anonymous by definition.

FieldTypeNotes
emailstringrequired — must be a valid email, stored lowercased
passwordstringrequired — minimum 10 characters
namestringoptional display name
turnstileTokenstringbrowser-only: applies solely to Navvo's own web sign-in form. API clients calling with an x-api-key are never captcha-checked and can omit this entirely
curl -s -X POST "https://navvo.io/api/v1/auth/register" \
  -H "content-type: application/json" \
  -d '{ "email": "[email protected]", "name": "Lina", "password": "superStrongPass123!" }'
{
  "user": { "id": "…", "email": "[email protected]", "name": "Lina", "roles": ["user"] },
  "sessionId": "3f6f2c1e-…",
  "accessToken": "eyJhbGciOi…"
}

accessToken is a long-lived Bearer JWT bound to sessionId — store it securely and send it on every user-scoped call. Revoking the session (logout, device management) kills the token server-side.

POST /auth/login

Same body (email, password) and same response envelope as register. A disabled account or wrong credentials returns 400. As with register, no captcha is ever required when calling with an API key — turnstileToken exists only for Navvo's first-party browser form.

Accounts created through Firebase sign in here too.An account created by POST /auth/firebase has no Navvo password — its stored hash is deliberately random. For accounts created with Firebase's email/password provider the password is real but held by Firebase, so this endpoint verifies it with Firebase and, on success, issues the normal Navvo session. Nothing changes for the caller: same request, same response envelope.For accounts created with a social provider (Google, Apple, …) there is no password to check. Those return 400 with code: "PROVIDER_ACCOUNT", naming the provider and where to exchange its token:
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "This account signs in with Google. Sign in there, then exchange the resulting ID token at POST /auth/firebase — this endpoint only accepts a Navvo password, which this account does not have.",
  "code": "PROVIDER_ACCOUNT",
  "details": { "signInMethod": "provider", "provider": "google.com", "exchangeEndpoint": "/api/v1/auth/firebase" },
  "requestId": "…"
}
Branch on code, not the message. PROVIDER_ACCOUNT does not count toward the brute-force lockout — it is not a failed guess. A genuinely wrong password still returns the ordinary Invalid credentials, and still counts.

GET /auth/me

The signed-in user + the current session.

curl -s "https://navvo.io/api/v1/auth/me" -H "authorization: Bearer $NAVVO_USER_TOKEN"
{
  "user": { "id": "…", "email": "[email protected]", "name": "Lina", "roles": ["user"],
            "profile": { "avatar": { "type": "emoji", "value": "🌟" }, "privacy": { "saveHistory": true } } },
  "session": { "id": "…", "sessionId": "…", "lastSeenAt": "…", "expiresAt": "…", "revokedAt": null }
}

PATCH /auth/me

Update the editable profile. All fields optional; only what you send changes.

FieldTypeNotes
namestringdisplay name (trimmed, max 160 chars)
avatarobject | null{ type: "image" | "emoji" | "letter", value, bgColor? }; null clears the avatar
privacyobject | nullprivacy toggles — whitelisted boolean keys saveHistory, personalization, shareAnalytics, useLocation; null clears the bag
curl -s -X PATCH "https://navvo.io/api/v1/auth/me" \
  -H "authorization: Bearer $NAVVO_USER_TOKEN" -H "content-type: application/json" \
  -d '{ "privacy": { "saveHistory": false, "personalization": true } }'

→ { "ok": true, "user": { … } }. Clients read the settings back from GET /auth/me → user.profile.privacy. Honour saveHistory: false by not calling POST /me/history.

POST /auth/logout

Signs out the current session server-side (the JWT's session id is revoked, so the token stops working everywhere). → { "ok": true }

GET /auth/sessions · DELETE /auth/sessions

Device management — list the user's sessions and revoke them.

{
  "items": [
    { "sessionId": "…", "current": true, "ipAddress": "94.…", "userAgent": "Mozilla/5.0 …",
      "lastSeenAt": "…", "createdAt": "…", "expiresAt": "…", "revokedAt": null }
  ]
}
  • DELETE /auth/sessions/{sessionId} — revoke one session → { "ok": true, "revoked": 1 }
  • DELETE /auth/sessions?all=1 — "sign out everywhere else": revokes every session except the current one → { "ok": true, "revoked": n }

GET /auth/me/export

GDPR-style data takeout — everything Navvo stores under the account, as one JSON document: { exportedAt, account, savedLocations, mapHistory, liveShares, signDisregards, sessions }.

DELETE /auth/me

Self-serve account deletion (app-store requirement). Cascades the user's own data — sessions, saved places & history, live shares — then removes the account.

FieldTypeNotes
confirmstringrequired — must be the literal string DELETE
passwordstringrequired when the account has a password

→ { "deleted": true }

Social sign-in (OAuth)

Providers are configured by the platform operator — always render buttons from the live list:

  • GET /auth/providers (public) → { "providers": [ { "id": "google", "label": "Google", … } ], "turnstile": { "enabled": true, "siteKey": "…" } }
  • GET /auth/oauth/{provider}/start?next=/map — 302-redirects the browser to the provider's consent page.
  • GET /auth/oauth/{provider}/callback — the provider redirects back here; on success the browser lands on /map#nvauth=ok&token=<accessToken>. If the email already belongs to a password account, the fragment is nvauth=link and the client confirms the link via POST /auth/oauth/link { linkToken, password? }.

POST /auth/firebase

Firebase sign-in exchange — the recommended sign-in path for apps using Firebase Authentication (email/password, Google, Apple, Microsoft, Facebook, GitHub…). Sign the user in with the Firebase SDK, then post the Firebase ID token; Navvo verifies it against Google's public certificates (issuer/audience pinned to the Navvo project), finds or creates the account by verified email, and returns the standard auth envelope (user, sessionId, accessToken) — so the user immediately has full access to member services (navigation, Atlas, saved places…).

FieldTypeNotes
idTokenstringrequired — Firebase ID token from getIdToken()
curl -s -X POST "https://navvo.io/api/v1/auth/firebase" \
  -H "content-type: application/json" \
  -d '{ "idToken": "eyJhbGciOiJSUzI1NiIs…" }'
Linking to an existing Navvo account requires the email to be verified with the auth provider (OAuth providers always are; raw email/password signups must confirm their email first) — this prevents account takeover via unverified addresses.

Access model: guest vs signed-in

The base map (tiles, styles, browsing, search, place details, geocoding reads) is open. Member services require a signed-in user's Bearer token — or your developer API key: routing/navigation (POST /routing/*, POST /traffic/route), the Atlas agent, saved places/lists, history, live sharing, reporting, and push tokens. Anonymous calls to those return 401.


Push notification tokens

Register the device's FCM token so your backend (or Navvo, when the operator enables server-side sending) can reach the user:

  • POST /me/push-tokens — body { "token": "fcm-registration-token", "platform": "web" | "android" | "ios" } (Bearer auth). Re-posting an existing token re-binds it to the current user.
  • DELETE /me/push-tokens/{token} — unregister on sign-out.

GET /me/notifications

The signed-in user's notification inbox — every notification Navvo has sent them, newest first, recorded at send time regardless of whether the push actually reached a device. This is what backs an in-app notification list; it is not a delivery receipt for any one device.

curl "https://navvo.io/api/v1/me/notifications?limit=50" \
  -H "authorization: Bearer $TOKEN"

Query parameters (all optional):

ParamTypeNotes
limitinteger1–200, default 50
offsetinteger≥ 0, default 0
typestringreturn only notifications of one type — see the table below

Notification types

type is a plain string, not a fixed enum an integration needs to hardcode against — new notification types can appear over time without a breaking change. This is the complete list of what Navvo actually sends today; nothing here is planned or hypothetical:

typeSent whendata payload
commute_leave_nowA saved commute's leave-time alarm fires — see Commutes{ "commuteId", "minutesBefore" }
admin_broadcastAn operator sends a platform-wide announcementnot set

Response — inbox with items

{
  "items": [
    {
      "id": "9c3f2e10-…",
      "type": "commute_leave_now",
      "title": "Navvo · Time to leave soon",
      "body": "Leave in ~15 min for \"Work\" — ~22 min drive to arrive by 08:30.",
      "url": "/map?from=31.95,35.91&to=31.96,35.93&navigate=1",
      "data": { "commuteId": "4a1d…", "minutesBefore": 15 },
      "isRead": false,
      "readAt": null,
      "createdAt": "2026-08-27T05:14:02.000Z"
    },
    {
      "id": "7b1a44c2-…",
      "type": "admin_broadcast",
      "title": "Navvo is adding Saudi Arabia",
      "body": "Routing and search are expanding to Saudi Arabia over the coming weeks.",
      "url": null,
      "data": null,
      "isRead": true,
      "readAt": "2026-08-26T09:00:11.000Z",
      "createdAt": "2026-08-25T18:30:00.000Z"
    }
  ],
  "total": 2,
  "limit": 50,
  "offset": 0
}

Response — empty inbox

A user who has never received a notification gets an empty array, not an error and not a null:

{ "items": [], "total": 0, "limit": 50, "offset": 0 }

Response — filtered by type

GET /me/notifications?type=admin_broadcast returns the same envelope shape, scoped to that one type; total reflects the filtered count, not the whole inbox.

Error responses

Same error envelope as the rest of the API. The two you will actually see on this endpoint:

Missing or invalid bearer token — 401:

{
  "message": "Missing bearer token",
  "error": "Unauthorized",
  "statusCode": 401,
  "code": "UNAUTHENTICATED",
  "requestId": "2fa48173-…"
}

limit/offset outside their allowed range — 400:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": ["limit must not be greater than 200"],
  "code": "VALIDATION_ERROR",
  "requestId": "e9289fd5-…"
}

Saved places

A user's favorites with custom name, icon, note, list, and tags — synced across devices.

GET · POST /me/saved-places

GET returns the newest 100 favorites. POST saves a point:

FieldTypeNotes
namestringrequired — the place/point name
lat / lngnumberrequired
detailstringoptional subtitle/address
placeIdstringoptional Navvo place id — also files the place into the user's "Saved places" list
customNamestringoptional user-chosen display name
notestringoptional private note
iconstringoptional icon/emoji
listNamestringoptional grouping label
tagsstringoptional comma-separated tags (max 12)
  • PATCH /me/saved-places/{id} — update customName, note, icon, listName, tags.
  • DELETE /me/saved-places/{id} → { "deleted": true, "id" }.

Every saved place — from GET, and from the POST/PATCH responses so you can use the result without re-fetching — comes back like this:

{
  "id": "6f1c…", "type": "favorite", "label": "Ardh cafe",
  "lat": 31.990558, "lng": 35.880810,
  "location": { "x": 35.880810, "y": 31.990558 },
  "metadata": {
    "name": "Ardh cafe", "detail": "Rainbow St", "placeId": "31c5a4e2-…",
    "customName": null, "note": null, "icon": null, "listName": null, "tags": []
  },
  "createdAt": "…", "updatedAt": "…"
}
Read coordinates from lat and lng.location is the raw stored geometry and its shape is driver-dependent — it is currently an { x, y } object where x is longitude and y is latitude, which is the reverse of the lat,lng order used everywhere else in this API. lat and lng are plain numbers and always mean what they say. Treat location as opaque.

The user's own fields live under metadata, not at the top level: customName, note, icon, listName and tags are all nested there. label is the display name the server maintains (it tracks customName when you set one, falling back to name).

GET · POST /me/locations

Pinned anchors like home and work: { "type": "home" | "work" | "favorite" | "custom", "label", "lat", "lng" }.

GET lists them, each carrying plain numeric lat and lng alongside the raw location geometry. Read the numbers, not the geometry — see the warning above.

  • DELETE /me/locations/{id} — remove one pinned anchor. Scoped to the calling user; another user's id returns 404, not someone else's data.

Lists — GET · POST /me/lists

Named collections of catalog places. Every account has four default lists, created automatically the first time it touches this API:

kindNameIcon
favouritesFavouritesheart
want-to-goWant to gobookmark
saved-tripsSaved Tripsroute
labelledLabelledtag

A default list is an ordinary list in every respect except that it cannot be deleted. Rename it, re-icon it, share it, hide it from the map, empty it — all allowed. Match them on kind, never on name: the user is free to rename Favourites to anything.

GET /me/lists

{
  "lists": [
    {
      "id": "9f3c…", "kind": "favourites", "name": "Favourites", "icon": "heart",
      "iconLightUrl": "https://navvo.io/uploads/list-icons/heart-light.svg",
      "iconDarkUrl": "https://navvo.io/uploads/list-icons/heart-dark.svg",
      "system": true, "public": false, "hiddenOnMap": false,
      "shareSlug": "favourites-8a1c4d02", "shareUrl": null,
      "count": 1,
      "canDelete": false,
      "deleteBlockedReason": "A default list cannot be deleted",
      "createdAt": "…", "updatedAt": "…",
      "places": [
        {
          "savedPlaceId": "b71e…", "placeId": "31c5a4e2-…", "slug": "ardh-cafe-ard-kafyh-2bd24a",
          "nameEn": "Ardh cafe", "nameAr": "…", "categories": ["cafe"], "rating": 4.6,
          "lat": 31.953, "lng": 35.913,
          "imageUrl": "https://navvo.io/uploads/places/…/a7f3.webp",
          "imageSource": "photo", "imageWidth": 408, "imageHeight": 544,
          "note": "try the flat white", "addedAt": "…"
        }
      ]
    }
  ],
  "total": 4,
  "totalPlaces": 1
}
FieldTypeNotes
kindstring | nullOne of the four defaults, or null for a list the user made.
iconstring | nullIcon name. Match on this if you ship your own icon set.
iconLightUrl / iconDarkUrlstring | nullAbsolute URLs to a 24 × 24 SVG of that icon — drop straight into an <img>. Both null when the icon name is not one Navvo ships (see below).
systembooleantrue for a default list.
hiddenOnMapbooleanThe user has hidden this list's places from their map. Presentational — the places are still returned.
shareUrlstring | nullNon-null only while public is true.
countnumberPlaces in the list.
canDeletebooleanWhether DELETE would succeed right now.
deleteBlockedReasonstring | nullWhy not, when canDelete is false.
places[].savedPlaceIdstringThe membership id — this is what DELETE/move take, not the place slug or id.

Lists come back in a stable order: the four defaults first, then the user's own by recency.

List icons

icon is a name; iconLightUrl / iconDarkUrl are ready-to-render SVGs of it, so a native client does not have to bundle an icon library to match the web. Pick the variant by your interface theme — the light file has a dark stroke for light backgrounds, and vice versa.

https://navvo.io/uploads/list-icons/{icon}-{light|dark}.svg

icon accepts any string on POST/PATCH, but URLs are only returned for icons Navvo ships — anything else gives null for both, rather than a link that would 404. The catalogue:

heart bookmark route tag star house briefcase map-pin flag coffee utensilsshopping-bag store plane car bed mountain trees waves camera music film bookpalette dumbbell gift baby paw-print graduation-cap building-2

The first four are the default lists' icons. Treat the list as open — new names may be added, so fall back to your own glyph when the URLs come back null.

Drive your UI from canDelete / deleteBlockedReason. They tell you to grey out the delete control and why, so the user never discovers the rule by getting a 409 back.

The rest of the surface

  • POST /me/lists { "name": "Weekend cafés", "public"?: false, "icon"?: "coffee" } — create one. Returns the same shape as a list in GET.
  • PATCH /me/lists/{id} { "name"?, "public"?, "hiddenOnMap"?, "icon"? } — edit any list, default or not.
  • DELETE /me/lists/{id} — delete a list. 409 if it is a default list, or if it still has places — empty it first. → { "deleted": true, "id" }
  • POST /me/lists/{id}/places { "placeId": "…", "note"? } — add a catalog place. Re-adding one that is already there updates its note instead of duplicating it.
  • DELETE /me/lists/{id}/places/{savedPlaceId} — remove one place from a list.
  • PATCH /me/lists/{id}/places/{savedPlaceId} { "toListId": "…" } — move a place to another list. A real move, so the note travels with it and the place is never briefly in neither list. If the destination already holds that place the two are merged (mergedWithExisting: true) rather than duplicated. → { "moved": true, "savedPlaceId", "fromListId", "toListId", "mergedWithExisting" }
  • POST /me/lists/{id}/feedback { "message": "…", "category"? } — send feedback about a list. → { "ok": true, "id" }

GET /shared-lists/{shareSlug} — public

The shared-list viewer — no auth; the slug is the secret, and only lists the owner marked public resolve. The response is scrubbed (no user ids):

curl -s "https://navvo.io/api/v1/shared-lists/weekend-cafes-a1b2c3"
{
  "found": true, "name": "Weekend cafés", "updatedAt": "…",
  "places": [
    { "slug": "ardh-cafe-ard-kafyh-2bd24a", "nameEn": "Ardh cafe", "nameAr": "…",
      "categories": ["cafe"], "rating": 4.6, "center": "POINT(35.913 31.953)", "note": null }
  ]
}

An unknown/private slug returns { "found": false }.

Owner view.GET /me/shared-lists/{slug} returns the same list for its owner, with a Bearer token instead of relying on the slug being secret. It answers 401 without a token, so use the public /shared-lists/{slug} form for share links and this one inside an authenticated account UI.

GET · POST · DELETE /me/history

The user's search/visit history (newest first, capped at 100 returned).

  • GET /me/history → { "items": [ … ], "total" }
  • POST /me/history { "name", "lat", "lng", "detail"?, "placeId"? } — record an entry
  • DELETE /me/history — clear all → { "deleted": true }
  • DELETE /me/history/{id} — remove one entry

Commutes (leave-time advice + alarms)

Saved home→work-style commutes, each with day-of-week repeat, a permanent or temporary lifespan, and any number of alarm lead-times. The server evaluates each enabled commute every minute against live traffic and, when the moment to leave approaches, sends a push notification (via the user's registered push tokens) — e.g. "Leave in ~15 min for Work."

All routes are Bearer-authenticated and scoped to the signed-in user.

  • GET /me/commutes → { "commutes": Commute[] }
  • POST /me/commutes Commute (without id) → { "commute" } (max 20 per user)
  • PUT /me/commutes/{id} (partial Commute) → { "commute" }
  • DELETE /me/commutes/{id} → { "ok": true }
// Commute
{
  "id": "…",
  "name": "Work",
  "from": { "lat": 31.95, "lng": 35.91, "label": "Home" },
  "to":   { "lat": 31.99, "lng": 35.95, "label": "Office" },
  "arriveBy": "09:00",              // HH:MM, local (Asia/Amman)
  "days": [0,1,2,3,4],              // 0=Sun … 6=Sat; [] = every day
  "permanent": true,                // false = temporary (auto-expires)
  "expiresAt": null,                // "YYYY-MM-DD" when temporary
  "notifications": [{ "minutesBefore": 30 }, { "minutesBefore": 15 }],
  "enabled": true
}
Alarm delivery uses Firebase Cloud Messaging and activates once the platform operator configures the server-side FCM credential; commute records and the schedule are stored regardless. Times are evaluated in Asia/Amman.

Live location sharing

Share a live position under a rotating, unguessable key. A share auto-expires 2 hours after the last ping; each ping extends it.

POST /me/live-share

Start a share: { "label"? } → { "shareKey": "…", "active": true, "expiresAt": "…" }. The viewer URL to hand out is https://navvo.io/map?live=<shareKey>.

GET /me/live-share

The user's active shares: { "items": [ { "shareKey", "label", "lastPingAt", "expiresAt" } ] }.

POST /me/live-share/{key}/ping

Push the current position (call every few seconds while sharing):

FieldTypeNotes
lat / lngnumberrequired
headingnumberoptional course in degrees
accuracynumberoptional GPS accuracy in metres
destNamestringShare-ETA — destination name while navigating
destLat / destLngnumberShare-ETA — destination coordinates
etaSecondsnumberShare-ETA — seconds to arrival; resend on every ping while navigating

→ { "active": true } ({ "active": false } once stopped/expired — stop pinging).

POST /me/live-share/{key}/stop

End the share → { "stopped": true }.

GET /live/{key} — public

The viewer read — no auth, poll it a few times a minute:

{
  "active": true, "lat": 31.9539, "lng": 35.9106, "heading": 270, "accuracy": 8,
  "label": "Lina", "updatedAt": "…",
  "destination": { "name": "Airport", "lat": 31.7226, "lng": 35.9932 },
  "etaSeconds": 1240, "arrivalTime": "2026-07-09T11:42:00.000Z"
}

destination, etaSeconds, and arrivalTime are only present while the sharer is navigating (they sent Share-ETA fields on the ping) — otherwise they are null. Ended/unknown keys return { "active": false }; a share started but not yet pinged returns { "active": true, "pending": true }.


Hand off from your app to the full Navvo map experience with plain URLs:

URLOpens
https://navvo.io/map/place/{slug}the place detail page for a catalog place
https://navvo.io/map?pin=31.95,35.91&name=Meet%20herea dropped pin at lat,lng with an optional label
https://navvo.io/map?from=31.95,35.91&to=31.72,35.99&navigate=1&mode=autoa route — with navigate=1 turn-by-turn starts immediately; mode is a routing profile (auto, pedestrian, bicycle, …)
https://navvo.io/map?live=KEYthe live-share viewer following a share key

Push notifications are not operated by Navvo. There is no Navvo-hosted push service — mobile apps should trigger their own FCM/APNS pushes from their backend, using these APIs as the data source (e.g. poll GET /live/{key} for arrival, or your own logic over saved places and history).
Copyright © 2026