Skip to main content

REST API Reference

Base URL

EnvironmentURL
Development (__DEV__)https://<EXPO_PUBLIC_DEV_HOST>
Preview / Productionhttps://server.sapot.lan

Resolved by config/runtime.ts (getApiUrl()). The app always speaks HTTPS — there is no plaintext HTTP fallback and no explicit port in the base URL. A dev/QA host override (setRuntimeHostOverride, persisted via features/shared/core/stores/secure-config.ts) takes precedence over both rows when set. See ENV_CONFIG.md.

Authentication

All protected endpoints require a JWT Bearer token in the Authorization header:

Authorization: Bearer <access_token>

The apiClient (axios instance) automatically attaches the token via an interceptor. On 401, it attempts a token refresh before retrying.


Auth — /auth

POST /auth/ — Register

Auth: None

Request body:

{
"username": "string — required",
"password": "string — required",
"first_name": "string — required",
"last_name": "string — required",
"phone_number": "string? — optional",
"email": "string? — optional"
}

Response 201:

{
"id": "string",
"username": "string",
"first_name": "string",
"last_name": "string",
"phone_number": "string",
"email": "string",
"access_token": "string",
"refresh_token": "string",
"detail": "string"
}

POST /auth/token — Login

Auth: None
Content-Type: application/x-www-form-urlencoded

Request body:

grant_type=password&username=<username>&password=<password>&scope=&client_id=&client_secret=

Response 200:

{
"access_token": "string",
"refresh_token": "string",
"token_type": "bearer"
}

POST /auth/logout — Logout

Auth: Required


POST /auth/refresh — Refresh Token

Auth: None

Request body:

{ "refresh_token": "string" }

Response 200:

{
"access_token": "string",
"refresh_token": "string"
}

POST /auth/reauthenticate — Re-authenticate (confirm password)

Auth: Required · Rate limit: 5/minute

Issues a short-lived (10 min) reauth token for sensitive actions, without invalidating the current session.

Request body:

{ "current_password": "string" }

Response 200:

{ "reauth_token": "string" }

Errors: 401 — wrong password.


GET /auth/exists/ — Check Identifier Exists

Auth: None
Query params: identifier=<username|email|phone>

Response 200:

{ "exists": "boolean" }

POST /auth/change-password — Change Password

Auth: Required · Rate limit: 3/minute

Request body:

{
"current_password": "string",
"new_password": "string"
}

POST /auth/forgot-password/security-questions — Set Security Questions

Auth: Bearer token (from registration flow)

Request body:

{
"questions": [
{ "question": "string", "answer": "string" }
]
}

GET /auth/forgot-password/security-question — Get Security Question

Auth: None
Query params: identifier=<string>

Response 200:

{ "question": "string" }

POST /auth/forgot-password/security-question/answer — Verify Security Answer

Auth: None
Query params: identifier=<string>

Request body:

{ "question": "string", "answer": "string" }

Response 200:

{ "correct": "boolean", "reset_link": "string" }

GET /auth/forgot-password/reset-password — Check Reset Token Valid

Auth: None
Query params: token=<string>

Response: 200 if valid


POST /auth/forgot-password/reset-password — Reset Password

Auth: None
Query params: token=<string>

Request body:

{ "new_password": "string" }

POST /auth/forgot-password/email — Send Reset Email Code

Auth: None
Query params: email=<string>


POST /auth/forgot-password/email-code — Verify Email Reset Code

Auth: None
Query params: email=<string>&code=<string>

Response 200:

{ "link": "string", "detail": "string" }

POST /auth/forgot-password/recovery-with-recovery-key — Reset via Recovery Key

Auth: None
Content-Type: multipart/form-data
Query params: user_identifier=<string>
Body: key_file (file upload)

Response 200:

{
"recovery-link": "string",
"method": "string",
"expire_in_seconds": "number"
}

POST /auth/forgot-password/generate-new-recovery-key — Generate Recovery Key

Auth: Bearer token (from registration flow)

Response: Plain text recovery key (text/plain)


POST /auth/verify/resend-verification-code — Resend Email Verification

Auth: Required

Response 200:

{ "message": "string" }

POST /auth/verify/verify-code — Verify Email Code

Auth: Required

Request body:

{ "code": "string" }

GET /auth/terms — Terms & Conditions Text

Auth: None

Response 200:

{ "content": "string" }

GET /auth/forgot-password/generate-security-question — Suggest Security Questions

Auth: Bearer token (from registration flow)

Returns the server-curated question list the registration flow offers the user.


GET /auth/forgot-password/recovery-constraints — Recovery Method Cooldowns

Auth: Required

Drives which recovery options the settings UI enables, and why one is greyed out.

Response 200:

{
"recovery_key": {
"has_key": "boolean",
"can_change": "boolean",
"days_since_generated": "number | null",
"days_until_changeable": "number | null"
},
"security_question": {
"has_question": "boolean",
"can_change": "boolean",
"is_burned": "boolean",
"is_expired": "boolean",
"days_since_set": "number | null",
"days_until_changeable": "number | null",
"days_until_expiry": "number | null"
}
}

POST /auth/forgot-password/phone — Send Reset SMS Code

Auth: None

Request body:

{ "phone_number": "string" }

POST /auth/forgot-password/phone-code — Verify Reset SMS Code

Auth: None

Request body:

{ "phone_number": "string", "code": "string" }

Response 200:

{ "link": "string", "detail": "string", "recovery_token": "string" }

POST /auth/forgot-password/otp/send — Send Master-Key Recovery OTP

Auth: None

Distinct from /auth/forgot-password/phone: that one resets the password, this one unlocks a wrapped master key blob (see Recovery Blobs).

Request body:

{ "phone_number": "string" }

POST /auth/forgot-password/otp/verify — Verify Master-Key Recovery OTP

Auth: None

Request body:

{ "phone_number": "string", "code": "string" }

Response 200:

{ "recovery_token": "string", "user_id": "string" }

POST /auth/forgot-password/email-recovery/send — Send Master-Key Recovery Email

Auth: None · Query params: email=<string>


GET /auth/forgot-password/email-recovery/verify — Verify Recovery Email Token

Auth: None · Query params: t=<token>

Response 200:

{ "recovery_token": "string", "user_id": "string" }

Keys — /keys

Peer ECDH public-key distribution. Underpins E2E encryption on both transports — see ARCHITECTURE.md. Handled by features/shared/crypto/peer-key-service.ts.

Some calls here use raw fetch with an explicit Authorization header rather than apiClient, because they run during initialization before the axios interceptor has a token to attach.

POST /keys/register — Register Own ECDH Public Key

Auth: Required

Request body:

{ "ecdh_public_key": "string — base64" }

Response 200: a server-signed credential other peers can verify offline:

{
"peer_id": "string",
"ecdh_public_key": "string — base64",
"issued_at": "number",
"expires_at": "number",
"signature": "string — base64 Ed25519"
}

GET /keys/{peerId} — Fetch a Peer's Signed Credential

Auth: Required

Same body as POST /keys/register's response. The client verifies signature against the server's Ed25519 key before trusting ecdh_public_key; a credential that fails verification is discarded. Registered (non-guest) peers only — guests are not server-registered.


GET /keys/{peerId}/type — Is This Peer a Guest?

Auth: Required

Response 200:

{ "is_guest": "boolean" }

GET /keys/server-public-key — Server Ed25519 Verify Key

Auth: None

Response 200:

{ "ed25519PublicKey": "string — base64" }

Fallback for when EXPO_PUBLIC_SERVER_VERIFY_KEY is not baked into the build. If neither is available the client skips credential signature verification, so treat a missing verify key as a security downgrade, not a harmless default.


POST /keys/contacts/{peerId} — Back Up a Contact's Key

Auth: Required

Guest peers never appear in /keys/{peerId}, so their public key — learned over the TCP handshake — would be lost on re-login. The client encrypts it under its own master key with nacl.secretbox and parks the ciphertext here. The server stores an opaque blob and cannot read the key.

Request body:

{ "encrypted_public_key": "string — base64(nonce ‖ ciphertext)" }

GET /keys/contacts — Restore Backed-Up Contact Keys

Auth: Required

Called on re-login / new device, before conversation keys are derived.

Response 200:

[ { "peer_id": "string", "encrypted_public_key": "string — base64" } ]

Recovery Blobs — /users

Wrapped copies of the user's master key, one per recovery method, so the key survives a forgotten password. The server only ever holds ciphertext.

POST /users/recovery-setup — Store Wrapped Master Keys

Auth: Required

Request body:

{
"blobs": [
{ "method": "string", "wrapped_blob": "string", "metadata": "string?" }
]
}

PUT /users/recovery-keys — Replace Wrapped Master Keys

Auth: Required

Same body as POST /users/recovery-setup. Used after a password change re-wraps the master key.


GET /users/recovery-key — Fetch One Wrapped Blob

Auth: None (authorized by recovery_token)
Query params: recovery_token=<string>&method=<string>

The recovery_token comes from a verified OTP / email / security-question challenge.

Response 200:

{ "wrapped_blob": "string", "metadata": "string | null", "user_id": "string" }

GET /users/wrapped-key — Own Wrapped Key (existence probe)

Auth: Required

LocalEncryptionService calls this on login to decide whether a wrapped key already exists server-side. A 200 means yes; anything else is treated as "not yet uploaded".


POST /users/wrapped-key — Upload Own Wrapped Key

Auth: Required

Request body:

{ "wrapped_blob": "string" }

Failure is non-fatal — the client logs and retries on next login.


User — /user-utils

GET /user-utils/current-user-info/ — Get Current User

Auth: Required (optional override via accessToken param)

Response 200:

{
"id": "string",
"username": "string",
"first_name": "string",
"last_name": "string",
"email": "string",
"phone_number": "string",
"email_verified": "boolean",
"phone_verified": "boolean",
"role": "\"admin\" | \"rescuer\" | \"user\""
}

GET /user-utils/is-rescuer — Check Rescuer Role

Auth: Required

Response 200: true or false


GET /user-utils/is-admin — Check Admin Role

Auth: Required

Response 200: true or false


POST /user-utils/search-user — Search Users

Auth: Required; the authenticated account must have a verified phone number

Query params: identifier_string=<string>&limit=<number=20>&offset=<number=0>

Response 200:

{
"res": [
{
"id": "string",
"username": "string",
"first_name": "string",
"last_name": "string",
"phone_is_verified": "boolean",
"role": "\"admin\" | \"rescuer\" | \"user\""
}
],
"limit": "number",
"offset": "number"
}

GET /user-utils/search-user/{id} — Get User by ID

Auth: Required

Response 200:

{
"id": "string",
"username": "string",
"first_name": "string",
"last_name": "string",
"phone_is_verified": "boolean",
"role": "\"admin\" | \"rescuer\" | \"user\"",
"last_active": "ISO-8601 string | null",
"status": "\"Active\" | \"Inactive\""
}

last_active / status come from UserActivity, stamped on WS connect/disconnect (and REST activity). The client uses last_active to render the "Last seen …" label for offline peers.


GET /user-utils/get-announcements — Role-Filtered Announcements

Auth: Required
Query params: limit=<number=20>&offset=<number=0>

Returns active, non-expired announcements filtered by the caller's role: admins see all; rescuers see rescuer + user-targeted announcements; regular users see only user-targeted announcements. Ordered newest first.

Response 200:

{
"role": "\"admin\" | \"rescuer\" | \"user\"",
"count": "number",
"announcements": [ "Announcement" ]
}

Profile — /update & /profile-picture

POST /update/profile — Update Profile

Auth: Required

Request body (all fields optional):

{
"username": "string?",
"first_name": "string?",
"last_name": "string?",
"phone_number": "string?",
"email": "string?"
}

POST /profile-picture/me — Upload Profile Picture

Auth: Required
Content-Type: multipart/form-data
Body: file (image upload)

Response 200:

{
"message": "string",
"photo_id": "string",
"url": "string"
}

GET /profile-picture/me — Get Own Profile Picture

Auth: Required

Response 200:

{ "url": "string" }

GET /profile-picture/:userId — Get User Profile Picture

Auth: Required

Response 200:

{ "url": "string" }

Sync — /sync

GET /sync/pull — Pull Changes from Server

Auth: Required
Query params sent by the client: last_pulled_at=<number>&schema_version=<number>

limit is a server-side default (100 per table), not something the client sends — page size is not client-controlled.

schema_version is sent by features/sync/api/sync.api.ts but the server ignores it: the parameter is commented out in app/api/sync.py (# schema_version: int = Query(default=1)), so FastAPI discards it. It is preserved on the client so a future server can use it without a mobile release.

Response 200:

{
"changes": {
"conversations": { "created": [], "updated": [], "deleted": [], "next_cursor": "number|null", "has_more": "boolean" },
"messages": { "created": [], "updated": [], "deleted": [], "next_cursor": "number|null", "has_more": "boolean" },
"conversation_participants":{ "created": [], "updated": [], "deleted": [], "next_cursor": "number|null", "has_more": "boolean" },
"calls": { "created": [], "updated": [], "deleted": [], "next_cursor": "number|null", "has_more": "boolean" },
"call_participants": { "created": [], "updated": [], "deleted": [], "next_cursor": "number|null", "has_more": "boolean" },
"message_receipts": { "created": [], "updated": [], "deleted": [], "next_cursor": "number|null", "has_more": "boolean" }
},
"timestamp": "number — Unix timestamp (ms)"
}

next_cursor/has_more are present on every table's change-set, and the client does page through them: SyncService.pullFromServer() loops while any table reports has_more, advancing the cursor to Math.min(...next_cursor) across those tables and merging pages by id. The loop is bounded at 50 iterations, and stops early (with a warning) if has_more is true but no table returns a usable next_cursor.


POST /sync/push — Push Local Changes to Server

Auth: Required

Request body:

{
"last_pulled_at": "number?",
"changes": {
"conversations": {
"created": [ { "id": "string", "title": "string|null", "conversation_type": "string", "is_deleted": "boolean", "created_at": "number", "updated_at": "number" } ],
"updated": [ { "id": "string", "...": "partial fields" } ],
"deleted": [ "string — id" ]
},
"conversation_participants": { "created": [], "updated": [], "deleted": [] },
"messages": { "created": [], "updated": [], "deleted": [] },
"calls": { "created": [], "updated": [], "deleted": [] },
"call_participants": { "created": [], "updated": [], "deleted": [] },
"message_receipts": { "created": [], "updated": [], "deleted": [] }
},
"guest_users": { "<user_id>": { "username": "string", "first_name": "string", "last_name": "string" } }
}

guest_users lets the client supply display-name hints for not-yet-registered peers referenced by a pushed record (e.g. a P2P-only message from a guest). The server uses these to materialize a placeholder User+Guest row instead of failing the FK.

Response 200:

{ "status": "ok" }

Errors: 409 — a referenced record was modified on the server after the client's last_pulled_at (conflict; client should re-pull before retrying). 404 — the record was already soft-deleted on the server. 500 — unhandled sync error (transaction rolled back).


Public Chat — /public-chat

Server-relayed broadcast channel, separate from P2P chat. History over REST; live messages arrive over the signaling WebSocket (see CONNECTION_MESSAGES.md).

GET /public-chat — Message History

Auth: Required
Query params: limit=<number>&before=<number?>before is an epoch-ms cursor for paging backwards.

Response 200:

{
"messages": [
{
"id": "string",
"content": "string",
"is_deleted": "boolean",
"sender_id": "string",
"sender_first_name": "string | null",
"sender_last_name": "string | null",
"sender_username": "string | null",
"created_at": "number — Unix ms"
}
],
"limit": "number",
"oldest_created_at": "number | null"
}

Page backwards by passing the previous response's oldest_created_at as before; null means the start of history has been reached.


GPS — /gps

Location history and last-known positions for the map screen. Live streaming does not go through REST — it uses a dedicated WebSocket (/gps/ws/<userId>), independent of ConnectionService.

GET /gps/latest — Latest Location Per User

Auth: Required

Response 200:

[
{
"user_id": "string",
"latitude": "number",
"longitude": "number",
"timestamp": "ISO-8601 string",
"username": "string",
"role": "\"admin\" | \"rescuer\" | \"user\" — optional; drives the map marker"
}
]

GET /gps/history/{userId} — Location History for One User

Auth: Required
Query params: limit=<number=50>

Response 200:

[
{
"id": "number",
"user_id": "string",
"latitude": "number",
"longitude": "number",
"timestamp": "ISO-8601 string"
}
]

Note id is a number here, unlike the string UUIDs used elsewhere in this API.


SMS Gateway — /gsm

Proxies the GSM module (GSM-module/GSM-fastapi/) for phone verification and for reaching people who are not on the LAN. The API server forwards to the GSM service; the handset never talks to it directly.

GET /gsm/health — GSM Module Health

Auth: Required

Response 200:

{ "status": "string", "gsm_ready": "boolean", "connected": "boolean", "detail": "string" }

The server preserves the gateway's HTTP 503 status when the modem is not ready. Callers treat that response as unavailable rather than relying only on the JSON status field.

gsm_ready (modem registered on the network) and connected (API can reach the GSM service) fail independently — surface them separately rather than collapsing to one "offline" state.


POST /gsm/sms/send — Send SMS to a Known User

Auth: Required
Query params: user_id=<string>&message=<string> (query params, not a JSON body)

Response 200:

{ "msg_id": "string", "ok": "boolean", "to": "string" }

Response 403 when the sender's phone number is not verified:

{
"detail": {
"reason": "PHONE_VERIFICATION_REQUIRED",
"message": "Verify your phone number before sending SMS."
}
}

Response 503 when the outbound queue is full:

{
"detail": {
"message": "Outbound SMS queue is full",
"reason": "QUEUE_FULL",
"msg_id": "string"
}
}

The chat screen marks the local message not_sent, shows that the SMS service is busy, and keeps the manual resend action available.


POST /gsm/contact-unknown-user — SMS an Arbitrary Number

Auth: Required
Query params: target_phone_number=<string>

Response 200:

{
"status": "string",
"detail": "string",
"user_id": "string",
"is_sapot_user": "boolean"
}

is_sapot_user reports whether the number already belongs to a registered account. If the onboarding SMS is rejected, the endpoint preserves the gateway error and the app does not display its success confirmation.


POST /gsm/verify — Verify Phone Code

Auth: Required

Request body:

{ "code": "string" }

POST /gsm/resend — Resend Phone Verification Code

Auth: Required

Response 200:

{ "message": "string" }

Phone verification request and resend calls preserve HTTP 503 gateway failures. The verification screen remains retryable and displays a busy or unavailable message.


POST /gsm/migrate-phone-user — Claim a Ghost Phone Account

Auth: Required

Merges a "ghost" record — created when someone was SMS'd before registering — into the calling user's account.

Response 200:

{ "migrated": "boolean", "ghost_user_id": "string?", "detail": "string?" }

Tile Server — separate deployment

Map tiles are not served by the API. getTileServerUrl() resolves a separate origin (<host>/tiles, see ENV_CONFIG.md), so the tileserver can be down while the API is healthy.

PathPurpose
GET /styles/basic-preview/style.jsonReachability probe (checkTileServerReachable). Probed instead of a tile because it is always present, whereas a given {z}/{x}/{y} depends on the loaded mbtiles.
GET /styles/basic-preview/{z}/{x}/{y}.pngRaster basemap tiles rendered by MapLibre.

The probe never throws — a failure is reported as false. MapLibre swallows tile fetch errors and exposes no error event, so this probe is the only way to tell the user their basemap is missing.


Debug / QA — /testing

Only reachable when the server runs with ENVIRONMENT=development. Gated in the app behind config/debug.ts.

POST /testing/login-as/{handle} — Log In as a Seeded Fixture

Auth: X-QA-Token header, from EXPO_PUBLIC_QA_API_TOKEN; must match the server's QA_API_TOKEN

Mints tokens for a seeded qa_* fixture account instead of registering a new user. Same response shape as POST /auth/.


Utility — / & /ping

GET / — Health Check

Auth: None
Response: 200 if server is up

GET /ping — Ping (Latency Check)

Auth: None

Response 200:

{ "status": "string", "timestamp": "number — server Unix timestamp (ms)" }

The client computes latency as Date.now() - timestamp.