User API
All endpoints require a Firebase ID token in the Authorization: Bearer <token>
header. A missing or invalid token returns 401 UNAUTHORIZED.
All endpoints with :id enforce that the authenticated user's UID matches
:id. A mismatch returns 403 FORBIDDEN.
Get User
GET /user/:id?lat=<number>&lng=<number>¬ificationToken=<string|null>
Auth: Bearer token; caller must match :id
Returns the user's profile, client configuration, and sunrise/sunset times for the given location. Creates the user document if it does not exist.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
lat |
number |
yes | −90 to 90; used to compute sun times |
lng |
number |
yes | −180 to 180; used to compute sun times |
notificationToken |
string \| null |
yes | FCM push token; pass null to clear |
Response 200
{
"user": {
"id": "uid_abc123",
"authUser": {/* AuthUser object */},
"name": "Arjun Mehta",
"email": null,
"phoneNumber": "+919876543210",
"photoURL": "https://example.com/photo.jpg",
"isAnonymous": false,
"notificationToken": "fcm-token-xyz",
"rides": ["ride_id1", "ride_id2"], // populated from rides subcollection
"settings": {
"homeLocation": { "lat": 12.9716, "lng": 77.5946 },
"notifications": true,
"shareLocation": true,
},
"type": "subscriber",
"status": "active",
"subscriptionExpiryAt": "2026-06-01T00:00:00.000Z",
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-06-01T08:00:00.000Z",
},
"config": {
// Nested intentionally: the outer `config` groups location-derived data
// (sunrise/sunset) with the inner `config` block from getClientConfig().
"config": {
"appSHA1": "string | null",
"revenueCatAPIKey": "string",
"sendCrashlyticsData": true,
"urlAboutUs": "https://...",
"urlPrivacy": "https://...",
"urlTerms": "https://...",
},
"sunrise": "06:15",
"sunset": "18:42",
},
}
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
Save Settings
POST /user/:id/settings
Auth: Bearer token; caller must match :id
Request
{
"homeLocation": { "lat": 12.9716, "lng": 77.5946 }, // null to clear
"notifications": true,
"shareLocation": false,
}
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
Save Notification Token
POST /user/:id/notification-token
Auth: Bearer token; caller must match :id
Request
{ "token": "fcm-token-xyz" } // null to clear
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
Save Alternative Details
POST /user/:id/alt
Auth: Bearer token; caller must match :id
Updates name, phone number, or photo URL. Only non-null fields are written.
Planned (email verification side-effect) — not yet live. Providing
authUser.emailisnullsaves the address to Firestore withisEmailVerified: falseand enqueuesSendVerificationEmailActivityon the worker to send a verification email via Resend. The email is not active until verified — see Verify Email Confirmation (Web).
Request
{
"name": "Arjun Mehta", // null to skip
"phoneNumber": "+919876543210", // null to skip; E.164 format
"photoURL": "https://example.com/photo.jpg", // null to skip
"email": "rider@example.com", // null to skip; only accepted when authUser.email is null
}
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 400 | INVALID_FIELD |
phoneNumber does not match E.164 pattern |
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
| 409 | EMAIL_ALREADY_SET |
email provided but authUser.email is already non-null |
| 409 | EMAIL_ALREADY_IN_USE |
email is already associated with another account |
Resend Verification Email
Planned — not yet live.
POST /user/:id/verify-email
Auth: Bearer token; caller must match :id
Re-enqueues SendVerificationEmailActivity. If an unused, non-expired
verificationTokens doc already exists for this user, the activity reuses it
(same link is resent). A new token is generated only when the existing one has
expired or been used.
Request — empty body
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 400 | NO_EMAIL_SET |
user.email is null; no email to verify |
| 400 | EMAIL_ALREADY_VERIFIED |
isEmailVerified is already true |
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
| 429 | TOO_MANY_REQUESTS |
Resend rate limit hit |
Verify Email Confirmation (Web)
Planned — not yet live.
GET https://95octane.com/verify-email?token=<token>
Astro SSR route. Looks up verificationTokens/{token}; validates
expiresAt >
now() and usedAt == null; writes isEmailVerified: true on the
user document and sets usedAt on the token. Renders a static confirmation page
on success; error page for expired or already-used tokens. No Firebase ID token
required — the token in the URL is the only credential.
Delete User
DELETE /user/:id
Auth: Bearer token; caller must match :id
Sets status: "deleted", records deletedAt, and revokes all active Firebase
Auth sessions. Permanent data removal is handled by a background workflow after
a 7-day grace period (see
worker/workflows/user.md).
Request — empty body
Response 202
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
List Favorites
GET /user/:id/favorites
Auth: Bearer token; caller must match :id
Returns all saved favorite locations. If settings.homeLocation is set, it is
injected as the first item with id: "home-location" and type: "home".
Response 200
{
"favorites": [
{
"id": "home-location", // synthetic; only present when homeLocation is set
"title": "Home",
"type": "home",
"latitude": 12.9716,
"longitude": 77.5946,
"placeId": null,
"createdAt": "2025-06-01T08:00:00.000Z",
"updatedAt": "2025-06-01T08:00:00.000Z",
},
{
"id": "fav_abc123",
"title": "Office",
"type": "destination",
"latitude": 12.9352,
"longitude": 77.6245,
"placeId": "ChIJbU60yXAWrjsR4E9-UejD3_g",
"createdAt": "2025-06-01T08:00:00.000Z",
"updatedAt": "2025-06-01T08:00:00.000Z",
},
],
}
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
Add Favorite
POST /user/:id/favorite
Auth: Bearer token; caller must match :id
Reserved
typevalue:type: "home"is server-managed. The "Home" favorite is synthesized on everyGET /user/:id/favoritesresponse from the user'ssettings.homeLocation(set viaPOST /user/:id/settings) — it is not stored in thefavoritessubcollection. Clients must not submittype: "home"here; usePOST /user/:id/settingswithhomeLocationinstead.
Request
{
"title": "Office", // required; 3–100 chars
"type": "destination", // required; one of origin | destination | meetingPoint | haltPoint | restaurant | fuelStation | other ("home" is reserved)
"latitude": 12.9352, // required
"longitude": 77.6245, // required
"placeId": "ChIJbU60yXAWrjsR4E9-UejD3_g", // required; null if no Place ID
}
Response 201
{
"id": "fav_abc123",
"title": "Office",
"type": "destination",
"latitude": 12.9352,
"longitude": 77.6245,
"placeId": "ChIJbU60yXAWrjsR4E9-UejD3_g",
"createdAt": "2025-06-01T08:00:00.000Z",
"updatedAt": "2025-06-01T08:00:00.000Z",
}
Errors
| Status | Code | Condition |
|---|---|---|
| 400 | MISSING_FIELD |
title, type, latitude, longitude, or placeId not provided |
| 400 | INVALID_FIELD |
title < 3 or > 100 chars; invalid type; placeId present but < 6 chars |
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
Remove Favorite
DELETE /user/:id/favorite/:favoriteId
Auth: Bearer token; caller must match :id
Request — empty body
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
| 404 | NOT_FOUND |
Favorite with :favoriteId does not exist for this user |
Add Authentication Provider
No server endpoint required.
Linking an additional sign-in provider (Google or Apple) is a client-side
Firebase Auth SDK operation. The client calls linkWithCredential() on the
FirebaseUser object directly.
The server reflects the result automatically: the next GET /user/:id call
returns the updated authUser.provider array (e.g.
["google.com", "apple.com"]) from the Firebase Auth token metadata. No
server-side write or endpoint is needed.
Buddy System
All buddy endpoints are Planned — not yet live.
All buddy endpoints require a Firebase ID token in
Authorization: Bearer <token>. A missing or invalid token returns
401 UNAUTHORIZED. All endpoints enforce that the authenticated user's UID
matches :id — mismatch returns 403 FORBIDDEN.
Send Buddy Request
POST /user/:id/buddy-request
Auth: Bearer token; caller must match :id
Creates mirrored buddyRequests docs on both users — type: "sent" on the
sender, type: "received" on the recipient. expiresAt is set to 90 days from
creation.
Planned stub: Check
users/{:id}/blockedUsers/{recipientId}andusers/{recipientId}/blockedUsers/{:id}before creating the request. If either doc exists, return200 { "success": true }— a silent success that gives no indication a block is in place.
Request
{ "recipientId": "uid_xyz" }
Response 201
{
"id": "req_abc123",
"type": "sent",
"otherUserId": "uid_xyz",
"senderId": "uid_abc",
"recipientId": "uid_xyz",
"createdAt": "2026-05-24T10:00:00.000Z",
"expiresAt": "2026-08-22T10:00:00.000Z",
}
Errors
| Status | Code | Condition |
|---|---|---|
| 400 | MISSING_FIELD |
recipientId not provided |
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
| 404 | USER_NOT_FOUND |
recipientId does not exist or is not active |
| 409 | REQUEST_ALREADY_PENDING |
A pending request exists between these users in either direction |
| 409 | ALREADY_BUDDIES |
Users are already connected |
Cancel or Decline Buddy Request
DELETE /user/:id/buddy-request/:requestId
Auth: Bearer token; caller must match :id
Unified cancel/decline endpoint. Reads users/{:id}/buddyRequests/{requestId}
to determine intent: type: "sent" → cancel (sender); type: "received" →
decline (recipient). Deletes both mirrored docs on success.
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Caller UID ≠ :id, or caller is neither sender nor recipient |
| 404 | NOT_FOUND |
Request does not exist or has expired |
Accept Buddy Request
POST /user/:id/buddy-request/:requestId/accept
Auth: Bearer token; caller must match :id
Caller must be the recipientId of the request. On success: writes to both
users' buddies subcollections and deletes both mirrored buddyRequests docs.
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Caller UID ≠ :id, or caller is not recipientId |
| 404 | NOT_FOUND |
Request does not exist or has expired |
| 409 | ALREADY_BUDDIES |
Users are already connected (stale request) |
List Buddy Requests
GET /user/:id/buddy-requests
Auth: Bearer token; caller must match :id
Returns active (non-expired) requests only, filtered by expiresAt > now().
Response 200
{
"sent": [
{
"id": "req_abc123",
"type": "sent",
"otherUserId": "uid_xyz",
"senderId": "uid_abc",
"recipientId": "uid_xyz",
"createdAt": "2026-05-24T10:00:00.000Z",
"expiresAt": "2026-08-22T10:00:00.000Z",
},
],
"received": [],
}
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
List Buddies
GET /user/:id/buddies
Auth: Bearer token; caller must match :id
Returns the buddies subcollection. Each entry contains only the buddy's UID
and connection timestamp — fetch GET /user/:buddyId separately for full
profile data.
Response 200
{
"buddies": [
{
"id": "uid_xyz",
"createdAt": "2026-05-20T10:00:00.000Z",
},
],
}
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
Remove Buddy
DELETE /user/:id/buddy/:buddyId
Auth: Bearer token; caller must match :id
Removal is mutual — deletes :buddyId from :id's buddies subcollection and
:id from :buddyId's buddies subcollection atomically.
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
| 404 | NOT_FOUND |
:buddyId is not a buddy of :id |
Block / Unblock User
Planned — not yet live.
All block endpoints require a Firebase ID token in
Authorization: Bearer <token>. A missing or invalid token returns
401 UNAUTHORIZED. All endpoints enforce that the authenticated user's UID
matches :id — mismatch returns 403 FORBIDDEN. Any authenticated user
(free, trial, subscriber, beta) may block or unblock. The blocked user
is never notified, and the block list is private to :id.
Block User
POST /user/:id/block/:targetId
Auth: Bearer token; caller must match :id
Writes users/{:id}/blockedUsers/{targetId}. Idempotent — blocking an
already-blocked user succeeds silently and returns 200.
Side effects, applied atomically in a single Firestore batch write together with the block doc:
- Delete
users/{:id}/buddies/{targetId}andusers/{targetId}/buddies/{:id}if they exist (removes the buddy connection in both directions). - Delete any pending
buddyRequestsdocs between the two users in both directions.
Request — empty body
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 400 | SELF_ACTION |
:targetId equals :id |
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
Unblock User
DELETE /user/:id/block/:targetId
Auth: Bearer token; caller must match :id
Deletes users/{:id}/blockedUsers/{targetId}. Restores the ability to interact
but does not auto-restore any prior buddy connection — the users must
re-establish it through the buddy request flow.
Request — empty body
Response 200
{ "success": true }
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |
| 404 | NOT_FOUND |
:targetId is not on :id's block list |
List Blocked Users
GET /user/:id/blocked
Auth: Bearer token; caller must match :id
Returns the blockedUsers subcollection. Each entry contains only the blocked
user's UID and block timestamp — same shape as the buddies list. No pagination
initially.
Response 200
{
"blocked": [
{
"id": "uid_xyz",
"createdAt": "2026-05-20T10:00:00.000Z",
},
],
}
Errors
| Status | Code | Condition |
|---|---|---|
| 401 | UNAUTHORIZED |
No valid Firebase ID token |
| 403 | FORBIDDEN |
Authenticated user UID ≠ :id |