# Calling Service API Reference Base URL: `https://api.example.com` (set by `PUBLIC_URL`). All bodies are JSON. Errors use `{"error": {"code": 404, "message": "room not found"}}`. ## Authentication | Audience | Header | Used for | |----------|--------|----------| | Platform admin (you, the operator) | `X-Admin-Key: ` | Accounts, applications, pricing, invoices | | Platform owner (your customer) | `Authorization: Bearer ` | Developer portal: their apps, keys, usage | | Application backend | `Authorization: Bearer ck_live_…` / `ck_test_…` | Rooms, tokens, consultations, calls | | End-user client | `Authorization: Bearer ` or `?token=` | ICE servers, WebSocket | API keys are shown once at creation and stored hashed. `ck_test_` keys belong to **sandbox** apps: free, capped at `TEST_MONTHLY_MINUTES` per month, smaller room limits, events tagged `"mode":"test"`, never invoiced. `ck_live_` keys are billed per relayed minute. Peer tokens are HS256 JWTs scoped to one room and one peer id. Never ship API keys to browsers or mobile apps: your backend mints peer tokens. Rate limit: `RATE_LIMIT_RPS` requests/second per key (or IP), burst 2x. `429` with `Retry-After` when exceeded. --- ## Self-service accounts (developer portal `/portal/`) ### `POST /v1/signup` ```json { "email": "dev@astrorashi.com", "password": "at-least-8-chars", "company": "Astro Rashi" } ``` → `201 { "account", "session_token", "app", "api_key": "ck_test_…" }` — a sandbox app and key are created immediately. Disable with `SIGNUP_ENABLED=false`. ### `POST /v1/login` → `200 { "account", "session_token", "session_expires_at" }` (24 h sessions) ### Owner endpoints (session token) | Endpoint | Purpose | |----------|---------| | `GET /v1/account` | Account plus apps with `this_month` usage | | `GET /v1/account/apps` · `POST /v1/account/apps` `{ "name", "mode": "test"\|"live", "webhook_url" }` | Live apps need `live_enabled` on the account (admin approval) → `403` otherwise. Max 10 apps. | | `PATCH /v1/account/apps/{id}` `{ "name", "webhook_url" }` · `DELETE /v1/account/apps/{id}` | Owners cannot change limits or pricing | | `POST /v1/account/apps/{id}/rotate-key` | New key shown once | | `GET /v1/account/apps/{id}/usage?month=YYYY-MM` | `{ usage, invoice, detail }` (detail from analytics: calls, consultations, by reason) | | `GET /v1/account/apps/{id}/invoice?month=YYYY-MM` | Computed statement (below) | ### Quotas and billing Each app has `settings.monthly_minutes` (hard cap, 0 = unlimited; sandbox default 200), `included_minutes`, `price_per_minute` and `currency` (defaults from `PRICE_PER_MINUTE`, `INCLUDED_MINUTES`, `CURRENCY`). Usage is the relayed time of every room from first join to last leave, accumulated per calendar month. When the cap is reached, `POST /v1/rooms` and `POST /v1/consultations` return **402** and a `usage.quota_reached` webhook fires once. Existing calls are not cut. Invoice: `billed_minutes = ceil(used_seconds / 60)`, `billable = max(0, billed − included)`, `amount = billable × price_per_minute`. Sandbox apps always show `amount: 0` with a note. ```json { "app_id": "app_…", "month": "2026-10", "currency": "USD", "used_minutes": 1234.5, "billed_minutes": 1235, "included_minutes": 1000, "billable_minutes": 235, "price_per_minute": 0.004, "amount": 0.94 } ``` --- ## Admin (dashboard at `/admin/`) ### `POST /v1/admin/apps` ```json { "name": "My App", "mode": "live", "account_id": "acct_… (optional)", "webhook_url": "https://my.app/hooks/calls", "settings": { "max_peers_per_room": 8, "max_rooms": 1000, "token_ttl_seconds": 3600, "monthly_minutes": 0, "included_minutes": 1000, "price_per_minute": 0.004, "currency": "USD" } } ``` → `201 { "app": {...}, "api_key": "ck_live_…" }`. All `settings` fields are optional and patchable. ### Accounts and invoices | Endpoint | Purpose | |----------|---------| | `GET /v1/admin/accounts` · `GET /v1/admin/accounts/{id}` | Platform owners and their apps | | `PATCH /v1/admin/accounts/{id}` `{ "live_enabled": true, "disabled": false, "company": "…" }` | Approve live mode; disabling also disables the account's apps | | `GET /v1/admin/apps/{id}/usage?month=` | Same shape as the owner usage endpoint | | `GET /v1/admin/invoices?month=YYYY-MM` | One invoice per app plus `total_amount` | ### `GET /v1/admin/apps` · `GET /v1/admin/apps/{id}` · `PATCH /v1/admin/apps/{id}` · `DELETE /v1/admin/apps/{id}` PATCH accepts `name`, `webhook_url`, `settings` (partial), `disabled`. ### `POST /v1/admin/apps/{id}/rotate-key` → `200 { "app": {...}, "api_key": "ck_live_…" }` — the old key stops working immediately. ### `GET /v1/admin/stats` → `{ "active_rooms": 3, "connected_peers": 7, "apps": 2, "uptime_seconds": 1234 }` --- ## Application ### `GET /v1/me` — the authenticated application. ### `POST /v1/rooms` ```json { "name": "support-123", "max_peers": 2, "ttl_seconds": 3600, "metadata": { "ticket": "123" } } ``` → `201 { "room": { "id": "room_…", "app_id": "app_…", "max_peers": 2, "expires_at": "…" } }` `max_peers` defaults to the app setting and cannot exceed it. Rooms with `ttl_seconds` disappear after expiry. ### `GET /v1/rooms` · `GET /v1/rooms/{id}` (includes live `peers`) · `DELETE /v1/rooms/{id}` (disconnects peers) ### `GET /v1/rooms/{id}/peers` · `POST /v1/rooms/{id}/close` (disconnect everyone, keep the room) ### `POST /v1/tokens` ```json { "room_id": "room_…", "peer_id": "user-42", "name": "Alice", "scope": "peer", "ttl_seconds": 900 } ``` → `201 { "token": "", "expires_at": "…", "ws_url": "wss://…/v1/ws?token=…", "room_id": "…", "peer_id": "…" }` `scope`: `peer` (default, can send offers) or `subscriber` (receive-only, cannot initiate). `peer_id` must match `^[A-Za-z0-9_.:@-]{1,64}$` and be unique within a room; a second connection with the same id replaces the first. ### `GET /v1/calls?limit=50` · `GET /v1/calls/{id}` In-memory call records for the current node (`id`, `room_id`, `started_at`, `ended_at`, `peak_peers`, `peers`). For history and quality metrics use the analytics service. --- ## Client ### `GET /v1/ice?ttl=3600` Accepts an API key or a peer token. Returns an `RTCConfiguration`-compatible list with time-limited TURN credentials. ```json { "ice_servers": [ { "urls": ["stun:turn.example.com:3478", "stun:stun.l.google.com:19302"] }, { "urls": ["turn:turn.example.com:3478?transport=udp", "turn:turn.example.com:3478?transport=tcp", "turns:turn.example.com:5349?transport=tcp"], "username": "1761600000:user-42", "credential": "base64…" } ], "ttl": 3600, "expires_at": "2026-10-27T22:00:00Z" } ``` ### `GET /v1/ws?token=` — WebSocket signaling Every frame is JSON: `{ "type": "...", "from": "...", "to": "...", "payload": {...}, "error": "..." }`. | Type | Direction | Notes | |------|-----------|-------| | `welcome` | server → you | `payload: { peer_id, room_id, call_id, peers: [{id,name,scope}] }` | | `peer-joined` / `peer-left` | server → room | `from` is the peer id; `payload` has `{id,name,scope}` on join | | `offer` / `answer` / `ice` | peer → peer | `to` required; payload is the SDP or ICE candidate JSON | | `data` | peer → peer or room | app-defined payload; omit `to` to broadcast | | `stats` | peer → server | `{ rtt_ms, jitter_ms, packet_loss, bitrate_kbps }` forwarded to analytics | | `ping` / `pong` | both | keep-alive; the server also sends WebSocket pings every 54 s | | `error` | server → you | `error` string, e.g. `room is full`, `peer not found: bob` | The server never inspects SDP. Media goes peer-to-peer or through TURN; the signaling server only relays. --- ## Consultations (metered 1:1 sessions) Built for marketplaces where a user pays per minute to talk to a consultant (astrologer, doctor, tutor). The platform backend owns balances and pricing; the calling server enforces the time cap. ### Consultants | Endpoint | Purpose | |----------|---------| | `PUT /v1/consultants/{id}` | Create/update: `{ "name": "Pandit Sharma", "modes": ["audio","video","chat"], "metadata": {...} }` | | `GET /v1/consultants?status=online` · `GET /v1/consultants/{id}` · `DELETE /v1/consultants/{id}` | Directory and presence | | `POST /v1/consultants/{id}/status` | `{ "status": "online" \| "offline" \| "busy" }` (rejected while in a consultation) | | `POST /v1/consultants/{id}/token` | Presence token → `{ "token", "expires_at", "presence_url" }` | | `GET /v1/presence/ws?token=` | Presence socket: connecting sets **online**, disconnecting sets **offline** after 10 s | Presence socket messages. Server → consultant: `consultation.requested` (payload = consultation), `consultation.accepted` (payload = accept result incl. `consultant_token`), `consultation.ended|cancelled|expired|declined`. Consultant → server: `{"type":"accept","payload":{"consultation_id":"…"}}`, `decline`, `{"type":"status","payload":{"status":"offline"}}`, `ping`. ### Lifecycle ``` requested ──accept──► accepted ──both peers connect──► active ──► ended │ decline / cancel / request timeout │ cancel / connect timeout reasons: user_hangup, consultant_hangup, └──► declined / cancelled / expired └──► cancelled / ended balance_exhausted, closed_by_api ``` ### `POST /v1/consultations` ```json { "consultant_id": "astro-1", "user_id": "user-42", "user_name": "Asha", "mode": "audio", "max_seconds": 600, "warn_at_seconds": 60, "request_timeout_seconds": 45, "connect_timeout_seconds": 60, "metadata": { "order_id": "o_123" } } ``` → `201 { "consultation": { "id": "cons_…", "status": "requested", ... } }` `409` if the consultant is offline/busy, has a pending request, or does not offer the mode. `max_seconds: 0` means uncapped. The server computes `max_seconds` from nothing: **your backend** converts the user's wallet balance to seconds. ### `POST /v1/consultations/{id}/accept` → `200 { "consultation", "room_id", "user_token", "consultant_token", "ws_url", "tokens_expire_at" }` Deliver `user_token` to the user's app (via your push/socket); the consultant receives `consultant_token` on the presence socket. Both join with `CallingClient` and the room peer ids are `u:` and `c:`. `POST /v1/consultations/{id}/tokens` re-issues both tokens. ### `POST /v1/consultations/{id}/decline` · `/cancel` · `/end` · `/extend` Body (optional): `{ "by": "user-42" }`; extend takes `{ "add_seconds": 120 }` (after a top-up). `end` works in any non-terminal state and reports `closed_by_api`. ### `GET /v1/consultations?status=&consultant_id=&user_id=&limit=` · `GET /v1/consultations/{id}` ### Meter messages (WebSocket, type `meter`) Sent to both peers when the session starts, every 15 s, at the warning threshold, after an extension, and at the end: ```json { "consultation_id": "cons_…", "state": "started|active|warning|extended|ended", "elapsed_seconds": 540, "remaining_seconds": 60, "max_seconds": 600, "billed_seconds": 600, "reason": "balance_exhausted" } ``` At `max_seconds` the server disconnects both peers (`balance_exhausted`). When either side disconnects the session ends with `user_hangup` or `consultant_hangup`. ### Consultation events (webhooks + analytics) `consultation.requested`, `.accepted` (data includes both tokens and `room_id`), `.started`, `.warning`, `.extended`, `.ended` / `.declined` / `.cancelled` / `.expired` with `data: { reason, ended_by, billed_seconds, duration_ms, max_seconds, mode, consultant_id, user_id }`, and `consultant.status` with `{ consultant_id, status, source }`. `billed_seconds` is whole seconds elapsed between both peers connecting and the end, capped at `max_seconds`. Analytics: `GET /v1/consultations?app_id=…&consultant_id=&user_id=&status=`, `GET /v1/consultations/{id}`, `GET /v1/apps/{app_id}/consultants/{id}/usage?days=30` → requests, completed, declined, missed, balance_exhausted, billed_minutes, avg_seconds. --- ## Feature guides The core API above is extended by feature modules, each documented in its own page: | Feature | Guide | Highlights | |---------|-------|------------| | Chat (history, typing, read receipts) | [docs/features/chat.md](features/chat.md) | `chat`/`typing`/`read` socket messages, `GET/POST /v1/rooms/{id}/messages` | | Call recording | [docs/features/recording.md](features/recording.md) | consent-gated recorder peer, `.ogg`/`.ivf` files per participant, `GET /v1/calls/{id}/recordings` | | Scheduled consultations | [docs/features/scheduling.md](features/scheduling.md) | availability, slots, `POST /v1/bookings`, reminders, auto-start | | Phone fallback (PSTN) | [docs/features/pstn.md](features/pstn.md) | `POST /v1/consultations/{id}/phone`, Twilio or mock provider | | Group calls (SFU) | [docs/features/group-calls.md](features/group-calls.md) | `POST /v1/rooms/{id}/sfu/enable`, `sdk/js/calling-sfu.js`, `/demo/group.html` | | Webhook delivery | [docs/features/webhooks.md](features/webhooks.md) | signatures, retries, delivery log, replay, `/portal/webhooks.html` | | Security: audit log and 2FA | [docs/features/security.md](features/security.md) | `GET /v1/admin/audit`, TOTP login, recovery codes | | Alerting and status page | [docs/features/alerting.md](features/alerting.md) | probes, Slack/webhook/email alerts, `GET /v1/status`, `/status/` | | Mobile SDKs | [docs/features/mobile-sdks.md](features/mobile-sdks.md) | Kotlin and Swift clients mirroring the JS SDK | | Capacity | [docs/CAPACITY.md](CAPACITY.md) | load-test results and sizing guidance (`cmd/loadtest`) | Server-side participants (the recorder `rec:` and the SFU `sfu`) appear in peer lists and presence events but do not count against `max_peers` and never end a consultation when they leave. --- ## Webhooks If an application has `webhook_url`, events are POSTed as JSON. Core events: `call.started`, `peer.joined`, `peer.left`, `call.ended` (with `data.duration_seconds`), `stats.reported`, `usage.quota_reached`, the consultation and consultant events listed above, and the feature events (`chat.message`, `recording.*`, `booking.*`, `pstn.*`). ```json { "type": "peer.joined", "app_id": "app_…", "room_id": "room_…", "call_id": "call_…", "peer_id": "user-42", "timestamp": "2026-10-27T10:00:01Z", "data": { "name": "Alice", "scope": "peer" } } ``` Every delivery is signed (`X-Webhook-Signature: v1=` over `timestamp.body`, with `X-Webhook-Id`, `X-Webhook-Timestamp`, `X-Webhook-Event`, `X-Webhook-Attempt`), retried on failure with backoff (2 s, 10 s, 1 m, 5 m, 30 m by default), logged per app, and replayable from the portal. See [docs/features/webhooks.md](features/webhooks.md). A "call" is the lifetime of a room from its first peer joining to its last peer leaving. --- ## Analytics service (Python, port 9000) Auth: `X-Analytics-Key: ` (the Go server also signs event bodies with `X-Signature` = HMAC-SHA256). | Endpoint | Purpose | |----------|---------| | `POST /v1/events` | Ingest a lifecycle/stats event (used by the signaling server) | | `GET /v1/calls?app_id=…&limit=50&offset=0` | Call detail records | | `GET /v1/calls/{id}` | One call with participants and quality averages | | `GET /v1/apps/{app_id}/usage?days=30` | Daily calls, minutes, peak peers | | `GET /v1/apps/{app_id}/billing?month=YYYY-MM` | Monthly breakdown: calls, consultations (by end reason), daily minutes | | `GET /v1/apps/{app_id}/quality?hours=24` | Average RTT / jitter / loss and degraded-sample count | | `GET /v1/summary` | Platform totals | | `GET /healthz` | Liveness | --- ## Operational endpoints `GET /healthz`, `GET /readyz`, `GET /metrics` (Prometheus text: `calling_active_rooms`, `calling_connected_peers`, `calling_uptime_seconds`).