Skip to main content

API Conventions

Cross-cutting behavior shared by every endpoint. For any single endpoint's exact field-level request/response schema, see the relevant openapi/*.yaml fragment (linked from each feature's .md file) rather than this page.

Base URL

The server is deployed behind Nginx (TLS 1.2/1.3) as the front door; the Gunicorn/Uvicorn worker listens internally on 127.0.0.1:8000 (server/runserver.sh) and is not reachable directly in production. Don't hardcode a port for the externally reachable base URL — discover it from deployment config (docs/deployment/environment-config.md, mobile-app/sapot-mobile-app/config/runtime.ts).

https://<deployed-host>/ # production/staging (Nginx -> Gunicorn), no explicit port
http://<LAN-IP>:8000/ # local dev only, running the server directly without Nginx

WebSocket endpoints use wss:// in production/staging and ws:// in local development.

HTTP redirects to HTTPS with a 301 in production.


Authentication

Most endpoints require a JWT Bearer token.

Authorization: Bearer <access_token>

Tokens are obtained from POST /auth/token (OAuth2 password flow). The username field of the form body accepts the user's email address — the field name is username for OAuth2 compliance only.

Use POST /auth/refresh with a refresh token to renew the access token without re-authenticating.

Role-gated endpoints require the user to hold the rescuer or admin role. Role resolution happens server-side from the JWT subject (sub claim = user UUID).


Error Responses

Standard HTTP error

{
"detail": "Human-readable error message"
}

Some endpoints return structured detail objects:

{
"detail": {
"message": "Incorrect credentials",
"attempts_remaining": 4
}
}

Validation error (422)

{
"detail": [
{
"loc": ["body", "field_name"],
"msg": "field required",
"type": "value_error.missing"
}
]
}

Rate Limiting

Rate limiting is implemented via slowapi. When a rate limit is exceeded the server returns HTTP 429 with a Retry-After header.

Limits are keyed by client IP (slowapi.util.get_remote_address), not by user or token — so on a LAN where many clients share one NAT egress address, they also share these budgets. Counters are stored in Redis (REDIS_URL) so they're shared across Gunicorn workers; if Redis is unavailable the limiter silently falls back to per-process in-memory counters, which under multiple workers effectively multiplies each limit by the worker count. That fallback is acceptable for tests and single-worker dev, not for production (server/app/limiter.py).

{
"detail": "Rate limit exceeded: 5 per 1 minute"
}

Limits are applied per-endpoint via @limiter.limit(...) decorators, not globally. The complete set (every decorated route in server/app/api/):

EndpointLimit
POST /auth/token5/minute
POST /auth/3/minute
POST /auth/refresh10/minute
POST /api/admin/refresh10/minute
POST /auth/reauthenticate5/minute
POST /auth/change-password3/minute
POST /auth/forgot-password/otp/send3/minute
POST /auth/forgot-password/otp/verify5/minute
POST /auth/forgot-password/email-code10/minute
POST /auth/forgot-password/phone-code10/minute
POST /auth/forgot-password/email-recovery/send3/minute
GET /auth/forgot-password/email-recovery/verify5/minute
POST /auth/forgot-password/security-question/answer10/minute
POST /auth/forgot-password/recovery-with-recovery-key10/minute
POST /keys/register3/minute
POST /keys/contacts/{peer_id}30/minute
GET /keys/contacts10/minute
POST /users/wrapped-key3/minute
GET /users/wrapped-key10/minute
PUT /users/wrapped-key5/minute
POST /users/recovery-setup3/minute
GET /users/recovery-key5/minute
PUT /users/recovery-keys5/minute

Every other endpoint is unlimited by slowapi.

Login attempts are also tracked per (user, IP) with progressive lockout. A locked account returns 429 with:

{
"detail": {
"locked_until": "2026-06-28T12:00:00+00:00",
"attempts_remaining": 0
}
}

Pagination

Paginated endpoints use fastapi-pagination. Response shape:

{
"items": [],
"total": 100,
"page": 1,
"size": 20,
"pages": 5
}

Query params: page (default 1), size (default 20).

This envelope applies to standard REST list endpoints only. A few endpoints use a different shape for their own protocol reasons: sync.md uses cursor-based pagination (next_cursor/has_more), messaging-and-websocket.md uses limit/before/oldest_created_at, and mikrotik-telemetry.md returns plain limit-bounded arrays with no envelope. Check the endpoint's own doc before assuming this shape.


Timestamps

Sync-related timestamps are milliseconds since Unix epoch (integer), matching WatermelonDB's format. Other timestamps are ISO 8601 strings or Unix floats depending on the endpoint.


Trailing Slashes

A middleware normalizes trailing slashes. POST /auth/ and POST /auth are equivalent.


Static Files

Profile pictures are served at /static/profile_pictures/<filename> directly by Nginx (bypassing Python workers), with a 30-day cache header.


WebSocket Authentication

WebSocket endpoints authenticate via a token query parameter (browsers cannot set custom headers on WS upgrades):

wss://<host>/ws/?token=<access_token>

The server closes the connection with code 1008 (policy violation) if the token is invalid.