Users & Sync
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.
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.
| Field | Type | Notes |
|---|---|---|
email | string | required — must be a valid email, stored lowercased |
password | string | required — minimum 10 characters |
name | string | optional display name |
turnstileToken | string | browser-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.
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": "…"
}
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.
| Field | Type | Notes |
|---|---|---|
name | string | display name (trimmed, max 160 chars) |
avatar | object | null | { type: "image" | "emoji" | "letter", value, bgColor? }; null clears the avatar |
privacy | object | null | privacy 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.
| Field | Type | Notes |
|---|---|---|
confirm | string | required — must be the literal string DELETE |
password | string | required 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 isnvauth=linkand the client confirms the link viaPOST /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…).
| Field | Type | Notes |
|---|---|---|
idToken | string | required — Firebase ID token from getIdToken() |
curl -s -X POST "https://navvo.io/api/v1/auth/firebase" \
-H "content-type: application/json" \
-d '{ "idToken": "eyJhbGciOiJSUzI1NiIs…" }'
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):
| Param | Type | Notes |
|---|---|---|
limit | integer | 1–200, default 50 |
offset | integer | ≥ 0, default 0 |
type | string | return 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:
type | Sent when | data payload |
|---|---|---|
commute_leave_now | A saved commute's leave-time alarm fires — see Commutes | { "commuteId", "minutesBefore" } |
admin_broadcast | An operator sends a platform-wide announcement | not 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:
| Field | Type | Notes |
|---|---|---|
name | string | required — the place/point name |
lat / lng | number | required |
detail | string | optional subtitle/address |
placeId | string | optional Navvo place id — also files the place into the user's "Saved places" list |
customName | string | optional user-chosen display name |
note | string | optional private note |
icon | string | optional icon/emoji |
listName | string | optional grouping label |
tags | string | optional comma-separated tags (max 12) |
PATCH /me/saved-places/{id}— updatecustomName,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": "…"
}
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 returns404, 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:
kind | Name | Icon |
|---|---|---|
favourites | Favourites | heart |
want-to-go | Want to go | bookmark |
saved-trips | Saved Trips | route |
labelled | Labelled | tag |
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
}
| Field | Type | Notes |
|---|---|---|
kind | string | null | One of the four defaults, or null for a list the user made. |
icon | string | null | Icon name. Match on this if you ship your own icon set. |
iconLightUrl / iconDarkUrl | string | null | Absolute 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). |
system | boolean | true for a default list. |
hiddenOnMap | boolean | The user has hidden this list's places from their map. Presentational — the places are still returned. |
shareUrl | string | null | Non-null only while public is true. |
count | number | Places in the list. |
canDelete | boolean | Whether DELETE would succeed right now. |
deleteBlockedReason | string | null | Why not, when canDelete is false. |
places[].savedPlaceId | string | The 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.
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 inGET.PATCH /me/lists/{id}{ "name"?, "public"?, "hiddenOnMap"?, "icon"? }— edit any list, default or not.DELETE /me/lists/{id}— delete a list.409if 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 }.
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 entryDELETE /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/commutesCommute(withoutid) →{ "commute" }(max 20 per user)PUT /me/commutes/{id}(partialCommute) →{ "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
}
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):
| Field | Type | Notes |
|---|---|---|
lat / lng | number | required |
heading | number | optional course in degrees |
accuracy | number | optional GPS accuracy in metres |
destName | string | Share-ETA — destination name while navigating |
destLat / destLng | number | Share-ETA — destination coordinates |
etaSeconds | number | Share-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 }.
Deep links into the Navvo map
Hand off from your app to the full Navvo map experience with plain URLs:
| URL | Opens |
|---|---|
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%20here | a 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=auto | a route — with navigate=1 turn-by-turn starts immediately; mode is a routing profile (auto, pedestrian, bicycle, …) |
https://navvo.io/map?live=KEY | the live-share viewer following a share key |
GET /live/{key} for arrival, or your own logic over saved places and history).Atlas Agent (NL control)
Turn natural-language commands into structured map actions — search, directions, pins, layer control, and now day-plan itineraries and fleet route optimization.
Voice & Speech-to-Text
Turn spoken audio into text (English + Arabic) and call Navvo's direct AI helpers for query understanding and route labels.