# Metered Realtime Messaging — Raw WebSocket reference For developers implementing a Realtime Messaging client from any stack (Go, Python, Java, Swift, Kotlin, Rust, Unity/C#, constrained IoT runtimes, etc.) — this file documents the JSON-over-WebSocket wire protocol byte-for-byte. **If you're on JavaScript or TypeScript, use the [`@metered-ca/realtime` SDK](https://www.metered.ca/docs/llms-realtime-messaging-sdk.txt) instead** — it wraps this protocol with framing, ack correlation, auto-reconnect, perfect-negotiation WebRTC, and TURN injection. The SDK and raw clients interoperate; the SDK is just an ergonomic JS layer over the same wire. - WSS endpoint: `wss://rms.metered.ca/v1` - REST control plane: `https://rms.metered.ca/v1` - Dashboard: https://dashboard.metered.ca | Pricing: https://www.metered.ca/pricing ## Getting Started 1. **Sign up** at https://dashboard.metered.ca/signup 2. Go to **Dashboard → Realtime Messaging** and complete the one-question use-case survey to enable the product for your app. 3. Go to **Dashboard → Realtime Messaging → Keys** and create a key: - **Secret key** — a pair: public key id `sk_id_…` (the JWT `kid`) + signing secret `sk_secret_…` (server-side only, shown ONCE at create). Mints JWTs; the REST control plane authenticates with both, colon-joined: `Authorization: Bearer sk_id_…:sk_secret_…`. - **Publishable (`pk_live_…`)** — browser-safe. The keyId IS the credential. 4. Configure the key's `channelPatterns` (e.g. `app_/*`) and `actions` (`publish` / `subscribe` / `presence` / `send`) on the dashboard. **Two-key model:** - `sk_id_…` + `sk_secret_…` — the secret key pair; server-side only; mints JWTs and drives the REST control plane; never ship the secret to clients - `pk_live_…` — browser-safe; carries fixed scope from its dashboard config --- ## Wire Protocol Overview Every message is a single JSON object with a `type` field. WS endpoint `/v1`, auth via `?token=` or `?key=`. ### Connect ``` wss://rms.metered.ca/v1?token= wss://rms.metered.ca/v1?key=pk_live_xxxxxxxx ``` ### Client → server message types | Type | Purpose | |---|---| | `subscribe` | Join a channel. Optional `includeSenderMetadata: true` to stamp every message with sender's peerMetadata. | | `unsubscribe` | Leave a channel. | | `publish` | Broadcast to all other subscribers of a channel. | | `send` | Direct message to a specific `peerId` (no channel). | All carry optional `requestId` for ack/error correlation. ### Server → client message types | Type | Purpose | |---|---| | `welcome` | First message after auth. Carries `peerId`, `expiresAt`, `serverTime`, `maxMessageSize`, optional `metadata` (e.g., iceServers). | | `ack` | Successful response to a client request. | | `error` | Failed request (connection stays open). | | `message` | A channel publish you're subscribed to. Carries `from`, optional `fromMetadata`, `data`. | | `direct` | A `send` directly to you. Carries `fromMetadata` if the sender's JWT included `peerMetadata`; omitted otherwise. | | `presence` | Peer joined/left a channel you're subscribed to. Includes each peer's `peerMetadata`. The first one after each subscribe is the full roster snapshot — ALWAYS sent, `joined: []` when the channel is empty (treat it as the authoritative roster; re-subscribes get a fresh one). Later ones are deltas. | | `going_away` | Server is about to shutdown gracefully; close-code 1001 follows with `retryAfterMs`. | ### Example frames ```jsonc // Client → server { "type": "subscribe", "channel": "room-abc", "id": "1" } { "type": "unsubscribe", "channel": "room-abc", "id": "2" } { "type": "publish", "channel": "room-abc", "data": {...}, "id": "3" } { "type": "send", "to": "peer-xyz", "data": {...}, "id": "4" } // Server → client (responses) { "type": "ack", "id": "1", "ok": true } { "type": "error", "id": "3", "code": "channel_not_authorized" } // Server → client (push) { "type": "welcome", "peerId": "peer-abc", "expiresAt": 1730000000, "serverTime": 1729996400, "maxMessageSize": 65536 } { "type": "message", "channel": "room-abc", "from": "peer-abc", "data": {...} } { "type": "direct", "from": "peer-xyz", "data": {...} } { "type": "presence", "channel": "room-abc", "joined": [...], "left": [...] } ``` --- ## JWT Auth (sk_-minted) HS256, signed with the sk_ key's signing secret. Header `kid` = the sk_'s `keyId`. ```json { "iss": "your-backend", "sub": "peerId-here", "exp": 1715539200, "iat": 1715535600, "channels": ["app_abc/room-1"], "permissions": ["publish", "subscribe", "presence", "send"], "metadata": { // connection-private (welcome only) "iceServers": [ { "urls": ["stun:stun.relay.metered.ca:80"] }, { "urls": ["turn:global.relay.metered.ca:80"], "username": "u", "credential": "c" } ] }, "peerMetadata": { // identity bag, visible to other peers "userId": "u_alice_123", "username": "Alice Anderson", "profilePic": "https://..." } } ``` - `channels` MUST be a subset of the sk_'s `channelPatterns`. - `permissions` MUST be a subset of the sk_'s `actions`. Defaults to full set. - `exp` ≤ 24h. **Self-mint** with the signing secret OR call `POST /v1/tokens` with the sk_ as Bearer to have Metered mint. --- ## `metadata` vs `peerMetadata` (load-bearing distinction) | Claim | Delivered | Visibility | Use for | |---|---|---|---| | `metadata` | Once, in `welcome.metadata` | Only the connecting peer | TURN iceServers, feature flags, anything user-private | | `peerMetadata` | On `presence` events (always), `direct.fromMetadata` (always), `message.fromMetadata` (opt-in via subscribe) | Other peers in same channel / direct | `userId`, `username`, `profilePic`, role tags | **Per-message channel stamping is OPT-IN** via `subscribe { includeSenderMetadata: true }`. Reason: high-frequency channels (cursors, game ticks) want bytes-tight messages and resolve identity from cached `presence.joined` events instead. --- ## REST Control Plane (sk_ Bearer) Base: `https://rms.metered.ca/v1` | Method | Path | Purpose | |---|---|---| | `POST` | `/v1/tokens` | Mint a JWT (alternative to self-mint). Body: `peerId` (req, ≤128 chars), `channels`, `permissions`, `expiresInSec`, `metadata` (≤8 KB), `peerMetadata` (≤4 KB — identity bag stamped on presence + direct + opt-in channel messages). | | `GET` | `/v1/channels/:id/peers` | List subscribed peers. Needs `presence` action. Response `peers` is `PresencePeer[]` — array of `{ peerId, metadata? }`, same shape as presence.joined entries. | | `POST` | `/v1/channels/:id/publish` | Server-side publish. Needs `publish` action. Body: `data` (req, ≤32 KB), `from` (≤128 chars, default `"server"`), optional `peerMetadata` (≤4 KB, stamped onto opt-in subscribers' `fromMetadata`). | | `DELETE` | `/v1/peers/:id` | Forcibly close every WS for a peer. Closes with WS code 4020 AdminDisconnect. | | `GET` | `/v1/usage` | Composite: `concurrentNow`, `peakConcurrent`, `messagesUsed`, `overage*`, plan limits. | All endpoints return `{ error, message? }` shape on failure. Same `unauthorized` for unknown/revoked/wrong-type sk_ (timing-safe). 503 = infra failure, retry. --- ## WebSocket Close Codes | Code | Meaning | |---|---| | 1001 | Going Away — graceful shutdown; follows a `going_away` JSON message with `retryAfterMs`. | | 1008 | Policy violation. | | 1009 | Message too big (frame > 64 KB). | | 4001 | Invalid token (signature, malformed, key not found). | | 4002 | Token expired. | | 4003 | Channel not authorized. | | 4010 | Over concurrent limit (free tier, or paid + balance exhausted, overages off). | | 4011 | Over per-connection message rate (token bucket — 100 msg/sec sustained, 200 burst default). | | 4012 | Account suspended. | | 4020 | Administrative disconnect via `DELETE /v1/peers/:id`. | Pre-handshake HTTP: 401 (auth fail), 404 (wrong path), 429 (per-IP rate limit), 503 (infra fail). --- ## Error Codes (soft drops, connection stays open) `error` messages carry `code`: - `malformed_message`, `unknown_type` — fix the client - `invalid_channel`, `invalid_peer_id` — fix the input - `channel_not_authorized` — channel outside key's patterns - `channel_reserved` — reserved prefix (`_metered/`, `_internal/`, `_system/`) - `channel_limit_exceeded` — 100 channels per connection - `peer_not_found` — `send` target not online - `missing_data` — `publish` / `send` missing `data` - `action_not_permitted` — operation not in key's actions - `over_message_quota` — plan exhausted; period rollover or re-enable overages --- ## Rate Limits & Quotas Three independent layers: | Layer | Scope | Default | |---|---|---| | Plan quota | Per-customer per-period | `maxConcurrentConnections`, `maxMessagesPerPeriod` | | Token bucket | Per-connection | 100 msg/sec sustained, 200 burst | | Per-IP connect | Per-source-IP | 60 attempts / 60s | Hard ceilings: max frame 64 KB, max channels per connection 100, max peerId 128 chars, max channel name 256 chars, max `requestId` 128 bytes, max `peerMetadata` serialized 4 KB, max `metadata` serialized 8 KB. Plan over-quota on messages: **soft drop** with `error: over_message_quota` (connection stays open). Plan over on concurrent: **close 4010**. Token-bucket abuse: **close 4011**. --- ## Channel naming - Channel names are strings up to 256 characters. - Wildcard patterns in `channelPatterns` / JWT `channels`: - `*` matches one path segment, e.g. `room-*` matches `room-42` but NOT `room/42/main` - `**` matches any number of segments, e.g. `app_xyz/**` matches everything under `app_xyz/` - Reserved prefixes (server-rejected): `_metered/`, `_internal/`, `_system/` --- ## Use Case Patterns ### 1. WebRTC Signalling (with Metered TURN) Backend mints TURN credentials via Metered TURN REST API, embeds them in JWT's `metadata.iceServers`, browser reads from `welcome.metadata.iceServers` straight into `new RTCPeerConnection({ iceServers })`. SDP/ICE exchanged via `send` (1:1) and `publish` on a call channel (group). No separate TURN-credential request from the browser. **No-backend variant** (static site / SPA / prototype): use a `pk_live_` publishable key instead of minting a JWT. Browser connects via `wss://rms.metered.ca/v1?key=pk_live_...`; TURN credentials arrive auto-injected in the welcome's `metadata.iceServers` when the pk_ key has "Auto-inject TURN" enabled (default; requires an active TURN service on the app). No client-side TURN fetch needed. **Critical: when creating the pk_ key in the dashboard, tick `Send` under "What can this key do?"** — `Send` is off by default for publishable keys but WebRTC signalling requires it because the wire protocol's `send` operation is what carries SDP and ICE between peers. Server assigns a random peerId at connect time; embed user identity in `data` payloads (`{ kind: "offer", fromName: "Alice", sdp: ... }`) since `peerMetadata` requires a JWT. Documented at /docs/realtime-messaging/quickstart-webrtc and /docs/realtime-messaging/sdk-javascript/guides/webrtc-no-backend. ### 2. AI Agent Communication Agents subscribe to workflow channels (`workflows/run-xyz/subtasks`); planner publishes subtasks; researchers/writers/reviewers reply via `send` (direct). Stream long tool calls back to user as a sequence of `direct` messages. Use `peerMetadata` to carry `agentName` / `model` / `capabilities`. Workflow orchestrator backend can publish via REST without maintaining its own WS. ### 3. Live Presence & Chat One channel per room. Subscribe with `includeSenderMetadata: true` so every chat bubble renders standalone (no per-message DB lookup needed). Roster comes from `presence.joined` events. Moderation = `DELETE /v1/peers/:id` for kicks, `POST /v1/channels/:id/publish` for system broadcasts. ### 4. IoT Telemetry & Device Control Per-device JWT with narrow `channelPatterns`. Device publishes telemetry on `devices/${id}/events`, subscribes to `devices/${id}/commands` + `fleets/${fleetId}/commands` for commands. Backend issues commands via REST publish — no WS needed in the control plane. ### 5. Collaborative Apps (cursors / dashboards / games) High-frequency events. Do NOT use `includeSenderMetadata` — cache identity from `presence.joined` instead; cursor messages stay tiny. Throttle client-side to 30 Hz to stay under token bucket. Channels: per-resource (`doc-${id}`). Catch-up state lives in your backend, not in Realtime Messaging (no message replay/history). --- ## Per-Message Stamping Cheat Sheet | Channel pattern | `includeSenderMetadata` | |---|---| | Chat / reactions / emoji | `true` | | Cursor sync, game ticks, frequent telemetry | `false` (cache from presence) | | WebRTC SDP/ICE | N/A — use `send` (direct messages always stamp) | --- ## Common Code Patterns ### Mint JWT with TURN + identity, all in one (Node.js) ```js // 1. Get TURN credentials from Metered TURN REST API const turnResp = await fetch( `https://${APP_NAME}.metered.live/api/v1/turn/credentials?apiKey=${TURN_KEY}` ); const iceServers = await turnResp.json(); // 2. Mint realtime-messaging JWT with iceServers const mintResp = await fetch("https://rms.metered.ca/v1/tokens", { method: "POST", headers: { Authorization: `Bearer ${REALTIME_SK}`, "Content-Type": "application/json" }, body: JSON.stringify({ peerId: userId, channels: [`call-${callId}`], permissions: ["publish", "subscribe", "presence", "send"], expiresInSec: 3600, metadata: { iceServers }, // TURN config (welcome-only) peerMetadata: { userId, username, profilePic }, // identity stamped on presence + direct }), }); const { token, expiresAt } = await mintResp.json(); ``` ### Client (browser) reads TURN from welcome ```js const ws = new WebSocket(`wss://rms.metered.ca/v1?token=${token}`); ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); if (msg.type === "welcome") { const pc = new RTCPeerConnection({ iceServers: msg.metadata?.iceServers ?? [] }); // ... WebRTC setup } }; ``` ### Chat — stamp every message with author identity ```js ws.send(JSON.stringify({ type: "subscribe", channel: `room-${roomId}`, includeSenderMetadata: true, requestId: "sub-1", })); // Every incoming `message` carries `fromMetadata` for rendering ``` ### Cursor — cache identity from presence, NOT stamp per message ```js ws.send(JSON.stringify({ type: "subscribe", channel: `doc-${docId}`, // includeSenderMetadata omitted (defaults false) })); // Update roster from `presence` events; render cursors from cached metadata ``` ### Server-side publish (chat moderation, IoT command, agent fan-out) ```js await fetch(`https://rms.metered.ca/v1/channels/${encodeURIComponent(channel)}/publish`, { method: "POST", headers: { Authorization: `Bearer ${SK}`, "Content-Type": "application/json" }, body: JSON.stringify({ data: { ... }, from: "system" }), }); ``` ### Force-disconnect a peer ```js await fetch(`https://rms.metered.ca/v1/peers/${encodeURIComponent(peerId)}`, { method: "DELETE", headers: { Authorization: `Bearer ${SK}` }, }); // Their WS closes with code 4020 AdminDisconnect ``` --- ## Operational Notes - Plan changes (overage toggle, plan upgrade) propagate within ~60s via the plan cache. - Key revocation (dashboard or REST) closes live connections using the revoked key within seconds. - Key rotation preserves the `kid` — old JWTs continue verifying for 24h via the previous-secret grace window. - Usage counters lag by ≤60s (one server-flush interval). --- ## Implementing reconnect correctly (since you don't have the SDK) Real-world clients drop. The SDK handles this; raw-WS implementers own it. Patterns: - **Exponential backoff + jitter** — start at ~500 ms, double per attempt, cap at ~30 s, ±20% jitter. - **Close-code-aware** — codes 4001 / 4003 / 4012 / 4020 are terminal (don't retry, surface to user). Code 4010 should slow-backoff (≥30 s floor) since it's a plan-cap signal. - **Re-subscribe on reconnect** — the server doesn't remember your subscriptions across drops. After a fresh `welcome`, re-send `subscribe` for every channel you were on. - **Token refresh on reconnect** — JWT may have expired during the disconnect window. Mint or fetch a fresh JWT before reconnecting. - **Inactivity watchdog** — if no frame arrives in ~60 s, close-and-reconnect. The server sends pings; if your client can't detect missing pings, time out at the application layer. --- ## Links - [Full documentation](https://www.metered.ca/docs/realtime-messaging/) - [JavaScript SDK docs](https://www.metered.ca/docs/realtime-messaging/sdk-javascript/) — `@metered-ca/realtime` (for JS/TS) - [SDK-only llms reference](https://www.metered.ca/docs/llms-realtime-messaging-sdk.txt) - [Unified llms reference (both layers)](https://www.metered.ca/docs/llms-realtime-messaging.txt) - [Dashboard](https://dashboard.metered.ca) - [Pricing](https://www.metered.ca/pricing) - [Metered TURN Server](https://www.metered.ca/docs/llms-turn-server.txt) — the natural pairing for WebRTC signalling - [Metered Global Cloud SFU](https://www.metered.ca/docs/llms-sfu.txt) — for media plane on group calls > 6 peers