Skip to content

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.

  1. Scope and model
  2. Discovery
  3. Server keys and pinning
  4. Request signatures (RFC 9421 profile)
  5. Processing a request: order, errors and rate limits
  6. The server-pair stream
  7. Endpoints
  8. Semantics
  9. Versioning and compatibility
  10. Security considerations
  11. Implementation notes
  • A server is identified by its host: the DNS name, with :port when it isn’t the default, of the URL people reach it at (its PUBLIC_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, where handle matches ^[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 from in 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.

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"] }
  • version is the highest federation version the server speaks; federation is the base path of version 1. versions maps every version it speaks to its base path, and features lists the optional capabilities it supports; both are negotiated as in §9. A document without them is from an 0.1 server (§9.2).

  • software is informational (implementation and version); receivers MUST NOT change behaviour because of it — that is what versions and features are for.

  • server_key is the raw 32-byte Ed25519 public key, base64url without padding.

  • rotation (and the 0.1 pair previous_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_key and federation are required for the key to be usable.

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:

  1. same key as pinned → the signature is bad (401 signature: bad_signature);
  2. a different key with a valid rotation statement (§3.2) → replace the pin with the new key and verify with it;
  3. 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).

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:

  1. rotation is present and rotation.previous_key equals the pinned key (and differs from server_key);
  2. created ≤ now + 300, created < expires, and expires − created ≤ 7776000 (90 days);
  3. now < expires;
  4. sig verifies with the pinned key over the statement built from the receiver’s own view of host (the keyid or stream from it is resolving), previous_key, server_key, created and expires.

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).

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_KEY secret, generated once by scripts/deploy.ts and 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) from FED_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 writes DATA_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.

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).

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", and nonce (16–64 characters; senders SHOULD use 16 random bytes, base64url). No other parameters are used; expires MUST 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 (its PUBLIC_URL) plus the request’s path and query, never from a Host or forwarding header, so a signature for one server, path or version can’t be replayed against another.

  • Content-Digest: RFC 9530 sha-256 over 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"

    @method is 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 created more 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 and created for 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 429 as “try later”: honour Retry-After, and for a call answer the caller with busy rather than retrying while it rings.
  • MUST NOT retry other 4xx unchanged. MAY retry network errors and 5xx with exponential backoff, re-signing each attempt (fresh created and nonce).
  • MUST treat a peer’s 404 on 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-known again 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).

Call and room signaling between two servers travels over one WebSocket per server pair.

  • 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}, where sig is the dialer’s Ed25519 signature (base64url) over the UTF-8 statement

    olp-stream-v1\n<from>\n<to>\n<created>\n<nonce>
  • The acceptor MUST check that from equals the from query parameter, to is its own host, created is within ±300 s, the signature verifies with the pinned key of from (§3; one re-fetch for rotation), and the nonce is unused (remembered 11 minutes). It then answers hello.ok with from/to swapped, its own created, the dialer’s nonce, and its own signature over the same statement form. The dialer MUST check hello.ok the 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) or hello.ok (dialer). A reference server refuses an upgrade whose from is not a host with 400 (Workers) or close code 4400.

  • Signals the dialer wants to send wait until hello.ok; then they go out in order.

  • 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.callId names a call (or a room leg) as the receiver knows it. A server registers the ids it expects (before sending /calls, /rooms/join or 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 carries merged (the call continues as a room leg under the same id) or transfer (the other side may still answer that it won’t follow) — and with room.signal carrying room.ended or room.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 signal whose msg.t (or a room signal’s inner msg.t) it doesn’t know, is answered with {t: "unsupported", type, callId?} on the same socket and otherwise ignored; the stream stays open. unsupported is never answered, so two servers can’t loop. Unknown fields inside known frames are dropped (§9.3).

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.

  • The dialer MUST close its socket (code 1000, reason idle) 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.
  • 1001 means the server is shutting down.

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.

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 localhost and *.localhost).
  • server_key is the raw 32-byte Ed25519 public key, base64url without padding.
  • rotation (with the 0.1 pair previous_key/rotation_sig) is present only during a key rotation’s overlap window (§3.2); receivers verify it with checkRotation.

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 to exists: unknown handles, blocks, cooldowns and duplicates all answer 202.
  • Knocking back someone who knocked you connects both (the receiver sends /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.

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.

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 from by stable id (for a phone, an allow-list entry through such a connection with canCallDevice), no block of the person or server, then quiet hours (voicemail), reachability, availability and busy.
  • guest targets are accepted only from the server that vouched for that guest at that Lounge phone.
  • line targets (feature public-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 answers ended unavailable, the same as a line that doesn’t exist. While nobody is free the caller may wait: call.state ringing with queue updates travel on the stream, the caller’s server may send call.voicemail to stop waiting, and the line’s ended carries its own voicemail offer, which the caller’s server relays.
  • Signaling for callId then travels on the server-pair 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

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/name on the signing server; the receiver re-checks the connection (and allow-list) at delivery time.

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.

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.

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 (addon target with share) by its own grants.
  • Sent only to servers that list addon-shares.

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.

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

  • unavailable unless from holds that share and could call it now; nothing else is told apart.
  • The receiver sends the add-on addon.test and waits up to 5 seconds for addon.test.ok.

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 from opted 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.

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 nonce from the phone’s code must match; then the phone asks for the same key press as for a member.

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.

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 for for at deviceId on the sender, and to is one of for’s active connections.

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.

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 to only if they asked that line (on the signing server) to call them back under id and 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/calls with the line target’s callback = id. Declined or not answered: it sends /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.

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 from connected with the room’s owner, then the lock and the size.
  • Everything after admission travels as room.signal on the stream, keyed by leg.

Call end reasons (CallResult.reason, call.state.reason): hangup, declined, busy, denied, voicemail, timeout, unreachable, unavailable, error

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.

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}$) }

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.
  • Knock (/knock): from asks to connect with to. The receiver MUST answer 202 in 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’s to knocked from earlier, 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.
  • The caller’s server checks its own rules, then sends /calls with a callId it chose and registers that id on the stream. The callee’s server authorizes alone (§7, /calls rules) and answers ringing or ended + reason. denied MUST 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 ended with timeout. 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 connecting when someone answers;
    • the party that placed the call sends the SDP offer (rtc.sdp offer), the callee’s side answers (rtc.sdp answer); rtc.ice flows both ways; the side that relays the answer marks the call active and both sides send call.state active;
    • call.state ringing and rtc.config never 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; hold on an active state is from the receiver’s person’s point of view (them = the other side put the call on hold).
  • 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).

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.

  • 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 (a VoicemailOffer with a single-use ticket in its own call.state ended). The callee’s server MUST NOT put an offer in /calls answers or in call.state on 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: from on its allow-list): the recording with olp-greeting: name|custom, or 204 for the spoken default, built from the offer’s name.

  • Message. The caller’s server delivers the recording with a signed /voicemail?to=&from=&fromId=&name=&durationMs= (kind=person for a person; via when 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. durationMs is capped at 120000; recordings are at most 2 MiB of audio/*.

  • 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 with olp-greeting: name|custom, or 204 for the default; 404 for an unknown or used ticket;
    • POST https://<issuer>/api/vm/message?ticket=<ticket>&durationMs=<ms> with the raw audio/* 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.

A Lounge phone’s space may let people from other servers use it (off by default).

  1. 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.
  2. H vouches with a signed /lounge/claim {from, deviceId, nonce, directory}: from is the guest’s own account, nonce the single-use code from the phone, directory their speed-dial (at most 10 of their connections, as addresses). H records that it vouched.
  3. 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.
  4. 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 answer denied unless it has a live vouched session for for at that phone of L and to is one of for’s active connections. An ended answer MAY carry H’s voicemail offer, which L relays (§8.4).
  5. Calls to the guest ring the phone too: H sends /calls with 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).
  6. The session ends by logging out, /lounge/leave from the guest’s own app through H, idle, a new takeover, or the phone going offline; L sends ended progress.

L learns the guest’s address and name only. H stays the authority for who the guest may call.

  • 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 from must 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.ended to the participant; room.leave, room.mute, room.talk, room.here, room.media and a mesh room’s rtc.* (with peer) from them. The route ends with room.ended or room.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.
  • 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, registers N on the stream, and sends the other side call.state ended (reason hangup) for the old call with transfer {callId: N, ringing: true, offerer: true}. Attended: the same, with ringing: 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: true means 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 sends call.state ended for N with 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).
  • A space may record its calls (opt-in, announced to everyone). The caller’s server MUST set recording: true on /calls when its space records the call. A server MAY refuse recorded calls for its people: it answers ended + denied with note “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 active with recording: {by: <space name>} on the stream. It MUST NOT include a ticket — 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 recording for calls placed through viaPhone or to a household phone, and a receiving server MUST NOT record calls from viaPhone.

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.

  1. Before its first request to a peer, a server fetches the peer’s .well-known and caches it for the response’s max-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 a 4xx (e.g. a 404 for 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.
  2. The peer’s versions are versions when 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.
  3. 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, statement olp-stream-v<N>).
  4. No common version: nothing is sent. The person who asked gets a plain error (a knock fails with it, a call ends unreachable with it as note, 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 the 404 described 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.

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 (ended denied with 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.

  • 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 answered unsupported (§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.
  • Additive changes keep the version: new optional request or response fields, new endpoints, new stream frames and signal types, new .well-known fields, 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-Digest values, key-rotation statements and their verification, stream hello statements, 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).
  • A server speaks the current federation version and the previous one. When version N+1 ships, the reference server keeps serving /fed/vN unchanged for at least 12 months and advertises both in versions (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.

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.

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.

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.

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.

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).

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.

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.

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.

  • Reference implementation: packages/federation (signatures, keys, schemas, this spec’s generated parts) and packages/server-app (federation.ts, fedStream.ts, fedCalls.ts, connections.ts, peers.ts for negotiation). Interop tests: tests/e2e/twoServers.test.ts (two self-hosted servers), tests/e2e/cloudflare.test.ts (two Workers) and tests/e2e/crossVersion.test.ts (this server against the previous release, both ways), all in CI; conformance vectors in tests/conformance/.
  • Key rotation: operators rotate from the companion’s Operator view (“Rotate key”) or with npx openloungephone federation rotate-key --url <server> (their session in OLP_SESSION_TOKEN); both call POST /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/admin routes 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.

Proudly supported by unsubscribe.llc · Sponsor the project