Federation protocol v1 (spec)
Status: normative, version 1 as implemented by Open Lounge Phone 0.1 and later (0.2 adds
version and feature advertisement, §9, within version 1). The readable overview,
with the reasons behind the design, is federation.md; where the two differ,
this document wins. The endpoint reference (§7) and the stream frames (§6.3) are generated from
the zod schemas in packages/federation (npm run docs:federation; CI fails when they drift).
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be read as in RFC 2119 and RFC 8174 when they appear in capitals.
Contents
Section titled “Contents”- Scope and model
- Discovery
- Server keys and pinning
- Request signatures (RFC 9421 profile)
- Processing a request: order, errors and rate limits
- The server-pair stream
- Endpoints
- Semantics
- Versioning and compatibility
- Security considerations
- Implementation notes
1. Scope and model
Section titled “1. Scope and model”- A server is identified by its host: the DNS name, with
:portwhen it isn’t the default, of the URL people reach it at (itsPUBLIC_URL). Hosts are lower case and match^[a-z0-9](https://github.com/Open-Lounge-Phone/open-lounge-phone/blob/main/docs/[a-z0-9-]*[a-z0-9])?(\.[a-z0-9](https://github.com/Open-Lounge-Phone/open-lounge-phone/blob/main/docs/[a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$. - An account is one person on one server. Its address is
handle@host, wherehandlematches^[a-z0-9._-]{2,30}$and may change (at most once a day; a released handle is reserved for 90 days). Every account also has a stable id ([A-Za-z0-9_-]{1,64}) that never changes. A party (Party, §7) is{handle, id, name}of one account. - A connection is a pair of accounts that both agreed to talk (a knock, then an accept).
Each server keeps its own record of it and MUST identify the remote side by
(host, id), not by handle. - Household phones (e.g. a kids’ phone) have no address. A guardian may list a connection
on such a phone’s allow-list; the phone’s server then shares it with
/fed/v1/phones. - Your server is your authority. A server MUST decide everything that concerns its own
accounts and phones (connections, allow-lists, quiet hours, availability, busy, blocks,
recording policy) by itself, and MUST NOT act on another server’s claim about them. A
sending server speaks only for its own accounts: every
fromin a request is an account of the server whose key signed it. - There is no directory, no search and no phone network bridge. Nothing in this protocol lets a server list another server’s accounts.
2. Discovery
Section titled “2. Discovery”Every federating server MUST serve GET https://<host>/.well-known/openloungephone (plain
http:// only for localhost and *.localhost, which are loopback by definition and are
used for development and interop tests):
{ "version": 1, "server_key": "<Ed25519 public key, base64url, 43 chars>", "federation": "/fed/v1", "software": "openloungephone/0.2.0", "versions": { "1": "/fed/v1" }, "features": ["rooms", "recording-flag", "lounge-guests", "key-rotation", "voicemail-greeting", "presence-batch", "transfer"] }-
versionis the highest federation version the server speaks;federationis the base path of version 1.versionsmaps every version it speaks to its base path, andfeatureslists the optional capabilities it supports; both are negotiated as in §9. A document without them is from an 0.1 server (§9.2). -
softwareis informational (implementation and version); receivers MUST NOT change behaviour because of it — that is whatversionsandfeaturesare for. -
server_keyis the raw 32-byte Ed25519 public key, base64url without padding. -
rotation(and the 0.1 pairprevious_key/rotation_sig) is present only during a key rotation’s overlap window (§3.2):{ "version": 1, "server_key": "<new key>", "federation": "/fed/v1","software": "openloungephone/0.2.0", "versions": { "1": "/fed/v1" }, "features": ["…"],"previous_key": "<old key>", "rotation_sig": "<0.1 hand-over, see §3.2>","rotation": { "previous_key": "<old key>", "created": 1790000000,"expires": 1790604800, "sig": "<base64url Ed25519 signature>" } } -
A server that does not federate answers
404. The document MAY be cached for up to 300 s (Cache-Control: public, max-age=300); §9.1 says when peers fetch it again. -
Receivers MUST accept a document whose optional fields they can’t read (too long, wrong type, unknown shape) by ignoring those fields: only
version,server_keyandfederationare required for the key to be usable.
3. Server keys and pinning
Section titled “3. Server keys and pinning”3.1 Trust on first use
Section titled “3.1 Trust on first use”A receiver learns a sender’s key from the sender’s .well-known, over HTTPS, the first time it
needs it (the keyid of a signed request, or the from of a stream hello), and MUST pin
it: store (host, key) and use the stored key from then on. Implementations SHOULD record when
the key was first and last seen. This is TLS plus trust-on-first-use, as SSH treats hosts.
When a signature does not verify with the pinned key, the receiver MUST fetch .well-known
again, once, and then:
- same key as pinned → the signature is bad (
401 signature: bad_signature); - a different key with a valid rotation statement (§3.2) → replace the pin with the new key and verify with it;
- any other different key → MUST NOT pin it; SHOULD record it for the operator (“SERVER KEY
CHANGED”); the request fails with
401. Only the operator can then replace the pin (§3.4).
A receiver MUST NOT fetch keys for hosts it would not contact (§10.4).
3.2 Rotation
Section titled “3.2 Rotation”A server rotates its key by generating a new one and, from that moment, signing everything
(requests and stream frames) with the new key only. For an overlap window it publishes
both keys in .well-known: the new one as server_key, and a rotation statement that hands
the old key over to the new one:
Field of rotation |
Meaning |
|---|---|
previous_key |
The key being replaced: the one peers have pinned (base64url, 43 chars). |
created |
Unix seconds when the rotation was made. |
expires |
Unix seconds when the overlap ends; after it the statement is no longer published. |
sig |
The old key’s Ed25519 signature (base64url) over the statement below. |
The statement is the UTF-8 text of these six lines joined by \n (no trailing newline):
openloungephone-key-rotation-v2<host><previous_key><server_key><created><expires>where <host> is the rotating server’s host (as in its addresses and keyids) and the
numbers are decimal. Binding the host, both keys and the window means a statement can’t be
reused by another server, stretched, or replayed after its window.
Overlap window. Publishers SHOULD use 7 days (the reference implementation’s fixed
value) and MUST keep expires − created between 1 day and 90 days. A server SHOULD NOT start
another rotation while one is in its window (peers still pinning the first key couldn’t follow
two hand-overs; the reference server refuses unless its operator forces it). After expires
the server stops publishing rotation, previous_key and rotation_sig, and nothing is
signed with the old key again, so the old private key SHOULD be destroyed at rotation time.
Verification. When a signature (or stream hello) doesn’t verify with the pinned key and
the re-fetched .well-known has a different server_key (§3.1), the receiver accepts the new
key only if all of these hold, with now its own clock in Unix seconds:
rotationis present androtation.previous_keyequals the pinned key (and differs fromserver_key);created ≤ now + 300,created < expires, andexpires − created ≤ 7776000(90 days);now < expires;sigverifies with the pinned key over the statement built from the receiver’s own view ofhost(thekeyidor streamfromit is resolving),previous_key,server_key,createdandexpires.
It then replaces the pin with server_key (keeping when it first saw the host, and recording
when this key was pinned), clears any refused change, and verifies the request with the new key.
Receivers SHOULD record the change for the operator (the reference server writes it to its
audit trail). If any check fails, the key change is refused exactly as in §3.1 step 3 and the
reason is recorded.
A peer that did not contact the rotating server during the whole window, or that pinned a key two rotations old, cannot follow; its operator decides (§3.4).
The 0.1 form. Open Lounge Phone 0.1 defined only previous_key plus rotation_sig (the
old key’s signature over openloungephone-key-rotation:<server_key>), with no time. No 0.1
server ever published it (0.1 had no way to rotate). A rotating server SHOULD publish it
alongside rotation during the window so 0.1 receivers can follow; a receiver implementing
this revision MUST NOT accept a key change from the 0.1 pair alone, because a statement without
a window could be replayed forever. Both forms are optional fields of .well-known, so this is
an additive change within version 1 (§9).
3.3 Private keys
Section titled “3.3 Private keys”The private key MUST be kept secret and SHOULD be generated on the server. The reference implementation stores it as a JWK:
- Cloudflare: the
FED_PRIVATE_KEYsecret, generated once byscripts/deploy.tsand never replaced. After a rotation the current key lives in D1 (fed_own_key), sealed with AES-256-GCM under a key derived (HKDF-SHA-256) fromFED_PRIVATE_KEY, with the public key as associated data; the secret thereby becomes the root that protects it. The Worker can rotate without a redeploy and without holding a Cloudflare API token. - Self-hosted:
DATA_DIR/federation-key.jwk(mode 600). A rotation replaces it atomically and writesDATA_DIR/federation-key.previous.json(mode 600) with the old key’s public half and the signed statement, removed after the window.
Losing the current key breaks every connection with servers that pinned it (their operators
must re-trust a new one). Keys are never printed or logged; operators compare fingerprints:
SHA256: followed by the base64url SHA-256 of the raw 32-byte public key.
3.4 Operator decisions
Section titled “3.4 Operator decisions”A refused key change stays pending for the receiving server’s operator, who sees the host, the
pinned key’s fingerprint, the new key’s fingerprint and when it was presented. After checking
with the other server’s operator out of band, they MAY re-trust it: the pin is replaced by
the presented key. A re-trust MUST apply only to the exact change the operator was shown (the
reference server requires both fingerprints and refuses with 409 if either changed), and
SHOULD be recorded with who did it. Alternatively the operator blocks the server (§5).
4. Request signatures (RFC 9421 profile)
Section titled “4. Request signatures (RFC 9421 profile)”Every request to a signed endpoint (§7) MUST carry an HTTP Message Signature (RFC 9421) in exactly this profile:
Signature-Input: sig1=("@method" "@target-uri" "content-digest");created=1790000000;keyid="a.example";alg="ed25519";nonce="Zm9vYmFyYmF6cXV4MTIzNA"Signature: sig1=:<base64 Ed25519 signature>:Content-Digest: sha-256=:<base64 SHA-256 of the body>:-
Label: exactly one signature, labelled
sig1. -
Covered components:
"@method"and"@target-uri"always, in that order, then"content-digest"when the request has a body. Receivers MUST reject a signature that does not cover these (401 signature: components). -
Parameters:
created(integer Unix seconds),keyid(the sending server’s host, exactly as in its addresses),alg="ed25519", andnonce(16–64 characters; senders SHOULD use 16 random bytes, base64url). No other parameters are used;expiresMUST NOT be relied on. Parameters appear once each; string values contain no"or\. -
@target-uri: the absolute URL the sender requests,https://<receiver host><path>[?query]. The receiver MUST reconstruct it from its own public origin (itsPUBLIC_URL) plus the request’s path and query, never from aHostor forwarding header, so a signature for one server, path or version can’t be replayed against another. -
Content-Digest: RFC 9530sha-256over the exact body bytes, for every request with a body (JSON or audio). -
Signature base (RFC 9421 §2.5), lines joined with
\n, no trailing newline (illustrative values):"@method": POST"@target-uri": https://b.example/fed/v1/knock"content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:"@signature-params": ("@method" "@target-uri" "content-digest");created=1790000000;keyid="a.example";alg="ed25519";nonce="Zm9vYmFyYmF6cXV4MTIzNA"@methodis upper case. The signature is Ed25519 over the UTF-8 bytes of the base, sent as standard base64 (with padding) between colons. -
Freshness: receivers MUST reject
createdmore than 300 s away from their clock (401 signature: expired). Servers SHOULD keep their clocks in sync (NTP). -
Nonces: after the signature verifies — and only then, so forged requests can’t burn real nonces — the receiver MUST record
(keyid, nonce)for at least the whole acceptance window plus a margin (the reference keeps them 11 minutes) and MUST reject a nonce it has seen (401 signature: replay). A sender MUST use a fresh nonce andcreatedfor every request, including retries.
The stream upgrade (GET /fed/v1/stream) is not signed this way; it authenticates in-band
(§6.1). The voicemail ticket endpoints (§8.4) use tickets, not signatures.
5. Processing a request: order, errors and rate limits
Section titled “5. Processing a request: order, errors and rate limits”A receiver processes a signed request in this order and answers the first failure:
| Step | Failure | Status | Body error |
|---|---|---|---|
| Federation enabled | this server doesn’t federate | 404 |
this server doesn't federate |
Body size (Content-Length, then actual): 16 KiB for JSON, 2 MiB for voicemail audio |
too large | 413 |
too large |
Signature headers present and well-formed; alg; covered components; created window; digest; key (§3); signature; nonce |
see reason | 401 |
signature: <reason> with reason missing, malformed, alg, components, expired, digest, unknown_key, bad_signature or replay |
| The operator’s server blocklist | sender blocked | 403 |
blocked |
| The sender’s request budget (default 300 requests a minute per sending server, over all signed endpoints) | over budget | 429 + Retry-After: 60 |
slow down |
Endpoint budget: /knock (default 500 knocks a day per sending server) |
over budget | 429 + Retry-After: 3600 |
slow down |
| JSON parse | invalid | 400 |
invalid JSON |
| Schema (§7) | invalid | 400 |
invalid body (voicemail: invalid sender) |
Every error body is {"error": "<short reason>"}; clients MUST NOT parse the text. Endpoint
outcomes that are the receiver’s business answer 202 whatever happened (§8.1); decisions
the caller needs answer 200 with a result object (calls, room joins, Lounge claims).
Senders:
- MUST treat
429as “try later”: honourRetry-After, and for a call answer the caller withbusyrather than retrying while it rings. - MUST NOT retry other
4xxunchanged. MAY retry network errors and5xxwith exponential backoff, re-signing each attempt (freshcreatedandnonce). - MUST treat a peer’s
404on an endpoint as “not supported” (a feature or endpoint the peer doesn’t have), not as a failure of the whole connection, and SHOULD fetch its.well-knownagain before the next request (§9.1). - MUST NOT send a request that uses an optional feature the peer doesn’t advertise (§9.2).
Receivers answer requests for a path they don’t have under /fed/ with 404 and
{"error": "not supported"}, and requests for a federation version they don’t speak (e.g.
/fed/v2/…) with 404 and an error that names the versions they do speak.
Budgets are fair defaults, the same for every sending server (the public hub gets no special treatment); operators MAY change them. Per-account limits also apply and are silent where revealing them would leak information (§8.1).
6. The server-pair stream
Section titled “6. The server-pair stream”Call and room signaling between two servers travels over one WebSocket per server pair.
6.1 Opening and authentication
Section titled “6.1 Opening and authentication”-
It is opened on demand by whichever server first needs to send a signal: the dialer connects to
wss://<peer host>/fed/v1/stream?from=<own host>(ws://for loopback hosts). -
The dialer’s first frame MUST be
hello:{t: "hello", from: <dialer host>, to: <acceptor host>, created, nonce, sig}, wheresigis the dialer’s Ed25519 signature (base64url) over the UTF-8 statementolp-stream-v1\n<from>\n<to>\n<created>\n<nonce> -
The acceptor MUST check that
fromequals thefromquery parameter,tois its own host,createdis within ±300 s, the signature verifies with the pinned key offrom(§3; one re-fetch for rotation), and the nonce is unused (remembered 11 minutes). It then answershello.okwithfrom/toswapped, its owncreated, the dialer’s nonce, and its own signature over the same statement form. The dialer MUST checkhello.okthe same way, including that the nonce echoes its own. -
Any failure closes the socket with code 4401 and a short reason. Before authentication the only acceptable frame is
hello(acceptor) orhello.ok(dialer). A reference server refuses an upgrade whosefromis not a host with400(Workers) or close code4400. -
Signals the dialer wants to send wait until
hello.ok; then they go out in order.
6.2 Messages and routing
Section titled “6.2 Messages and routing”- After authentication, every frame is
{t: "signal", msg}(§6.3). Frames MUST be at most 16 KiB. Both sides MAY send on any authenticated socket between them (if both dialed at the same time there may briefly be two). msg.callIdnames a call (or a room leg) as the receiver knows it. A server registers the ids it expects (before sending/calls,/rooms/joinor a transfer notice, and when it accepts one) and routes each inbound signal to the call it belongs to. Signals for unknown ids MUST be dropped silently.- A route ends with
call.state ended— unless it carriesmerged(the call continues as a room leg under the same id) ortransfer(the other side may still answer that it won’t follow) — and withroom.signalcarryingroom.endedorroom.leave. - Malformed signal frames are ignored (logged); they don’t close the stream.
- A frame of a type the receiver doesn’t know, or a
signalwhosemsg.t(or a room signal’s innermsg.t) it doesn’t know, is answered with{t: "unsupported", type, callId?}on the same socket and otherwise ignored; the stream stays open.unsupportedis never answered, so two servers can’t loop. Unknown fields inside known frames are dropped (§9.3).
6.3 Frames
Section titled “6.3 Frames”Handshake frames (hello from the dialer, hello.ok from the acceptor)
| Field | Type | Required | Notes |
|---|---|---|---|
t |
"hello" | "hello.ok" |
yes | |
from |
string (len ≥1, len ≤260) | yes | The sender of this frame (host, with port if any). |
to |
string (len ≥1, len ≤260) | yes | The other end (host, with port if any). |
created |
integer | yes | Unix time in seconds. |
nonce |
string (^[A-Za-z0-9_-]{16,64}$) |
yes | The dialer’s random nonce; hello.ok echoes it. |
sig |
string (len ≤128) | yes | base64url Ed25519 signature over streamStatement(from, to, created, nonce). |
Signal frames — {"t": "signal", "msg": …}, where msg is one of call.state, rtc.sdp, rtc.ice, room.signal, call.voicemail, call.callback, call.key (the shapes in protocol.md, with callId naming the call or room leg on the receiving side).
Room signals — msg of a room.signal is one of room.state, room.media, room.idle, room.ended, room.leave, room.mute, room.remove, room.lock, room.talk, room.here, rtc.sdp, rtc.ice.
Not supported — the answer to a frame, or a signal’s msg, of a type the receiver doesn’t know (the stream stays open; never answered itself):
| Field | Type | Required | Notes |
|---|---|---|---|
t |
"unsupported" |
yes | |
type |
string (len ≤64) | yes | The frame’s t, or signal:<msg.t> for a signal. |
callId |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
The signal’s callId, when it had one. |
Timing constants — signature and hello freshness ±300 s; nonces remembered 11 minutes.
6.4 Idle close
Section titled “6.4 Idle close”- The dialer MUST close its socket (code
1000, reasonidle) once 60 s pass with no active call or room leg on the stream and no signal in either direction. While calls are up it SHOULD look again at least every 10 minutes, and SHOULD stop counting a call that has had no signaling for 6 hours (a lost hangup must not pin the stream open). - The acceptor never closes for idleness (on Cloudflare its Durable Object hibernates).
- There are no keepalive pings. A closed stream is simply dialed again when the next signal needs it; a server MUST NOT assume a signal was delivered because a socket was open.
1001means the server is shutting down.
7. Endpoints
Section titled “7. Endpoints”Federation version 1, base path /fed/v1. JSON bodies are at most 16384 bytes; voicemail audio at most 2097152 bytes. Every refusal carries { error: string (len ≤200) }.
| Endpoint | Signed | Summary |
|---|---|---|
GET /.well-known/openloungephone |
no | Discovery: the server’s federation key and where /fed/v1 lives. |
POST /fed/v1/knock |
yes | A contact request from from to to. |
POST /fed/v1/connections/accept |
yes | from accepts the knock that to sent them. |
POST /fed/v1/connections/remove |
yes | from disconnected from to, cancelled a knock, or blocked to. |
POST /fed/v1/calls |
yes | Ring someone on the receiving server for from. |
GET /fed/v1/stream |
no | WebSocket upgrade: the server-pair stream, authenticated in-band by hello. |
POST /fed/v1/voicemail |
yes | A voicemail message for a phone here, or with kind=person for a person here. |
POST /fed/v1/greeting |
yes | The greeting to play before from leaves a message for to. |
POST /fed/v1/phones |
yes | The household phones to may call through their connection with from. |
POST /fed/v1/addon-shares |
yes | Feature addon-shares: the shares of from’s add-ons that to holds (for listing and keys). |
POST /fed/v1/addon-shares/leave |
yes | Feature addon-leave: from gives back a share of to’s add-on (their grant goes; their calls within it end). |
POST /fed/v1/addons/test |
yes | Feature addon-shares: from tests their share of to’s add-on end to end (Test connection), without a call. |
POST /fed/v1/presence |
yes | from’s availability, for their connections on the receiving server (opt-in). |
POST /fed/v1/lounge/claim |
yes | The sender vouches that its account from scanned the receiver’s Lounge phone. |
POST /fed/v1/lounge/progress |
yes | The receiver’s account’s takeover of the sender’s Lounge phone moved on. |
POST /fed/v1/lounge/dial |
yes | A guest (the receiver’s account for) pressed a key on the sender’s Lounge phone; the receiver places the call as them. |
POST /fed/v1/lounge/leave |
yes | from leaves the receiver’s Lounge phone (from their own app). |
POST /fed/v1/lines/callback |
yes | Feature line-callback: the sender’s public line calls to back now (they asked while waiting in its queue). |
POST /fed/v1/lines/callback/missed |
yes | Feature line-callback: from didn’t take the line’s call back id. |
POST /fed/v1/rooms/join |
yes | from asks into the phone room room@<receiver>; leg names their side. |
GET /.well-known/openloungephone
Section titled “GET /.well-known/openloungephone”Discovery: the server’s federation key and where /fed/v1 lives.
Not signed.
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { version: integer, server_key: string (^[A-Za-z0-9_-]{43}$), federation: string, software?: string (len ≤64), versions?: object, features?: string (len ≤32)[] (0–64), previous_key?: string (^[A-Za-z0-9_-]{43}$), rotation_sig?: string (len ≤128), rotation?: { previous_key: string (^[A-Za-z0-9_-]{43}$), created: integer, expires: integer, sig: string (len ≤128) } } |
Cacheable for up to 300 s. |
| 404 | — | The server does not federate. |
Receiver rules
- Served over HTTPS (plain HTTP only for
localhostand*.localhost). server_keyis the raw 32-byte Ed25519 public key, base64url without padding.rotation(with the 0.1 pairprevious_key/rotation_sig) is present only during a key rotation’s overlap window (§3.2); receivers verify it withcheckRotation.
POST /fed/v1/knock
Section titled “POST /fed/v1/knock”A contact request from from to to.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
to |
string (^[a-z0-9._-]{2,30}$) |
yes | A handle on the receiving server. |
note |
string (len ≤140) | A short note shown with the request. |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always, whether or not the handle exists, the sender is blocked, cooling down or already pending. |
| 429 | — | The sending server exceeded its daily knock budget (Retry-After). |
Receiver rules
- MUST NOT reveal whether
toexists: unknown handles, blocks, cooldowns and duplicates all answer 202. - Knocking back someone who knocked you connects both (the receiver sends
/connections/accept).
POST /fed/v1/connections/accept
Section titled “POST /fed/v1/connections/accept”from accepts the knock that to sent them.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
to |
string (^[a-z0-9._-]{2,30}$) |
yes | The handle (on the receiving server) whose knock from accepts. |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always; ignored unless to has a pending knock to from. |
Receiver rules
- Both sides’ rows become
active; calls are allowed only from now on.
POST /fed/v1/connections/remove
Section titled “POST /fed/v1/connections/remove”from disconnected from to, cancelled a knock, or blocked to.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
to |
string (^[a-z0-9._-]{2,30}$) |
yes | The handle (on the receiving server) from disconnects from. |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always. |
Receiver rules
- The receiver drops its row for the pair (keeping its own blocks and declines) and any phone allow-list entries made through it.
POST /fed/v1/calls
Section titled “POST /fed/v1/calls”Ring someone on the receiving server for from.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
callId |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | Chosen by the caller’s server; names the call on both servers. |
from |
Party |
yes | One of the sending server’s accounts. |
to |
CallTarget |
yes | |
viaPhone |
{ label: string (len ≥1, len ≤24) } | A household phone (e.g. a kid’s) calls through from’s connection. |
|
guestOf |
string (len ≤260) | from is a guest at one of the sender’s Lounge phones, from this host. |
|
ringLabel |
string (len ≥1, len ≤24) | What to show while ringing, when it isn’t from.name. |
|
recording |
boolean | The caller’s space records this call; the receiver may refuse (denied + note). |
|
addon |
string (^[a-z0-9._-]{2,30}$) |
Feature addons: the caller is from’s add-on of this name; to must be a person. |
|
addonShare |
{ id: string (^[a-z0-9-]{1,30}$), label: string (len ≥1, len ≤40) } |
Feature addon-shares (with addon): the add-on calls within this share of it; to holds it. |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { state: "ringing" } | { state: "ended", reason: "hangup" | "declined" | "busy" | "denied" | "voicemail" | "timeout" | "unreachable" | "unavailable" | "error", note?: string (len ≤200), voicemail?: VoicemailOffer } |
ringing, or ended with a reason; never a voicemail offer (that is the caller’s server’s to make), except for a line target, whose server offers its own box. |
Receiver rules
- Decided by the receiver alone: an active connection with
fromby stable id (for a phone, an allow-list entry through such a connection withcanCallDevice), no block of the person or server, then quiet hours (voicemail), reachability, availability and busy. guesttargets are accepted only from the server that vouched for that guest at that Lounge phone.linetargets (featurepublic-lines) need no connection: the receiver checks that the line is published, its blocklist and the operator’s, who the line takes (anyone, people connected to the space, or an allow-list), its limits per caller and per server, and refuses calls from kids’ phones (viaPhone). Every refusal answersendedunavailable, the same as a line that doesn’t exist. While nobody is free the caller may wait:call.stateringingwithqueueupdates travel on the stream, the caller’s server may sendcall.voicemailto stop waiting, and the line’sendedcarries its ownvoicemailoffer, which the caller’s server relays.- Signaling for
callIdthen travels on the server-pair stream.
GET /fed/v1/stream
Section titled “GET /fed/v1/stream”WebSocket upgrade: the server-pair stream, authenticated in-band by hello.
Not signed.
Query parameters
| Field | Type | Required | Notes |
|---|---|---|---|
from |
string (len ≥1, len ≤260) | yes | The dialing server’s host (checked by hello). |
Responses
| Status | Body | Meaning |
|---|---|---|
| 101 | — | Switching protocols; the dialer’s first frame MUST be hello. |
| 400 | — | from is not a host name. |
Receiver rules
- See the stream.
POST /fed/v1/voicemail
Section titled “POST /fed/v1/voicemail”A voicemail message for a phone here, or with kind=person for a person here.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Query parameters
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string (len ≥1, len ≤64) | yes | A phone’s device id, or with kind=person a handle. |
kind |
"person" |
||
from |
string (^[a-z0-9._-]{2,30}$) |
yes | The sender’s handle on the sending server. |
fromId |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | The sender’s stable account id. |
name |
string (len ≥1, len ≤64) | yes | The sender’s display name. |
durationMs |
string (^\d{1,7}$) |
yes | Length in ms (capped at 120000). |
via |
string (len ≤24) | With kind=person: the sender’s kids’ phone that called, through its guardian. |
Request body: The raw recording (Content-Type: audio/*), at most 2 MiB; Content-Digest covers it.
Responses
| Status | Body | Meaning |
|---|---|---|
| 201 | { ok: true } |
Stored (and transcribed if the server does that). |
| 403 | — | No active connection with the sender (a phone: the sender isn’t on its allow-list). |
| 400 | — | Invalid sender fields or an empty body. |
| 413 | — | Larger than 2 MiB. |
| 415 | — | Not audio/*. |
Receiver rules
- The sender is
from/fromId/nameon the signing server; the receiver re-checks the connection (and allow-list) at delivery time.
POST /fed/v1/greeting
Section titled “POST /fed/v1/greeting”The greeting to play before from leaves a message for to.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
to |
{ kind: "person", handle: string (^[a-z0-9._-]{2,30}$) } | { kind: "phone", deviceId: string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) } |
yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | — | The audio (Content-Type: audio/*) with header olp-greeting: name|custom. |
| 204 | — | Use the spoken default greeting (olp-greeting: default). |
| 403 | — | No active connection (a phone: from not on its allow-list). |
Receiver rules
- Not cached by the caller’s server beyond the one voicemail it serves.
POST /fed/v1/phones
Section titled “POST /fed/v1/phones”The household phones to may call through their connection with from.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
to |
string (^[a-z0-9._-]{2,30}$) |
yes | |
phones |
{ id: string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$), label: string (len ≥1, len ≤24) }[] (0–16) |
yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always. |
Receiver rules
- Replaces the previous list for that connection; an empty list clears it.
POST /fed/v1/addon-shares
Section titled “POST /fed/v1/addon-shares”Feature addon-shares: the shares of from’s add-ons that to holds (for listing and keys).
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
to |
string (^[a-z0-9._-]{2,30}$) |
yes | |
shares |
{ addon: string (^[a-z0-9._-]{2,30}$), share: string (^[a-z0-9-]{1,30}$), label: string (len ≥1, len ≤40) }[] (0–100) |
yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always; ignored unless to has an active connection with from. |
Receiver rules
- Replaces the previous list for that connection; an empty list clears it.
- Informational: the add-on’s server decides every call into a share (
addontarget withshare) by its own grants. - Sent only to servers that list
addon-shares.
POST /fed/v1/addon-shares/leave
Section titled “POST /fed/v1/addon-shares/leave”Feature addon-leave: from gives back a share of to’s add-on (their grant goes; their calls within it end).
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
to |
string (^[a-z0-9._-]{2,30}$) |
yes | The add-on’s owner on the receiving server. |
addon |
string (^[a-z0-9._-]{2,30}$) |
yes | The add-on’s name. |
share |
string (^[a-z0-9-]{1,30}$) |
yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always; ignored unless from holds that share. |
Receiver rules
- Only the grant held by
from(the signing server’s account) for that share goes. - Sent only to servers that list
addon-leave; the holder’s server forgets the share either way.
POST /fed/v1/addons/test
Section titled “POST /fed/v1/addons/test”Feature addon-shares: from tests their share of to’s add-on end to end (Test connection), without a call.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
to |
string (^[a-z0-9._-]{2,30}$) |
yes | The add-on’s owner on the receiving server. |
addon |
string (^[a-z0-9._-]{2,30}$) |
yes | The add-on’s name. |
share |
string (^[a-z0-9-]{1,30}$) |
yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { state: "ok" | "offline" | "timeout" | "unavailable", ms?: integer (≥0), version?: string (len ≤64), uptimeS?: integer (≥0), calls?: integer (≥0) } |
How it went. |
| 429 | — | Too many tests from that person for that add-on (Retry-After). |
Receiver rules
unavailableunlessfromholds that share and could call it now; nothing else is told apart.- The receiver sends the add-on
addon.testand waits up to 5 seconds foraddon.test.ok.
POST /fed/v1/presence
Section titled “POST /fed/v1/presence”from’s availability, for their connections on the receiving server (opt-in).
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
to |
string (^[a-z0-9._-]{2,30}$)[] (1–200) |
yes | from’s active connections on the receiving server (one request per server). |
online |
boolean | yes | Has a live app session or phone. |
available |
boolean | yes | Takes calls right now (their own availability switch). |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always; entries in to without an active connection are ignored. |
Receiver rules
- Sent only while
fromopted in; batched per receiving server; at most 5 per person per 10 s (later changes coalesce to the latest state). - Receivers treat presence older than an hour as unknown.
POST /fed/v1/lounge/claim
Section titled “POST /fed/v1/lounge/claim”The sender vouches that its account from scanned the receiver’s Lounge phone.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
deviceId |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | |
nonce |
string (^[A-Za-z0-9_-]{16,64}$) |
yes | |
directory |
{ address: string (len ≤300), name: string (len ≥1, len ≤64) }[] (0–10) | yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { step: "press_key", expiresAt: integer } | { step: "failed", reason: "expired" | "wrong_key" | "timeout" | "busy" | "not_found" } |
Next step, or why not. |
Receiver rules
- Refused (
not_found) unless the phone’s space lets people from other servers use it. - The single-use
noncefrom the phone’s code must match; then the phone asks for the same key press as for a member.
POST /fed/v1/lounge/progress
Section titled “POST /fed/v1/lounge/progress”The receiver’s account’s takeover of the sender’s Lounge phone moved on.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string (^[a-z0-9._-]{2,30}$) |
yes | |
deviceId |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | |
step |
"press_key" | "started" | "failed" | "ended" |
yes | |
reason |
string (len ≤32) | ||
expiresAt |
integer (≥0) |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always; ignored unless the receiver vouched for to at that phone. |
POST /fed/v1/lounge/dial
Section titled “POST /fed/v1/lounge/dial”A guest (the receiver’s account for) pressed a key on the sender’s Lounge phone; the receiver places the call as them.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
callId |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | |
for |
string (^[a-z0-9._-]{2,30}$) |
yes | |
deviceId |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | |
deviceLabel |
string (len ≥1, len ≤24) | yes | |
to |
string (len ≤300) | yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { state: "ringing" } | { state: "ended", reason: "hangup" | "declined" | "busy" | "denied" | "voicemail" | "timeout" | "unreachable" | "unavailable" | "error", note?: string (len ≤200), voicemail?: VoicemailOffer } |
Like /calls; an ended answer may carry the guest’s server’s voicemail offer. |
Receiver rules
- Refused (
denied) unless the receiver has a live vouched session forforatdeviceIdon the sender, andtois one offor’s active connections.
POST /fed/v1/lounge/leave
Section titled “POST /fed/v1/lounge/leave”from leaves the receiver’s Lounge phone (from their own app).
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
from |
Party |
yes | One of the sending server’s accounts. |
deviceId |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always. |
POST /fed/v1/lines/callback
Section titled “POST /fed/v1/lines/callback”Feature line-callback: the sender’s public line calls to back now (they asked while waiting in its queue).
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | The call to waited under (their place in line). |
to |
string (^[a-z0-9._-]{2,30}$) |
yes | The person to call back, on the receiving server. |
line |
{ handle: string (^[a-z0-9._-]{2,30}$), name: string (len ≥1, len ≤64), description?: string (len ≤140) } |
yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { ok: true } |
Always, whether or not anyone rings. |
Receiver rules
- Rings
toonly if they asked that line (on the signing server) to call them back underidand that hasn’t expired; anything else is ignored (default deny). - Rings their apps and their own phones for 30 s. Answered: the receiver calls the line again through
/fed/v1/callswith thelinetarget’scallback=id. Declined or not answered: it sends/fed/v1/lines/callback/missed.
POST /fed/v1/lines/callback/missed
Section titled “POST /fed/v1/lines/callback/missed”Feature line-callback: from didn’t take the line’s call back id.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | |
from |
Party |
yes | One of the sending server’s accounts. |
line |
string (^[a-z0-9._-]{2,30}$) |
yes | The line’s handle on the receiving server. |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { retry: boolean, voicemail?: { ticket: string (len ≥16, len ≤128), name: string (len ≥1, len ≤24), maxMs: integer, prompts: ("name" | "greeting" | "vm.person" | "vm.cant_take" | "vm.leave_message" | "vm.tone" | "vm.sent" | "vm.not_sent" | "greet.say_name" | "greet.say_greeting" | "greet.saved" | "greet.not_saved" | "greet.default" | "greet.not_allowed")[] (0–8) } } |
Whether the line tries again, or its voicemail offer. |
Receiver rules
- Only for a call back that is ringing
from(the same server and account that asked). - The line’s own setting decides: one more try at the end of the line, or its voicemail offer (relayed by the caller’s server like a transfer’s). A line that doesn’t hear back within 45 s treats the call back as missed.
POST /fed/v1/rooms/join
Section titled “POST /fed/v1/rooms/join”from asks into the phone room room@<receiver>; leg names their side.
Signed (RFC 9421 profile, §4). Unsigned or badly signed requests get 401.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
leg |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | |
from |
Party |
yes | One of the sending server’s accounts. |
room |
string (^[a-z0-9._-]{2,30}$) |
yes |
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { ok: true, roomId: string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$), name: string (len ≤64) } | { ok: false, reason: "denied" | "locked" | "full" | "unreachable" | "busy" | "error", note?: string (len ≤200) } |
Admitted, or why not. |
Receiver rules
- Decided by the room’s server: open to connections and
fromconnected with the room’s owner, then the lock and the size. - Everything after admission travels as
room.signalon the stream, keyed byleg.
Call end reasons (CallResult.reason, call.state.reason): hangup, declined, busy, denied, voicemail, timeout, unreachable, unavailable, error
7.1 Shared types
Section titled “7.1 Shared types”One of the sending server’s accounts.
| Field | Type | Required | Notes |
|---|---|---|---|
handle |
string (^[a-z0-9._-]{2,30}$) |
yes | Current handle on the sending server (may change). |
id |
string (len ≥1, len ≤64, ^[A-Za-z0-9_-]+$) |
yes | Stable account id on the sending server (never changes). |
name |
string (len ≥1, len ≤64) | yes | Display name. |
CallTarget
Section titled “CallTarget”One of:
- { kind:
"person", handle: string (^[a-z0-9._-]{2,30}$) } - { kind:
"phone", deviceId: string (len ≥1, len ≤64,^[A-Za-z0-9_-]+$) } - { kind:
"guest", deviceId: string (len ≥1, len ≤64,^[A-Za-z0-9_-]+$) } - { kind:
"line", handle: string (^[a-z0-9._-]{2,30}$), callback?: string (len ≥1, len ≤64,^[A-Za-z0-9_-]+$) } - { kind:
"addon", handle: string (^[a-z0-9._-]{2,30}$), name: string (^[a-z0-9._-]{2,30}$), share?: string (^[a-z0-9-]{1,30}$) }
VoicemailOffer
Section titled “VoicemailOffer”The call wasn’t answered (no answer, declined, busy, quiet hours, unavailable, offline): the caller may leave a message. Play the greeting, the tone, record until hang-up, then upload.
| Field | Type | Required | Notes |
|---|---|---|---|
ticket |
string (len ≥16, len ≤128) | yes | Single use, expires in 10 minutes. GET /api/vm/greeting?ticket= returns the greeting audio (olp-greeting: name|custom) or 204 for the default; POST /api/vm/message?ticket=&durationMs= with a raw audio/* body leaves the message. |
name |
string (len ≥1, len ≤24) | yes | Who was called, for the spoken default greeting. |
maxMs |
integer | yes | Longest message accepted (2 minutes). |
prompts |
("name" | "greeting" | "vm.person" | "vm.cant_take" | "vm.leave_message" | "vm.tone" | "vm.sent" | "vm.not_sent" | "greet.say_name" | "greet.say_greeting" | "greet.saved" | "greet.not_saved" | "greet.default" | "greet.not_allowed")[] (0–8) |
yes | What to play before recording, in order, for the default and name greetings: ["name","vm.cant_take","vm.leave_message","vm.tone"]. For a custom greeting play greeting, then vm.tone. |
8. Semantics
Section titled “8. Semantics”8.1 Knocks and connections
Section titled “8.1 Knocks and connections”- Knock (
/knock):fromasks to connect withto. The receiver MUST answer202in every case — unknown handle, the sender blocked or cooling down after a decline, a duplicate, or an inbox over its daily limit (default 50 a day per account, the rest dropped silently) — so an address book can’t be probed. Only accounts are knockable; household phones never are. - Accept (
/connections/accept): the knocked account accepts; both sides mark the connection active. If the receiver’stoknockedfromearlier, that is the match; otherwise the request is ignored. Knocking someone who already knocked you is an accept. - Decline sends nothing: the knock just expires (30 days) on the knocker’s side, and the decliner’s server drops that person’s further knocks for 30 days.
- Remove (
/connections/remove): disconnect, cancel a pending knock, or block. The receiver drops its row for that pair, and anything granted through it (phone allow-list entries, shared phones, presence), but keeps its own blocks and declines. - Senders limit their own accounts (defaults: 10 knocks a day per account, one pending knock per pair); a public server may add a monthly allowance.
8.2 Calls
Section titled “8.2 Calls”- The caller’s server checks its own rules, then sends
/callswith acallIdit chose and registers that id on the stream. The callee’s server authorizes alone (§7,/callsrules) and answersringingorended+ reason.deniedMUST be used for every authorization failure, so a caller can’t tell “no connection” from “blocked”. - A call to a person rings everywhere the person is on their server; the first place to answer
wins. The callee’s server decides when it stops ringing (its ring time, 10–60 s) and then
sends
call.state endedwithtimeout. Callers MUST NOT end the call on their own ring timer before a generous backstop (the reference uses the maximum ring time plus 5 s). - Signaling on the stream, from the side that owns the event:
- the callee’s server sends
call.state connectingwhen someone answers; - the party that placed the call sends the SDP offer (
rtc.sdp offer), the callee’s side answers (rtc.sdp answer);rtc.iceflows both ways; the side that relays the answer marks the callactiveand both sides sendcall.state active; call.state ringingandrtc.confignever cross: each server rings its own people and hands its own clients its own ICE servers (its own TURN);- either side ends with
call.state ended+ reason;holdon anactivestate is from the receiver’s person’s point of view (them= the other side put the call on hold).
- the callee’s server sends
- Media is WebRTC between the two clients, peer to peer or through each side’s own TURN; it never flows through either server for a 1:1 call. Clients SHOULD enable Opus DTX.
- A call merged into a 3-way call ends with
call.state ended+merged {roomId}and continues as a room leg under the same id (§8.6).
8.3 Presence
Section titled “8.3 Presence”Presence is opt-in per account and goes only to that account’s active connections. A server
sends one /presence per receiving server with every connected handle there in to (at most
200 per request), at most 5 changes per person per 10 s; changes over that are coalesced (only
the latest state is sent when the window opens). Turning presence off sends online: false
once. Receivers MUST ignore handles in to that have no active connection with from, and
SHOULD show presence older than an hour as unknown.
8.4 Voicemail and greetings
Section titled “8.4 Voicemail and greetings”-
Who offers. When a call goes unanswered (no answer, declined, busy, quiet hours →
voicemail, unavailable, unreachable), the caller’s own server offers voicemail to its caller (aVoicemailOfferwith a single-use ticket in its owncall.state ended). The callee’s server MUST NOT put an offer in/callsanswers or incall.stateon the stream, with exactly two exceptions: a Lounge guest’s call (§8.5) and an unanswered transfer (§8.7). Receivers MUST ignore offers anywhere else. -
Greeting. The caller’s server fetches what to play with a signed
/greeting(to= the person, or the phone through its allow-list). The callee’s server answers only with an active connection (a phone:fromon its allow-list): the recording witholp-greeting: name|custom, or204for the spoken default, built from the offer’sname. -
Message. The caller’s server delivers the recording with a signed
/voicemail?to=&from=&fromId=&name=&durationMs=(kind=personfor a person;viawhen a kids’ phone called through its guardian). The callee’s server re-checks the connection (and the phone’s allow-list) at delivery, stores it, and tells its person or the phone’s guardians.durationMsis capped at 120000; recordings are at most 2 MiB ofaudio/*. -
Relayed offers. When an offer crosses servers (the two exceptions), the receiving server gives its own client an offer of its own whose ticket forwards to the issuing server’s public ticket endpoints, which are part of this protocol:
GET https://<issuer>/api/vm/greeting?ticket=<ticket>→ greeting audio witholp-greeting: name|custom, or204for the default;404for an unknown or used ticket;POST https://<issuer>/api/vm/message?ticket=<ticket>&durationMs=<ms>with the rawaudio/*body →201,403(no longer allowed),404(unknown or used),429(the sender’s fair-use allowance).
Tickets are bearer credentials: single use, valid 10 minutes. A relaying server MUST send a ticket only to the server that issued it and MUST NOT log it.
8.5 Lounge guests
Section titled “8.5 Lounge guests”A Lounge phone’s space may let people from other servers use it (off by default).
- The guest scans the phone’s code on server L, gives their own address
alice@H, and continues on their own server H, which asks them to confirm. - H vouches with a signed
/lounge/claim {from, deviceId, nonce, directory}:fromis the guest’s own account,noncethe single-use code from the phone,directorytheir speed-dial (at most 10 of their connections, as addresses). H records that it vouched. - L refuses unless the space allows guests, the nonce is current and unused, and the
phone is free; then the phone asks for the same key press as for a member. L reports
progress to H with
/lounge/progress(press_key,started,failed+ reason,ended+ reason). H MUST ignore progress for sessions it didn’t vouch for. - Dialing. A key press on the phone sends
/lounge/dial {callId, for, deviceId, deviceLabel, to}to H. H places the call as its account — its own rules and connections — and relays between the two legs (each leg has its own call id). H MUST answerdeniedunless it has a live vouched session forforat that phone of L andtois one offor’s active connections. Anendedanswer MAY carry H’s voicemail offer, which L relays (§8.4). - Calls to the guest ring the phone too: H sends
/callswith target{kind: "guest", deviceId}; L MUST accept it only from the server that vouched for the guest currently at that phone, and only for that account (from.id). - The session ends by logging out,
/lounge/leavefrom the guest’s own app through H, idle, a new takeover, or the phone going offline; L sendsendedprogress.
L learns the guest’s address and name only. H stays the authority for who the guest may call.
8.6 Rooms
Section titled “8.6 Rooms”- A phone room has an address like a person,
standup@host. A person on another server joins from their own server, which checks its own rules, registers a leg id on the stream and sends/rooms/join {leg, from, room}. - The room’s server decides alone: the room must be open to connections and
frommust have an active connection with the room’s owner (people in the room’s own space are always eligible), then the lock and the size. It answers{ok: true, roomId, name}or{ok: false, reason}(denied,locked,full,unreachable,busy,error). - Afterwards every room message travels as
room.signal {callId: <leg>, msg}in both directions:room.state,room.media,room.idle,room.endedto the participant;room.leave,room.mute,room.talk,room.here,room.mediaand a mesh room’srtc.*(withpeer) from them. The route ends withroom.endedorroom.leave. - Each person’s own server hands them its own ICE servers. A room’s media goes through the room owner’s server’s relay (SFU) or a peer-to-peer mesh (up to 4 people); relayed rooms are encrypted in transit, not end to end. Each person’s own server meters their minutes; the room’s server MAY also count remote participants against the room’s owner.
8.7 Transfers
Section titled “8.7 Transfers”- Only a team or org space may transfer someone from another household or server, and only to its own members, phones (never a kids’ phone), ring groups and extensions. A home never transfers a remote party; targets outside the space are refused.
- Blind: the transferring server rings the target under a new call id
N, registersNon the stream, and sends the other sidecall.state ended(reasonhangup) for the old call withtransfer {callId: N, ringing: true, offerer: true}. Attended: the same, withringing: false(the new call connects at once). - The receiving server re-checks its own side. A kids’ phone never follows: its server
just ends the old call. Otherwise it opens its own successor call for its person (or relays
the notice on to the server or household its person is in) and tells its client the same way.
offerer: truemeans its person places the new call (sends the SDP offer). - The old route stays open until the other side follows or ends it; if the receiver ends the old
call instead of following, the sender MUST end
N. - Unanswered: if nobody answers
N, the transferring server sendscall.state endedforNwith its own voicemail offer (the ring group’s shared box, or the member’s), exactly as a caller from its own space would get; the receiving server relays it to its person (§8.4).
8.8 Recording
Section titled “8.8 Recording”- A space may record its calls (opt-in, announced to everyone). The caller’s server MUST set
recording: trueon/callswhen its space records the call. A server MAY refuse recorded calls for its people: it answersended+deniedwithnote“This server doesn’t take recorded calls” (or ends a call when a recording is announced later). - When recording starts (once the call is active), the recording side’s server sends
call.state activewithrecording: {by: <space name>}on the stream. It MUST NOT include aticket— the recording is made by the recording side’s own client. The receiving server announces it to its person (prompt and light) or ends the call if it refuses recordings. - Calls with a kids’ phone on either side are never recorded: a caller’s server MUST NOT set
recordingfor calls placed throughviaPhoneor to a household phone, and a receiving server MUST NOT record calls fromviaPhone.
9. Versioning and compatibility
Section titled “9. Versioning and compatibility”A version is wire compatibility: the paths, the signature profile (§4), the stream
handshake (§6.1) and every field’s meaning. A feature is an optional capability within a
version. Both are advertised in .well-known (§2) and negotiated per peer; neither is ever
assumed from software.
9.1 Negotiation
Section titled “9.1 Negotiation”- Before its first request to a peer, a server fetches the peer’s
.well-knownand caches it for the response’smax-age(the reference server clamps it to 1 minute … 1 hour, default 5 minutes). It fetches it again when the cache is stale, when a signature from the peer fails with the pinned key (§3.1, the same fetch), and after the peer answers a request with a4xx(e.g. a404for an endpoint it no longer has). If a fetch fails, a stale copy is used and retried a minute later; with no copy at all the peer is treated as an 0.1 server (version 1, the 0.1 features) and the request itself decides. - The peer’s versions are
versionswhen present, else{1: federation}. A base path is used only if it is a plain absolute path (^(/[A-Za-z0-9_-]+)+$, at most 64 characters); any other value counts as not offered. - The server uses the highest version both sides speak, at the peer’s base path for it,
for every request and for the stream (
<base>/stream, statementolp-stream-v<N>). - No common version: nothing is sent. The person who asked gets a plain error (a knock
fails with it, a call ends
unreachablewith it asnote, e.g. “b.example only speaks a newer federation version (2); this server needs an update to talk to it”), the server logs it, and nothing else is affected. The other side, if it tries, gets the404described in §5 naming the versions this server speaks.
Because @target-uri is signed (§4), a request signed for one version can’t be replayed
against another. Keys and pins are shared across versions.
9.2 Features
Section titled “9.2 Features”A server lists in features exactly the optional capabilities it implements. A sender MUST use
an optional feature with a peer only if the peer lists it, and otherwise degrades with a clear
outcome for the person, never a silent failure:
- no
rooms: joining a room on that server or merging a call with someone there is refused with “Rooms aren’t available with that server yet”; - no
recording-flag: a call that would be recorded is never placed to that server (endeddeniedwith a note), a recorded call from it is ended rather than recorded unannounced, and people from it can’t join a recorded space’s rooms; - no
lounge-guests: signing in to that server’s Lounge phone is refused in plain words; - no
transfer: transferring someone from that server is refused in plain words; - no
voicemail-greeting: the caller hears the spoken default greeting; - no
presence-batch: availability isn’t sent to that server; - no
key-rotation: the peer can’t follow a rotation on its own; the rotating server’s operator is told which peers will need their operators to re-trust the new key (§3.4).
Absent features means an 0.1 server, which implements every feature marked below, so
0.1 peers keep everything they had. Receivers MUST ignore feature names they don’t know. A
registered name never changes meaning; a capability that needs a different meaning gets a new
name.
This implementation speaks federation version 1 (/fed/v1).
| Feature | Implied when features is absent (0.1) |
What it covers |
|---|---|---|
rooms |
yes | Phone rooms across servers: POST /fed/v1/rooms/join, room.signal stream frames, and 3-way merges that turn a call into a room leg (call.state.merged). |
recording-flag |
yes | Recorded calls: CallBody.recording, the call.state.recording notice, and refusing recorded calls with denied + note. A server never places or keeps a recorded call with a peer that doesn’t list it. |
lounge-guests |
yes | Guests at another server’s Lounge phone: /fed/v1/lounge/{claim,progress,dial,leave}, the guest call target, CallBody.guestOf/ringLabel. |
key-rotation |
yes | Publishes and verifies the timed key hand-over (.well-known.rotation, statement openloungephone-key-rotation-v2, §3.2). |
voicemail-greeting |
yes | POST /fed/v1/greeting: the callee’s greeting for a caller whose call went to voicemail. Without it the caller hears the spoken default greeting. |
presence-batch |
yes | POST /fed/v1/presence with to listing all of the sender’s connections on the receiving server in one request. |
transfer |
yes | Transfers across servers: call.state ended + transfer hands a call over to a new call id with the same server (team and organization spaces). |
public-lines |
no | Public lines: the line call target (a published ring group, no connection needed), call.state.queue and call.state.line on the stream, call.voicemail from the caller’s server, and the line’s voicemail offer passed with ended. |
line-callback |
no | Call me back from a public line’s queue: call.callback from the caller’s server, call.state.callback with ended, POST /fed/v1/lines/callback (ring the person now) and /fed/v1/lines/callback/missed, and the line call target’s callback. Needs public-lines. |
addons |
no | Add-ons (services linked to a person’s account, handle+name@host): the addon call target, CallBody.addon for calls from an add-on, and call.key on the stream. A server lists it only when its operator turned add-ons on; add-on calls are never placed with a server that doesn’t list it. |
addon-shares |
no | Shares of add-ons (handle+name+share@host, docs/add-ons.md): the addon call target’s share, CallBody.addonShare for an add-on’s call within a share, and POST /fed/v1/addon-shares (the shares a person there holds). Needs addons. Grants to people on a server that doesn’t list it, and calls into shares with it, are refused. |
addon-leave |
no | POST /fed/v1/addon-shares/leave: someone there gives back a share of one of our add-ons (their grant goes, and their calls within it end). Needs addon-shares. Without it, the holder’s server still forgets the share and refuses the add-on’s calls to them; the add-on’s owner keeps seeing them as a holder. |
New features are added to this registry (and to FEATURES in packages/federation) in the
same change that implements them.
9.3 Tolerance
Section titled “9.3 Tolerance”- Receivers MUST ignore unknown JSON fields in every body, query, response, stream frame and
.well-known(the reference implementation’s schemas strip them; none is strict). Limits on size (§5, §6.2) still apply. - Unknown endpoints and versions are answered
404“not supported” (§5); unknown stream frames and signal types are answeredunsupported(§6.2). Neither closes a connection. - Closed enums (call end reasons, room-join reasons, Lounge steps, call target kinds) are validated strictly by v1 receivers. A new value is allowed within version 1 only behind a feature: a sender uses it only with peers that advertise that feature. Anything else that needs a new value needs a new version.
9.4 Additive and breaking changes
Section titled “9.4 Additive and breaking changes”- Additive changes keep the version: new optional request or response fields, new
endpoints, new stream frames and signal types, new
.well-knownfields, new features (and enum values behind them). - Everything else is breaking and needs a new version: removing or renaming a field, making an optional field required (or a required one optional), changing a field’s meaning, type or limits (in either direction: a v1 receiver rejects a longer value it doesn’t allow), a new enum value outside a feature, a different signature profile, or a different stream handshake.
- Golden vectors freeze the wire.
tests/conformance/holds the v1 conformance vectors: RFC 9421 signature bases and signatures for fixed keys and requests,Content-Digestvalues, key-rotation statements and their verification, streamhellostatements, example bodies and frames for every endpoint, and a snapshot of every v1 schema. Their tests must pass forever: a v1 vector is never edited (a breaking change needs v2 and new vectors), and the schema snapshot test fails if a field is removed, renamed, made required, or has its type or limits changed. The same directory freezes the device protocol (protocol.md).
9.5 Deprecation and the support window
Section titled “9.5 Deprecation and the support window”- A server speaks the current federation version and the previous one. When version N+1
ships, the reference server keeps serving
/fed/vNunchanged for at least 12 months and advertises both inversions(keeping"federation": "/fed/v1"for 0.1 peers as long as it speaks v1). - A version is dropped only in a release that says so in its notes, after its 12 months, by
removing it from
versions. Peers that speak only the dropped version then get the “no common version” outcome (§9.1) with words saying which side needs an update. - Features are not deprecated within a version: a server that stops supporting one stops listing it, and peers degrade as in §9.2.
- Interop is tested release to release: CI runs the current server against the previous
released server (git tag
server-v*, pinned in.github/workflows/interop.yml) in both directions, and against a simulated newer server that advertises extra versions and features and sends unknown fields.
9.6 Inside signals
Section titled “9.6 Inside signals”Device and app messages carried inside signals (call.state, rtc.*, room.*) follow the
device protocol’s own versioning (protocol.md, PROTOCOL_VERSION); additive
fields there are additive here.
10. Security considerations
Section titled “10. Security considerations”10.1 Transport and first contact
Section titled “10.1 Transport and first contact”All federation traffic MUST use HTTPS/WSS with certificate validation (loopback hosts excepted).
Trust on first use means an attacker who can impersonate a server’s TLS name at the very first
contact could get the wrong key pinned; after that, a changed key is refused and surfaced to the
operator. Operators who know each other MAY compare server_key values out of band.
10.2 Key compromise and loss
Section titled “10.2 Key compromise and loss”A stolen private key lets the thief speak for every account on that server to every peer, until
peers stop trusting it. There is no revocation in v1. The victim rotates (§3.2), which moves
peers that follow within the window to a key the thief doesn’t have — but the thief holds the
old key too and could sign a competing hand-over for peers that haven’t followed yet (they would
also need to serve the victim’s .well-known, i.e. control its name or TLS). After a known
compromise, operators SHOULD tell their peers’ operators the new fingerprint out of band. On
Cloudflare, rotating after a leak of FED_PRIVATE_KEY only helps as long as the database stays
private too (the sealed key is derived from that secret). Keep the key backed up and secret;
losing it without a rotation breaks all pins.
10.3 Replay and binding
Section titled “10.3 Replay and binding”created (±300 s) plus single-use nonces stop replays; nonces are consumed only by valid
signatures. @target-uri binds a signature to one receiver, path, query and version; the
receiver MUST use its configured public origin to rebuild it, never proxy headers.
content-digest binds the body. Stream hello frames are bound to both hosts and a fresh
nonce, and hello.ok echoes the dialer’s nonce, so each side proves it holds its key.
10.4 Server-side request forgery
Section titled “10.4 Server-side request forgery”Addresses are typed by people (x@some.host) and keyids arrive from anyone, so a server
must never let them point it into its own network. A server with a public name MUST NOT contact
(knocks, key fetches, streams, ticket relays) loopback names, IP literals, single-label names or
local-network suffixes (.local, .internal, .lan, .home.arpa, .localdomain); a
development server on a loopback name may talk to other loopback servers. DNS can still resolve
a public name to a private address: self-hosted operators SHOULD also filter outbound traffic
(Cloudflare Workers can’t reach private networks).
10.5 Privacy and enumeration
Section titled “10.5 Privacy and enumeration”Knocks always answer 202, authorization failures always say denied, and there is no
directory, so accounts can’t be listed or probed. Presence, names and shared phones go only to
active connections. Call audio is peer to peer (or through the parties’ own TURN) and never
passes through a server; relayed room audio passes through the room owner’s relay (encrypted in
transit, not end to end). Recordings are opt-in per space and always announced to every party,
on every server.
10.6 Abuse and denial of service
Section titled “10.6 Abuse and denial of service”Budgets per sending server (§5) and per account, bounded bodies (16 KiB JSON, 2 MiB audio, 16 KiB stream frames), one stream per server pair that the dialer closes when idle, and operator blocklists (a person, a household, or a whole server) keep any one server from exhausting another. Budgets are equal for every server.
10.7 Trust boundaries
Section titled “10.7 Trust boundaries”A server only speaks for its own accounts: the keyid host is the authority for every from,
and a receiver MUST bind from to that host. Everything that protects a person — connections,
allow-lists, quiet hours, blocks, recording refusal, a kids’ phone never following a transfer —
is enforced by that person’s own server, whatever the other server claims. Lounge guest
assertions are accepted only from the guest’s own server and only for the phone it vouched at.
Voicemail tickets are bearer tokens: single use, 10 minutes, forwarded only to their issuer.
11. Implementation notes
Section titled “11. Implementation notes”- Reference implementation:
packages/federation(signatures, keys, schemas, this spec’s generated parts) andpackages/server-app(federation.ts,fedStream.ts,fedCalls.ts,connections.ts,peers.tsfor negotiation). Interop tests:tests/e2e/twoServers.test.ts(two self-hosted servers),tests/e2e/cloudflare.test.ts(two Workers) andtests/e2e/crossVersion.test.ts(this server against the previous release, both ways), all in CI; conformance vectors intests/conformance/. - Key rotation: operators rotate from the companion’s Operator view (“Rotate key”) or with
npx openloungephone federation rotate-key --url <server>(their session inOLP_SESSION_TOKEN); both callPOST /api/admin/federation/rotate-key. The Operator view lists pinned keys (host, fingerprint, first seen, pinned / key change refused, and what the peer last advertised: software, versions, features) with “Re-trust…” and “Block server…”; every such action, and every automatic re-pin or refusal, goes into the server-wide operator audit trail (GET /api/admin/audit). These/api/adminroutes are the reference server’s own, not part of this protocol. - Same server, several households: calls between households on one server use the same
code paths with host
''and direct hub-to-hub delivery instead of HTTP and the stream; that is internal and not part of this protocol.