# Metered Realtime Messaging — llms.txt reference Bi-directional, low-latency messaging over WebSockets. One protocol covers WebRTC signalling, AI agent communication, live presence and chat, IoT telemetry, and collaborative applications. - 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 (`sk_live_…`)** — server-side. Used to mint JWTs and drive the REST control plane. **The signing secret is shown ONCE at create time.** - **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_live_…` — server-side; mints JWTs; never ship 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`. | | `going_away` | Server is about to shutdown gracefully; close-code 1001 follows with `retryAfterMs`. | --- ## 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**. --- ## Use Case Patterns ### 1. WebRTC Signalling (with Metered TURN) When the customer's signalling key has "Auto-inject TURN" enabled (default), the signalling-server fetches their Metered TURN credentials and injects them into `welcome.metadata.iceServers` automatically. Browser reads them straight into `new RTCPeerConnection({ iceServers })`. No client-side TURN fetch, no backend TURN embed. Customer-supplied iceServers in a JWT's `metadata.iceServers` always wins if present (override semantics for self-hosted TURN or per-user creds). SDP/ICE exchanged via `send` (1:1) and `publish` on a call channel (group). Requires an active TURN service on the app (any tier, including free). **No-backend variant** (static site / SPA / prototype): use a `pk_live_` publishable key. Browser connects via `wss://rms.metered.ca/v1?key=pk_live_...`; TURN credentials arrive in the welcome via the same auto-injection path. **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 delivers SDP and ICE candidates between peers. Without `Send`, `peer.join()` succeeds and presence fires but no `RTCPeerConnection` ever negotiates. 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 identity (TURN auto-injected by the server) ```js // No need to fetch TURN credentials — the signalling-server injects them // into welcome.metadata.iceServers automatically when the sk_ key has // "Auto-inject TURN" enabled (default). 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, peerMetadata: { userId, username, profilePic }, // identity stamped on presence + direct }), }); const { token, expiresAt } = await mintResp.json(); // Self-hosted TURN or per-user creds? Override by including // metadata: { iceServers: [...] } in the body above. Customer-supplied // iceServers always wins over auto-injection. ``` ### 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 to the signalling server within ~60s via the plan cache. - Key revocation (dashboard or REST) fires an `auth:invalidate` pub/sub event. Live connections using the revoked key close 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). --- ## JavaScript / TypeScript SDK — `@metered-ca/peer` Official SDK for browser + Node 18+. Wraps the wire protocol with framing, ack correlation, auto-reconnect, perfect-negotiation WebRTC, and TURN credential injection. The SDK and raw-WS clients interoperate — they speak the same protocol. ``` npm install @metered-ca/peer ``` ESM + CJS + UMD bundles. Full UMD ~13 KB gzipped (WebRTC included), within a 30 KB budget. Zero runtime dependencies. ### Two classes **`MeteredPeer`** — channel + WebRTC orchestration. Use for video calls, screen share, multi-peer chat/presence. ```ts import { MeteredPeer } from "@metered-ca/peer"; const peer = new MeteredPeer({ // exactly one of: apiKey: "pk_live_…", // pk_ keys must have `Send` ticked in the dashboard for WebRTC; // off by default. Without it peer.join succeeds but no // RTCPeerConnection ever negotiates (SDP/ICE travels over `send`). tokenProvider: async () => fetchJwt(), }); peer.on("peer-joined", ({ peer: remote }) => { remote.on("track", ({ streams }) => attachToVideo(streams[0])); }); peer.on("peer-left", ({ peer: remote }) => removeVideo(remote.id)); peer.on("data", ({ senderPeerId, data, kind }) => handle(senderPeerId, data, kind)); const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); peer.addStream(localStream); await peer.join("room-42"); await peer.send({ chat: "hi everyone" }); // broadcast to channel await peer.sendTo(otherPeerId, { hi: "you" }); // directed to one peer ``` Public methods: `join(channel, opts?)`, `close(reason?)`, `send(data)` (broadcast), `sendTo(peerId, data)` (directed), `addStream(stream, metadata?)`, `addTrack(track, stream?, metadata?)`, `removeStream(stream)`, `removeTrack(track)`, `replaceTrack(oldTrack, newTrack)`, `getStreamMetadata(stream)`, `getTrackMetadata(track)`. `close()` is terminal — to reconnect after closing, construct a new `MeteredPeer`. ### Multi-stream + per-track metadata `addStream` and `addTrack` accept an optional `StreamMetadata` bag (`{ role?, label?, [k: string]: unknown }`) that arrives at the receiver on the `stream-added` and `track` events. Convention: `role: "camera" | "screen" | "canvas" | "file" | custom`, `label: string`. Use it to label streams so receivers can lay out simultaneous camera + screen share correctly. ```ts // Sender peer.addStream(cam, { role: "camera", label: "front cam" }); peer.addStream(screen, { role: "screen", label: "shared window" }); // Receiver peer.on("peer-joined", ({ peer: remote }) => { remote.on("stream-added", ({ stream, metadata }) => { if (metadata?.role === "camera") attachToFaceTile(stream); if (metadata?.role === "screen") attachToScreenTile(stream); }); remote.on("stream-removed", ({ stream }) => removeTile(stream.id)); }); ``` Wire transport: metadata rides over the signalling WS as a `direct` message under reserved key `__meteredTrackMeta`; the SDK intercepts and stashes it before firing the event. Newcomers + reconcile survivors get the full metadata set re-sent before track re-attachment. `stream-added` / `stream-removed` are stream-level lifecycle events (preferred over `track` for tile-per-stream UIs). On reconcile, `stream-added` fires fresh with the same `stream.id` but a new `MediaStream` object — customer must re-bind `