Tieroute client API
This page is the reference for the Tieroute client API: every endpoint a client calls on its own homeserver, what each one accepts and returns, and which errors it gives. It's for anyone building a Tieroute client, or a homeserver that serves one. Status: draft, wire version 1. The server-to-server (S2S) API that homeservers use between themselves is in Server-to-server API.
Endpoint summary
| Endpoint | Auth | Purpose |
|---|---|---|
POST register |
no | Register an identity from its genesis entry, or move it here |
POST auth/challenge |
no | Get a nonce to sign |
POST auth/session |
no | Trade a signed nonce for a token |
POST auth/logout |
yes | Revoke this token |
GET chain/{id} |
no for local identities, yes for remote ones | Fetch an identity chain |
POST chain/append |
yes | Append an entry to your own chain |
GET resolve/{handle} |
no for local handles, yes for remote ones | Resolve a handle |
POST keypackages/upload |
yes | Upload this device's key packages |
GET keypackages/count |
yes | How many are left |
POST keypackages/claim |
yes | Claim one key package per device of an identity |
POST invites/create |
yes | Make an invite code |
POST invites/revoke |
yes | Revoke an invite code |
GET invites |
yes | List your invite codes |
POST contacts/request |
yes | Send a contact request |
POST contacts/accept |
yes | Accept an incoming request |
POST contacts/decline |
yes | Decline an incoming request |
POST contacts/cancel |
yes | Withdraw an outgoing request |
POST contacts/remove |
yes | Remove a contact |
POST contacts/block, POST contacts/unblock |
yes | Block or unblock an identity |
GET contacts |
yes | List contacts and pending requests |
POST groups/create |
yes | Register a new group with its hub |
POST groups/epoch-key |
yes | Register an epoch key after a reg commit |
POST groups/register, POST groups/unregister |
yes | Add or remove a group from your allowlist |
GET groups/{group}/commits |
yes | Fetch commits you missed |
GET servers/{name} |
yes | Fetch another server's document |
POST send |
yes | Submit deliveries |
GET inbox |
yes | Fetch inbox items |
POST inbox/ack |
yes | Mark items processed |
GET stream |
first frame | Live inbox items and presence (WebSocket) |
POST link/proof |
no | Leave a link proof from a new device |
GET link/proof/{device} |
yes | Fetch a new device's link proof |
POST recover |
no | Recover an identity with the recovery key |
GET account/keys |
yes | Fetch the wrapped copies of the account key |
POST account/keys |
yes | Store or remove a wrapped copy |
GET account/data |
yes | Fetch the encrypted account data |
POST account/data |
yes | Replace the encrypted account data |
POST backup/put |
yes | Upload a backup segment |
GET backup/list |
yes | List the stored segments |
GET backup/get |
yes | Fetch one segment |
POST presence |
yes | Set your presence state and status message |
POST profile |
yes | Publish your public profile |
GET profile/{id} |
yes | Fetch anyone's public profile |
POST media/upload |
yes | Upload an encrypted file |
POST media/upload-public |
yes | Upload a profile picture |
GET media/{server}/{id} |
yes | Download a file, from any server |
GET turn |
yes | Get credentials for this homeserver's call relay |
POST report |
yes | Report messages to your homeserver |
Read the API conventions first. It covers encoding, base URLs, authentication, errors, pagination, and WebSockets, and the default consequence when a request breaks a MUST. Section references (§) are to the Tieroute specification.
Paths are relative to the client API base, the client URL in the server document's apis entry for version 1 (§8.1). "Auth" says whether the endpoint needs Authorization: Bearer. Each endpoint has its own section below, with its request and response schemas, behavior, and errors. Schemas use the types from §4.2: identifier, device-key, b64, b64-32, hash, msg-id, group-id, nonce, timestamp, and signed.
A client doesn't need to know whether someone is local or remote. When an endpoint names an identity, group hub, or file on another server, the homeserver makes the S2S call on the client's behalf (Server-to-server API).
Also served, unauthenticated, at fixed paths (§5):
GET https://{name}/.well-known/tieroute/server: the server document (§5.1), including itshandles,registration, andinvitemembers.GET https://{handle}/.well-known/tieroute/handle: the handle document, on each subdomain handle the homeserver gives out (§5.3). Its schema is in Server-to-server API.
A homeserver with "handles": "chat.example" gives @kat.chat.example to a user who asks for it, and serves https://kat.chat.example/.well-known/tieroute/handle for them. That needs wildcard DNS and a TLS certificate for each subdomain.
Identities and sessions
Register an identity
POST register registers a new identity on this homeserver, or moves an existing one here.
register-req = {
( "genesis": signed // ; a chain-entry with seq 0 (§6.2), t = "chain-entry"
"chain": [+ signed] ), ; or, when moving here (§6.6): the whole chain, whose newest home is this server
? "move": signed, ; with "chain": a `move` (t = "move", §6.6) signed by a current device, carrying the allowlists
? "invite": tstr, ; the registration invite code; required when "registration" is "invite"
? "check": bool, ; with "chain": only ask whether this server would take the move
}
register-res = {
"id": identifier,
}
Registration is by invite (§8.2). A server whose registration is invite takes one or more registration codes it hands out, or publishes in its server document's invite member for open signups. How many codes a server has, and whether they're limited by use count or expiry, is the server's business.
The server MUST check that:
inviteis a valid registration code (403 bad-invite), or the server'sregistrationisclosed(403 registration-closed)- the genesis entry is valid (§6.4 rule 1)
- its
tsis within 10 minutes of the server's clock, in both directions (§6.4 rule 5) - its
set-homenames this server - its
set-handleis either under this server'shandlesdomain, free, and not reserved (§5.4), or (if the server allows it) a domain whose handle record already points to the resulting identifier (§5.3)
Registration never asks for an email address (§8.2).
Moving here (§6.6). With chain, the server checks the whole chain (§6.4), that its newest set-home names this server, the handle as above, and that a device-signed move away from the previous home is one that home appended: it asks the previous home (S2S identity/fetch), as on a first fetch (Server-to-server API). A recovery-signed move counts from anywhere. It also checks the signed move: signed by a current device of the identity, naming it, this server, and the hash of the chain's newest entry, with ts within 10 minutes (400 bad-signature, bad-request, or clock-skew otherwise). The chain is public, so without it anyone who had seen the move could register it first, with allowlists of their own. It stores the chain, takes the move's contacts and groups into the user's allowlists, and pushes the chain to contacts' servers. Registering the same chain again with a valid move succeeds, so a client whose reply was lost can retry. The client appends the move on the old home first (after asking with check, below); that home then stops delivering to the identity and keeps its chain as a tombstone. Its other users' deliveries to the identity route to the new home, like any remote recipient; deliveries other servers send it for the identity are dropped, and senders learn the new home from the chain push (§6.6). It keeps the identity's sessions, and still signs its devices in: if a thief made the move, the owner's devices have to see the alarm and recover (§6.4.1).
Asking first, and registering late (§6.6):
With
"check": true, the server registers nothing. It runs every check above on the chain the client is about to have, except asking the previous home about the newest entry, which the client hasn't appended yet, and answers200with theid, or the error the registration would get. A client MUST ask before appending a device-signed move on the old home, so a refusal changes nothing.checkwithoutchainis400 bad-request.The newest entry's
tshas to be within 10 minutes (400 clock-skew) unless it's a device-signed move the previous home confirms it appended: the previous home checked its clock then, and the registration may come later, after a lost reply or the app closing in between. The signedmoveis what has to be recent.Doesn't sign you in. Follow with
auth/challengeandauth/session. Genesis entries are public, so a token here would let anyone who has seen a genesis entry get a session.Idempotent: registering the same genesis again returns the same
id.Errors:
bad-invite,registration-closedinvalid-chain,clock-skew,bad-signaturehandle-taken: the handle belongs to someone else, or is reservedalready-exists: this identifier is registered here with different bytes
Get a sign-in challenge
POST auth/challenge:
challenge-req = { "id": identifier, "key": device-key }
challenge-res = { "nonce": nonce, "expires": timestamp, ; valid for 60 seconds, single use
? "now": timestamp } ; the server's clock (§8.4)
- Errors:
not-found(unknown identity),not-allowed(keyisn't a current device).
Start a session
POST auth/session:
session-req = { "auth": signed } ; t = "auth", "k" = the device key, body below
auth-body = {
"id": identifier,
"nonce": nonce, ; from auth/challenge
"server": server-name, ; this server, so the signature can't be replayed elsewhere
"ts": timestamp,
? "name": tstr, ; device label for session lists, e.g. "Kat's Laptop"
}
session-res = { "token": tstr, "expires": timestamp }
The server checks the signature, that k is a current device of id (§8.2), that the nonce is unexpired and unused, that server is itself, and that ts is within 10 minutes.
- Errors:
bad-signature,bad-request(serverisn't this server),not-allowed(kisn't a current device),expired(410, the nonce is unknown, used, or expired),clock-skew.
End a session
POST auth/logout: body {}. Revokes the token used. 204.
Identity chains and handles
GET chain/{id} and GET resolve/{handle} work without a session, so a device that isn't signed in yet can find its own identity. Without a session, the server answers only for local identities and handles, and for handles on its own domain or its handle domain, which it may fetch from itself. Other remote lookups, which make the server fetch from other servers, need a session.
Fetch a chain
GET chain/{id}:
GET chain/tr:7f3ka7qmx2vbn4rtd6wycs5e?after=<seq>&home=<server>&fresh=1
chain-res = {
"entries": [* signed], ; chain entries with seq > after (all of them if after is absent), in order
"head": uint, ; seq of the current head
}
homeis the identity's homeserver. Use thehomethatresolvereturned. For an identity not cached here, the homeserver fetches the chain fromhomewith S2Sidentity/fetch(Server-to-server API).fresh=1asks the homeserver to fetch the chain from the identity's home again before answering, instead of answering from its cache. Clients use it before rejecting a sequenced commit on the strength of a chain (§7.1). What comes back is still accepted only under §6.5. It needs a session (401 unauthenticated), and is never answered from the cache: if the homeserver can't ask the home, it answers503 unavailable(unreachable, or the home's chain didn't pass §6.5),404 not-found(it doesn't know where the identity lives), or429 rate-limited, and the client doesn't decide (§7.1). Homeservers rate-limit it.- Errors:
not-found.
Append to your chain
POST chain/append appends an entry to your own chain (the token's identity).
append-req = { "entry": signed } ; t = "chain-entry"
append-res = { "seq": uint, "head": hash } ; head = SHA-256(JCS(signed)) of the appended entry (§4.3)
The server applies every rule in §6.4, including the past and future clock checks.
prevMUST be the current head. If it isn't, the server returnsstale-head, and the client re-fetches and retries.- Recovery-signed entries are accepted here too, from a signed-in device. A user who has lost every device uses
POST recoverinstead (see Recovery). - Side effects:
- A device-signed
set-homeorremove-devicetriggers achain-alertnotice to all of the user's devices:alarm, ornoticefor a self-rotation (§6.4.1). - A device-signed
add-devicetriggers achain-alertat levelnotice(§6.4.1), so a device added through a phished approval doesn't go unnoticed. - A removed device's tokens are revoked immediately (§7.6).
- The homeserver pushes the new chain to the user's contacts (see Chain push).
- A device-signed
- Idempotent: appending the entry that's already the head returns the same response.
- Errors:
invalid-chain,bad-signature,clock-skew,stale-head,unknown-kind(an unknown chain op).
Chain push
When a local identity's chain changes (through chain/append or recover), its homeserver tells everyone who needs to know:
- Local contacts get a
chain-updatenotice in their inbox (chain-update-notice, under Fetch the inbox). - Each remote server that is home to at least one contact gets one S2S
identity/pushcall through the durable outbox (Server-to-server API). That server sendschain-updateto its own users who have the identity as a contact.
A client that gets chain-update refetches the chain and runs the sync rule for that identity (see Devices and linking).
Client chain copies
Clients keep their own copy of every chain they fetch, as §6.5 asks, and apply the same comparison to whatever their homeserver serves: they take it if it extends their copy or is a valid recovery fork, keep their copy if it's a prefix (an out-of-date answer), and refuse anything else. A refusal is shown to the user as a warning that someone may be trying to slip a device into that person's account. This protects against a dishonest homeserver, which could otherwise serve each client a different chain.
Resolve a handle
GET resolve/{handle}. Clients resolve handles through their own homeserver (§5.3). The homeserver looks up the HTTPS handle document, https://{handle}/.well-known/tieroute/handle, or, when there's no document, the DNS TXT record _tieroute.{handle}. It then fetches the chain, and checks that the chain names the handle. Handles on other domains are resolved the same way.
resolve-res = { "id": identifier, "home": server-name }
The client MUST still fetch the chain and check that its current handle equals the one it asked for (§5.3). This endpoint isn't a trusted answer. If the chain doesn't name the handle, the client treats the handle as unresolved. Errors: not-found.
Key packages
Key packages are Messaging Layer Security (MLS) KeyPackage objects, carried as base64url bytes. The server never parses them (§2.3), so validating them is the claiming client's job.
Upload key packages
POST keypackages/upload uploads key packages for the token's device.
kp-upload-req = {
"packages": [* b64], ; one-time key packages
? "last-resort": b64, ; replaces this device's last-resort package (§11.4)
? "replace": bool, ; true: delete this device's unclaimed one-time packages first
}
kp-upload-res = { "count": uint } ; unclaimed one-time packages now stored for this device
A device SHOULD keep about 50 packages stored, plus a last-resort one (§11.4). Keeping fewer is acceptable for a device that's rarely added to new groups. When a device runs out, claimants get its last-resort package, which is reused across claims (§11.4). replace is for when a device's old packages are no longer usable, for example after a change to the capabilities it advertises. Servers MAY cap storage at 200 per device (413 over-quota).
Count key packages
GET keypackages/count:
kp-count-res = { "count": uint, "last-resort": bool }
Claim key packages
POST keypackages/claim claims one package for each current device of an identity.
kp-claim-req = {
"id": identifier,
? "group": group-id, ; when re-adding a device to a group (§11.9)
? "devices": [+ device-key], ; claim only for these current devices (see the device filter below)
}
kp-claim-res = {
"packages": [* {
"dev": device-key,
"kp": b64,
"last-resort": bool, ; true if the device had no one-time packages left
}],
}
- Allowed (§11.4) if any of these hold:
- the claimant is the same identity (its own other devices). Claiming your own identity's packages is always allowed.
- the claimant is a contact of the target
- the target has a pending outgoing request to the claimant, less than 30 days old (§9.3)
groupis in the target's groups allowlist. These claims MUST be rate-limited per claimant and target, and per origin server and target, because group IDs aren't secret.
- Device filter. Without
devices, the server claims one package per current device. With it, only for the listed devices that are current in the chain. The allow rules are the same either way. The sync rule (see Devices and linking) uses it to add only the devices that are missing from a group. - Each one-time package is handed out once and then deleted. The response leaves out devices that have no packages at all.
- For a remote identity, the homeserver claims through S2S
keypackages/claimon the identity's home, with the caller asclaimant(Server-to-server API). - Errors:
not-allowed,not-found,unavailable(503, a remote identity's home can't be reached).
Invite codes
Every contact request needs an invite code (§9.1). Codes are 10 case-sensitive characters from [A-Za-z0-9]. The homeserver stores only HMAC-SHA256(server-secret, code) (§9.1), so it can show the code once, when it's made, and never again.
Make an invite code
POST invites/create:
invite-create-req = {
? "public": bool, ; true: a code to publish on your profile (§9.1). Default false
? "uses": uint, ; how many requests it may start. Absent or 0: unlimited
? "expires": timestamp, ; absent: never
}
invite-create-res = {
"code": tstr, ; the code itself, shown only now
"hash": b64-32, ; its keyed hash, which names it in invites/revoke and GET invites
}
- Requests that use a public code are held to the per-sender rate limits (§9.5). Private codes are limited by their own
usesinstead. - Errors:
bad-request(expiresis in the past).
The client shares the code as @handle::code (§9.4), for example @kat.chat.example::Kx82mPq1Za, or as a link on the link domain.
Revoke an invite code
POST invites/revoke:
invite-revoke-req = { "code": tstr } / { "hash": b64-32 }
204. Stops new requests with the code. Contacts already made with it stay (§9.1). Errors: not-found, bad-request (neither member given).
List invite codes
GET invites:
invites-res = {
"items": [* {
"hash": b64-32,
"public": bool,
"created": timestamp,
? "uses": uint, ; uses left; absent: unlimited
? "expires": timestamp,
}],
}
Lists your live codes: not revoked, not used up, and not expired. The codes themselves aren't stored, so they aren't listed.
Contacts
Send a contact request
POST contacts/request:
request-req = { "request": signed } ; t = "contact-request", signed by a current device of the token's identity (§9.2)
The signed body is the contact-request from §9.2: from, to, ts, nonce, and invite, the recipient's invite code without the @handle:: part.
Returns 202 {} once the request is well formed, whatever happens after that. A request with a wrong, used-up, or revoked code, one dropped by a rate limit, and one to someone who blocked you all look the same as one that went through (§9.2, §10.3).
- The server checks the signature, that the signer is a current device of
from, and thatfromis the token's identity. Bad requests getbad-signatureorbad-request. - It records a pending outgoing request (
pending-out) forto, which lasts 30 days (§9.3). - For a remote recipient, it then forwards the request with S2S
contacts/request(Server-to-server API). The recipient's chain has to be cached first (fromresolveorchain). - The recipient's homeserver checks the invite code and the rate limits (§9.5). If both pass, it puts a
contact-requestnotice in the recipient's inbox (see Fetch the inbox). - Requests in both directions (§9.2): if
toalready has a pending request tofrom, both become contacts at once, and both get acontact-requestnotice so their clients know. The identity whose identifier sorts first, bytewise, creates the direct group. That avoids creating two groups. - Idempotent: a repeat of the same signed request is a no-op.
Accept a contact request
POST contacts/accept:
accept-req = { "id": identifier } ; who you're accepting
200 {}. It requires a pending incoming request from id, otherwise not-found. It marks id as a contact on your side. Next, your client:
- claims
id's key packages (keypackages/claim) - creates the direct group (
groups/create) - sends the commit and the Welcome (
send)
The requester's homeserver promotes you to a full contact when the Welcome arrives (§9.3).
Decline a contact request
POST contacts/decline:
decline-req = { "decline": signed } ; t = "contact-decline" (§9.3)
202 {}. It clears the pending request and puts a contact-declined notice in the requester's inbox. For a requester on another server, the homeserver passes the decline on with S2S contacts/decline (Server-to-server API), and the requester's homeserver does this. After that, the requester can't send another request to this identity for 30 days, even with a new code (§9.3).
Cancel a contact request
POST contacts/cancel: { "id": identifier } → 204. Withdraws your pending outgoing request to id.
Remove a contact
POST contacts/remove: { "id": identifier } → 204. Removes id from your contacts allowlist (§9.6). Your client also leaves the direct group.
Block or unblock an identity
POST contacts/block and POST contacts/unblock: { "id": identifier } → 204. Blocking also removes the contact, and drops every future request and Welcome from id, whatever code it carries, silently (§9.6).
List contacts
GET contacts:
contacts-res = {
"items": [* contact],
? "next": tstr,
}
contact = {
"id": identifier,
"state": "contact" / "pending-in" / "pending-out" / "blocked",
"since": timestamp,
? "request": signed, ; for pending-in: the original request
}
Paginated (see the API conventions).
Groups
A group's hub is named in its tieroute_group extension (§11.2, §11.6). Every group starts with its creator's homeserver as hub (§11.5), and the hub sequences its commits (§11.6). A member often needs to commit in a group whose hub is someone else's server, for example when another member links a new device. The endpoints below, and POST send, take an optional hub. When it names another server, the homeserver forwards the call to that hub over S2S (Server-to-server API) and passes the hub's answer back.
Groups created before the tieroute_group extension existed have no known hub. Re-create them (§11.11).
Create a group
POST groups/create registers a new group with its hub, and adds it to your groups allowlist.
group-create-req = {
"group": group-id,
"kind": "direct" / "group",
"epoch-key": b64-32, ; public half of epoch_key_0 (§11.6), or of epoch_key_N after a handoff
? "epoch": uint, ; after a hub handoff: the epoch the new hub starts at. Default 0
? "hub": server-name, ; the group's hub, when it isn't this homeserver (§11.11)
}
204. Errors: already-exists (that group ID is registered). The client discards the group and starts again with a new random ID (§11.5).
A hub handoff (§11.11) uses the same endpoint. With hub naming another server, the homeserver registers the group there with S2S group/register (Server-to-server API), and passes that hub's error back unchanged: 403 not-allowed if the new hub doesn't take groups from this server, 409 already-exists, or 503 unavailable if it can't be reached.
Register an epoch key
POST groups/epoch-key registers the key for the epoch a reg commit created (§11.6). Clients whose MLS library can derive next before the commit is accepted never call it.
epoch-key-req = {
"key": signed, ; t = "epoch-key", signed with the one-time `reg` key (§11.6)
? "hub": server-name, ; the group's hub, when it isn't this homeserver
}
204. When hub names another server, the homeserver forwards key with S2S group/epoch-key (Server-to-server API) and passes the hub's error back unchanged. The hub takes the key only if the group is awaiting one at that epoch and k is the stored reg. The same registration again, after it took effect, also gets 204, so a client whose reply was lost can retry; 409 stale-epoch if it doesn't match what's registered. Errors: bad-signature, bad-request, not-found, unavailable (the hub couldn't be reached).
Register or unregister a group
POST groups/register and POST groups/unregister: { "group": group-id } → 204. Adds a group to your groups allowlist after your device processes a Welcome, or removes it when you leave (§10.3). The homeserver already allowlisted the group when it put the Welcome in your inbox, so messages sent before your device processes it arrive; registering again is harmless.
Fetch missed commits
GET groups/{group}/commits, with the group ID in base64url:
GET groups/<group id>/commits?after=<epoch>&hub=<server-name>
commits-res = {
"items": [* { "epoch": uint, "data": b64, "receipt": signed }], ; in epoch order, epochs > after; epoch is the one each commit was made in
}
A device at epoch N needs commit N first, so it asks with after=N-1. hub is the group's hub, from its tieroute_group extension (§11.2). When it's another server, the homeserver fetches the commits from it with S2S commit/fetch (Server-to-server API) and passes its answer or error back. Without hub, the homeserver answers only for groups it's hub for itself.
Commits the hub has kept (30 days, §11.6). Errors: expired if the hub no longer has epoch after + 1, so the device has fallen too far behind (§11.9); not-allowed if the group isn't in your allowlist; not-found if the hub doesn't know the group; unavailable if a remote hub can't be reached.
Fetch another server's document
GET servers/{name}:
; response: the named server's document, as served at /.well-known/tieroute/server
Lets a client check a receipt signed by a remote hub without fetching from that server itself (which would reveal the client's address to it). The homeserver fetches and caches the document; 404 not-found if it can't.
Sending and receiving
Send deliveries
POST send submits one or more deliveries (§10.1): kind "mls", or kind "link" for a link bundle to your own new device (see Devices and linking).
send-req = { "deliveries": [+ outgoing] }
outgoing = {
"id": msg-id, ; client-chosen; the server deduplicates on it
"kind": "mls",
"to": [+ identifier], ; every member identity of the group, including yourself (your other devices)
"group": group-id,
"epoch": uint,
"mls": mls-kind,
? "ephemeral": bool, ; typing indicators and receipts (§13.3)
"data": b64, ; MLSMessage bytes (PrivateMessage) or a Welcome
? "commit": signed, ; required when mls = "commit": the commit-request (§11.6)
? "hub": server-name, ; the group's hub, when it isn't this homeserver
? "welcomes": [* welcome-out], ; with a commit: Welcomes to send only if the hub accepts it
}
welcome-out = {
"id": msg-id,
"to": [+ identifier], ; the new members
"data": b64, ; MLS Welcome bytes
}
send-res = { "results": [+ send-result] } ; one per outgoing, in order
send-result = {
"id": msg-id,
"status": "sent" / "rejected",
? "error": tstr, ; for "rejected": an error code
? "receipt": signed, ; for an accepted commit: the hub's commit-receipt (§11.6)
}
- The server fills in
fromfrom the token. Clients don't send it. sentmeans the server has taken responsibility for delivery: stored in local inboxes now, or queued in its outbox for other servers. Allowlist drops are silent (§10.3), so they also saysent.- Recipients on other servers get the delivery through the outbox, with
tonarrowed to each destination server's recipients (Server-to-server API). Recipients this server has never fetched a chain for are dropped. - A delivery that another server refuses, or that hasn't succeeded 72 hours after it was queued, is dead. The homeserver tells the sending identity's devices with a
delivery-failednotice (§10.5), withreasonset to the refusal's error code, orexpired. - Commits (
"mls": "commit"):- The server checks that
commitis a validcommit-requestwhosegroupandepochmatch, whosehashisSHA-256of the decodeddata, and which carries exactly one ofnextandreg. - It runs the hub step (§11.6). With
hubset to another server, it forwards the commit to that hub with S2Scommit(Server-to-server API) instead. - If the hub accepts, the result is
sentwith thereceipt. The commit is delivered with the receipt in itsreceiptmember, and then every Welcome. - If not, the result is
rejected, and nothing is delivered. The error isstale-epoch(another commit won this epoch; resubmitting the identical commit-request the hub accepted isn't one of these, and gets the original receipt, §11.6),awaiting-key(the hub is waiting for the key from aregcommit, so retry shortly),bad-signature(kisn't the hub's key for this epoch),not-found(the hub doesn't know the group), orbad-request. Errors from a remote hub come back in the per-item result unchanged; an unreachable hub isunavailable.
- The server checks that
welcomedeliveries can also be sent on their own, as"mls": "welcome"with nocommit.- Idempotent per
id. Resubmitting anidreturns the original result and doesn't deliver twice. - A
"link"delivery is{ "id": msg-id, "kind": "link", "to": [your identifier], "data": b64 }, at most 256 KiB ofdata, and goes only to your own inbox (§7.2, §10.1). Anything else isbad-request. In the inbox it has nogroup,epoch, ormls. - Errors for the whole request (
400 bad-request): a malformed body, or no deliveries. Per-delivery problems go in eachsend-result:bad-request,unknown-kind(akindormlsvalue the server doesn't know),not-allowed,stale-epoch,awaiting-key,too-large,unavailable.
Fetch the inbox
GET inbox:
GET inbox?after=<cursor>&limit=<n>
inbox-res = {
"items": [* inbox-item],
? "next": tstr,
"gap": bool, ; true if items expired before this device acked them (§10.4, §11.9)
}
inbox-item = {
? "cursor": tstr, ; absent only for ephemeral items on the stream, which are never stored
"ts": timestamp, ; when the server received it
inbox-payload,
}
inbox-payload = ( "delivery": delivery // "notice": notice )
notice = contact-request-notice
/ contact-declined-notice
/ chain-alert-notice
/ chain-update-notice
/ delivery-failed-notice
contact-request-notice = { "t": "contact-request", "request": signed, "chain": [+ signed] } ; the requester's chain, so the client can verify
contact-declined-notice = { "t": "contact-declined", "decline": signed }
chain-alert-notice = { "t": "chain-alert", "level": "alarm" / "notice", "entry": signed } ; §6.4.1
chain-update-notice = { "t": "chain-update", "id": identifier } ; see Chain push
delivery-failed-notice = { ; §10.5
"t": "delivery-failed",
"id": msg-id,
"to": [+ identifier], ; the recipients it didn't reach
"reason": tstr, ; an error code: "expired" after 72 hours, "wrong-home" (§10.2), or the refusal's code
}
Items come from other users (delivery, the delivery object from §10.1, with from filled in) or from the homeserver itself (notice). The inbox is per user, and each device keeps its own position by acking. Clients ignore notices whose t they don't know (§19.2).
Acknowledge inbox items
POST inbox/ack:
ack-req = { "upto": tstr } ; the cursor of the last item this device has processed
204. An item is deleted once every current device has acked past it, or after 30 days (§10.4).
Open the live stream
GET stream is a WebSocket (see the API conventions) that pushes inbox items as they arrive, and contacts' presence.
Client → server:
stream-auth = { "t": "auth", "token": tstr, ? "after": tstr } ; must be the first frame; "after" replays stored items after that cursor
stream-ack = { "t": "ack", "upto": tstr } ; same as POST inbox/ack
Server → client:
stream-ready = { "t": "ready", "gap": bool } ; after a successful auth
stream-item = { "t": "item", "item": inbox-item }
Plus presence frames (stream-presence, see Presence and public profiles), and ping, pong, and error from the conventions.
- After
ready, the server sends any stored items afterafterin order, then new items live. - Ephemeral deliveries (
"ephemeral": true) go only to devices with an open stream, and arrive without acursor. Nothing to ack. - If the device is removed from the chain, the server closes the stream with 4403.
Devices and linking
An identity can have more than one device. Adding one takes both devices at hand (§7.2), and every device of every member then joins every shared group, across federated homeservers.
Sign in with another device
- New device. The user enters their handle (and homeserver); the device needs the identifier to sign in once it's added. It generates an Ed25519 device key and a one-time HPKE key pair (§3), finds the homeserver's client base from its server document (§8.1), and resolves the handle (
GET resolve/{handle}, no session needed for local handles). It parks the HPKE public key with a link proof that has no code (POST link/proof), and shows a link code, as a QR code and as text. - Existing device. The user scans the QR code, or enters the code. The device fetches the parked key (
GET link/proof/{device}) and checks it against the hash in the code. If the code was scanned in person, go to step 4: it needs no confirmation code. If it was typed, the device makes a random confirmation code (XXXX-XXXX, 40 bits of Crockford base32, as in §6.7) and shows it. Nothing changes yet. - New device. The user types the confirmation code. The device signs a link proof with it and leaves it on the homeserver (
POST link/proof). - Existing device. It polls
GET link/proof/{device}. When the proof is there, it checks that it's signed by the device key from the link code, names this identity, carries the HPKE key the code's hash names, and (for a typed code) carries the confirmation code it showed. Only then does it append a chain entry with oneadd-deviceop (POST chain/append), and send the link bundle (below). A wrong code is reported, and the existing device keeps waiting. - New device. It tries to sign in (
POST auth/challenge, thenPOST auth/session) every couple of seconds.auth/sessionanswers403 not-alloweduntil the key is in the chain. Then it uploads key packages, readsGET contacts, opens the link bundle from its inbox, and waits for Welcomes. - Everyone. The chain change reaches the user's contacts as a
chain-updatenotice (see Chain push). Their clients, and the user's own devices, add the new device to every shared group (see The sync rule), and each chat shows a system line.
Why two codes. With only the link code, anyone who persuades a user to enter a code gets a device added to that user's identity, and the sync rule then puts that device in every chat. Attackers have phished messaging users with exactly this kind of fake "linked device" code. The confirmation code means approving needs both devices at hand: the code from the new device goes into the old one, and the code from the old one goes into the new one. Someone who can trick the user into revealing both codes can still get in, so clients MUST also:
- say next to the link-code field: only enter a code shown on a device you're holding;
- say next to the confirmation code: never read it to anyone or paste it into a website;
- alert the identity's other devices when a device is added (
chain-alertat levelnotice, §6.4.1), and show a system line in every chat (see The sync rule).
A client that leaves these out exposes its users to the phishing attack above.
Link code
link-xxxx-xxxx-…-xxxx@chat.example
link-, then 64 bytes in RFC 4648 base32, lowercase, no padding (103 characters), in groups of four joined by -, then @ and the homeserver's name. The 64 bytes are the device public key, then the SHA-256 of the one-time HPKE public key. Parsers MUST ignore case, accept link (space) as the prefix, strip the prefix at most once, split off @home at the last @, and ignore any non-alphanumeric characters in the rest. An approving device MUST refuse a code whose home isn't its own homeserver.
A short form carries only the 32-byte device key (52 characters) and no home. Devices that predate link bundles show it. Approving devices still accept it, and send no bundle; that device starts with empty chats. spec/vectors/link.json has a worked example of it.
Confirmation code comparison is case-insensitive and ignores dashes and spaces; O reads as 0, and I and L read as 1.
Leave a link proof
POST link/proof. No session: the new device isn't signed in yet.
link-proof-req = { "proof": signed } ; t = "link-proof", k = the new device's key
link-proof = { ; the body of that signed object
"id": identifier, ; the account being joined
? "code": tstr, ; the confirmation code, normalized: uppercase, no dashes
? "hpke": b64, ; the one-time HPKE public key, 1,216 bytes (§3, §7.2)
"ts": timestamp,
} ; at least one of code and hpke
The server checks the signature against the key in k, that id is a local identity, that the key isn't already a device on it (409 already-exists), and that ts is within 10 minutes (400 clock-skew). It keeps the latest proof per (id, device) for 10 minutes, and MAY keep it in memory only. A proof without a code only parks the key: it doesn't replace a waiting proof that has one. It MUST allow at most five proofs with a code per (id, device), then answer 429 rate-limited, so the 40-bit code can't be guessed. An hpke that isn't 1,216 bytes is 400 bad-request. Answers 202.
Fetch a link proof
GET link/proof/{device}. Session required. {device} is the new device's key in base64url. Returns { "proof": signed } if a proof for the caller's own identity and that device is waiting, otherwise 404 not-found. Only the identity's own devices can see its proofs.
The link bundle
The approving device sends the link bundle (§7.2) as a "link" delivery to its own identity (POST send). data is the HPKE encapsulation (1,120 bytes), then the ciphertext of JCS(link-bundle), sealed to the parked key in base mode with info = "tieroute link bundle" and the identifier as associated data. History goes in it as encrypted files (POST media/upload, see Files), in the JSON Lines format of §7.3.
The new device opens it after signing in, and only then has the account key. Every other device of the identity gets the same delivery, can't open it, and ignores it.
The sync rule
For every group a client shares with an identity whose tieroute_group extension is present (§11.2):
- List the identity's leaves in the group, and its current devices from the chain.
- Removes first. For any leaf whose device is no longer in the chain, commit a Remove through the hub. Until that commit lands, the removed device can still decrypt new messages: ending its sessions and refusing it deliveries helps, but isn't encryption.
- Then adds. For any device with no leaf, claim key packages for only those devices (
devicesfilter onPOST keypackages/claim) and commit Adds through the hub. Send the Welcome to that identity. - If a commit gets
409 stale-epoch, someone else got there first. Process their commit and check again.
Any member that notices a device change commits the Adds and Removes right away; the hub's first-commit-wins rule drops duplicates. Clients run this for their own identity too, which is how a user's existing devices add a newly linked one and remove a removed one. A device MUST NOT run it for its own identity once its own key is no longer in the chain. A device that hasn't uploaded key packages yet is retried later.
System lines. Whenever a commit changes the devices of an identity in a chat, clients SHOULD show a line in that chat, the way other messengers show a safety-number change: "@kat has a new device", "A device of @kat's was removed", or for the user's own identity "A new device was added to your account". A client with no chat view, such as a bot, MAY skip them. Without them, the other members can't see that a new device can now read the chat. Together with the notice alert on add-device, this makes a device slipped into an identity visible to everyone it can now read, not only the identity's owner. No line is shown when a chat is created or joined.
The sync rule skips groups with no known hub (see Groups).
Recovery
For a user who has lost every device (§6.1, §6.3, §17.3).
Recover an identity
POST recover. No session: the recovery key's signature is the proof.
recover-req = { "entry": signed } ; a chain-entry, signed by the recovery key
recover-res = { "seq": uint, "head": hash }
The client:
- Reads the identity's chain (
GET chain/{id}) and counts the entries already signed by a recovery key; call thatn. - Derives recovery key
n + 1from the 12 words (§6.3) and checks thatSHA-256of its public key equals the chain's currentset-recoverycommitment. If not, the words are wrong. It stops here, without asking the server. - Signs one entry with that key, holding
add-devicefor a fresh device key,remove-devicefor every existing device (recovery means they're lost, or one was stolen), andset-recoverywith the commitment to keyn + 2. - Sends it to
POST recover, then signs in with the new device key as usual.
Where the entry attaches. Normally on top of the head. But if a device-signed entry moved the identity (set-home) recently enough for the fork rule (§6.4.1), the client attaches the entry right before that move instead, so it forks the chain and undoes the move and everything after it. A client can leave some margin under the 72-hour limit for clock differences. Other recent device changes don't need a fork: removing every old device already undoes them.
The server MUST accept only entries signed by the recovery key; device-signed entries go through chain/append (403 not-allowed). An entry on top of the head is validated exactly as chain/append does (400 invalid-chain, 400 clock-skew, 409 stale-head, 404 not-found). An entry with a lower seq is a fork: the server MUST apply the fork rule (§6.4.1) against its stored chain, and on success replace the overridden entries with it (otherwise 400 invalid-chain). Either way, the server ends the sessions of every device that's no longer in the chain, sends the owner's devices a chain-alert notice at level alarm, and pushes the chain (see Chain push).
recover is unauthenticated and cheap to call with garbage. Servers SHOULD rate-limit it per identity and per address. A server MAY leave this to something in front of it, such as a reverse proxy that already limits requests per address. Without any limit, anyone can make the server verify signatures and load chains as fast as they can send requests.
After recovering, the device gets the account key back from its "recovery" wrapped copy (GET account/keys, see Account data and backups).
Account data and backups
These endpoints let a user get back everything they had after losing every device: their contact folders, nicknames, and settings, and optionally their message history. §7.4 and §7.5 fix the data and the cryptography, and this section fixes the endpoints that carry them. Every endpoint here needs a session.
- The account key is a random 16-byte key each identity creates at setup (§7.4). Devices keep it, and new devices get it when they're linked (§7.2).
- A wrapped key is the account key encrypted under another key: the recovery code's
recovery_wrap(§6.1), or a key derived from the backup passphrase (§7.5). - Account data is one encrypted blob: contact folders, nicknames, settings, and the backup device's position (§7.4).
- A segment is one encrypted piece of the message history backup (§7.5).
The homeserver stores all of these as opaque bytes. It never has the account key, so it can't read any of them.
Fetch the wrapped keys
GET account/keys:
account-keys-res = {
"items": [* wrapped-key], ; wrapped-key from §7.4: at most one per "via"
}
A device that has the account key never needs this. It's for a device that got in with the recovery code (POST recover), which unwraps the "recovery" copy, or one restoring a backup with the passphrase, which unwraps the "passphrase" copy.
Store or remove a wrapped key
POST account/keys:
account-keys-req = { "set": wrapped-key } / { "remove": "passphrase" / "recovery-next" }
204.
setstores the copy, replacing any existing copy with the samevia. A device stores the"recovery"copy at setup, and the"passphrase"copy when the user turns on backups or changes the passphrase.removedeletes the passphrase copy, when the user turns backups off. The recovery copy can't be removed: it's how a user who loses every device gets their data back (§7.4).- Recovery-code rotation (§6.3) stages the new code's wrap as
"recovery-next"before appending the entry, then promotes it:setthe"recovery"copy,remove"recovery-next". Recovery wraps carrycommit, the SHA-256 of their code's recovery key 1, which also goes in their associated data (spec/vectors/account.json,staged_wrapped_key). A"recovery"copy withoutcommitis from before this, and still accepted. - Errors:
bad-request(a"passphrase"copy withoutargon2or withcommit, a"recovery-next"copy without a 32-bytecommit, orremovewith anything but"passphrase"or"recovery-next").
The server can't check that a wrapped key is correct. A device that stores a wrong one only hurts its own identity, and only on recovery.
Fetch the account data
GET account/data:
account-data-res = {
"version": uint, ; 0 if it has never been written
? "ct": b64, ; absent if it has never been written
}
ct is the 12-byte nonce, then the AES-128-GCM output, encrypting the JSON of account-data (§7.4) under the account key.
Replace the account data
POST account/data:
account-data-req = {
"ct": b64,
"replaces": uint, ; the version this write is based on
}
account-data-put-res = { "version": uint } ; replaces + 1
- The server accepts the write only if
replacesis the current version. Otherwise it answers409 stale-version: another device wrote first. The client fetches the new version, merges its change into it, and tries again (§7.4). - Account data is at most 256 KiB of
ct(413 too-large), and at least 28 bytes, a nonce and a tag (400 bad-request). - Idempotent: a retry of a write that succeeded gets
409 stale-version, and the client finds its own change already in the current version when it merges.
The associated data binds the blob to its identity and version: JCS(["tieroute account data", identifier, version]), where version is replaces + 1 (§7.4). A client that reads a blob checks it against the version GET account/data returned, so a server can't relabel an old blob as new. Wrapped keys are bound the same way, with JCS(["tieroute account key", identifier, via]), plus commit at the end when there is one. spec/vectors/account.json has both.
Message history backup
Backups are opt-in (§7.5). Only the identity's backup device, named in its account data, uploads segments, so the server never sees two writers.
Upload a segment
POST backup/put:
backup-put-req = { "seq": uint, "ct": b64, ? "snapshot": true } ; from §7.5
204.
ctis the 12-byte nonce, then the AES-128-GCM output, underbackup-keywith the associated data from §7.5. A segment's plaintext is at most 1 MiB, soctis at most 1 MiB + 28 bytes. This endpoint takes bodies up to 2 MiB (API conventions).seqMUST be higher than every stored segment'sseq(409 stale-versionotherwise), except for a retry: the sameseqwith the samectis accepted again, and the sameseqwith a differentctis409 already-exists.- With
snapshot, the server stores the segment, then deletes every segment with a lowerseq(§7.5). - Errors:
over-quota(413, the user's backup storage is full: compact with a snapshot, or tell the user),too-large.
List segments
GET backup/list:
backup-list-res = { "segments": [* { "seq": uint, "size": uint, ? "snapshot": true }] } ; from §7.5, in seq order
size is the length of ct in bytes. The list isn't paginated: compaction keeps it short.
Fetch a segment
GET backup/get?seq=<n>:
backup-get-res = { "seq": uint, "ct": b64, ? "snapshot": true }
Errors: not-found.
Restoring
GET account/keys, and unwrap the account key: with the recovery code after losing every device, or with the passphrase.GET account/data, and decrypt it. It names the newestseqthe backup device recorded.GET backup/list, then fetch the newest snapshot and every segment after it withGET backup/get.- Decrypt each segment, and import its
history-itemlines (§7.3), verifying every message's signature.
If the list stops short of the seq the account data recorded, the client MUST warn the user that the server lost or withheld part of the backup (§7.5). Without the warning, a server could silently truncate someone's history.
Presence and public profiles
§13 fixes the objects and the rules; this section fixes the endpoints that carry them. Every endpoint here needs a session.
Presence
Presence is a state (available, online, away, busy, or offline) and an optional status message. It isn't end-to-end encrypted (E2EE): homeservers see it (§13.2). It goes only to mutual contacts: identities that are each in the other's contact list.
Set your presence
POST presence:
presence-req = {
"state": presence-state, ; §13.2
? "status": tstr, ; at most 128 characters; absent clears it
}
204.
- The homeserver records it for the token's identity, and sends it to every mutual contact: on the stream for local ones (below), and through S2S
presencefor the rest (Server-to-server API). - A client sets
offlineto appear offline, and the homeserver then stops sharing presence until the state changes (§13.2). When every device's stream has been closed for 5 minutes, the homeserver MAY sendofflineon the identity's behalf. A server that doesn't is acceptable; its users' contacts then see the last state they set. - Errors:
bad-request(an unknown state, or a status over 128 characters).
Receive presence
Presence arrives on the WebSocket stream (GET stream), never in the inbox: it's only worth knowing while it's current.
stream-presence = {
"t": "presence",
"id": identifier,
"state": presence-state,
? "status": tstr,
"pv": uint, ; the identity's current profile version (§13.1)
"ts": timestamp,
}
Right after ready, the server sends one presence frame for each mutual contact whose presence it knows, so a client starts with a full picture. When pv is higher than the version the client has, it fetches the new profile (GET profile/{id}). Clients that poll GET inbox instead of holding a stream don't get presence.
Public profiles
A profile is a signed object of type "profile", signed by a current device of the identity (§13.1). Anyone can see it, including people who haven't added you.
Publish your profile
POST profile:
profile-req = { "profile": signed } ; t = "profile", body is `profile` from §13.1
204. The homeserver checks that:
- the signature verifies, and the signer is a current device of
id, which is the token's identity (bad-signature,not-allowed) vis higher than the stored profile'sv(409 stale-version)nameis at most 64 characters,bioat most 500, andlinksat most fivehttps://URLs (bad-request)avatar, if present, names a file uploaded withPOST media/upload-publicon this server (bad-request)invite, if present, is one of the identity's live public invite codes (bad-request)
The new v reaches contacts as pv in the next presence update.
Fetch a profile
GET profile/{id}:
profile-res = { "profile": signed }
For an identity on another server, the homeserver fetches it with S2S profile/fetch from the identity's home (Server-to-server API), and MAY cache it for up to an hour. Errors: not-found (no such identity, or it hasn't published a profile).
A homeserver that caches a remote profile MUST drop its copy when a presence update for that identity carries a pv higher than the copy's v. Contacts' clients fetch the new profile when they see the higher pv (above), and without this they'd get the old copy back until the cache expires.
The client MUST verify the signature, and that the signer is a current device of id in the chain it holds (§13.1). A homeserver relays profiles but can't change them.
Files
Files are encrypted on the client before upload, at most 64 MiB, and kept for 30 days (§14). Profile pictures are the one exception: they're public, unencrypted, and at most 1 MB (§13.1). Every endpoint here needs a session.
Upload a file
POST media/upload, with the encrypted bytes as the body and Content-Type: application/octet-stream:
media-upload-res = {
"id": b64-16, ; random, chosen by the server
"sha256": hash, ; of the bytes received
"size": uint, ; bytes received
}
- The client checks that
sha256matches what it encrypted, then putsidin themedia-refit sends inside the message (§14). The homeserver never sees the key. - Errors:
too-large(413, over 64 MiB, which is 67,108,864 bytes plus the 16-byte authentication tag),over-quota(413),bad-content-type(415).
Upload a profile picture
POST media/upload-public, with the image as the body and its type as the Content-Type: image/png, image/jpeg, image/webp, or image/gif.
media-upload-public-res = { "media": public-media } ; §13.1
The server SHOULD check that the bytes really are an image of that type (400 bad-request if they aren't), and MAY re-encode it to strip metadata. A server that does neither serves whatever was uploaded, which is the uploader's own risk. Public media isn't deleted after 30 days while a current profile names it. Errors: too-large (413, over 1,000,000 bytes), bad-content-type (415, any other type).
Download a file
GET media/{server}/{id}, with {id} in base64url:
- The response is the raw bytes, with
Content-Type: application/octet-stream, or the image type for public media. - When
{server}is another server, the homeserver fetches the file with S2Smedia/fetch(Server-to-server API), so the other server never learns the client's address (§14). It MAY cache it until it expires. - Errors:
not-found,expired(410, past 30 days),removed(410, deleted after a report, §16.2). Clients show "this file has expired" and "this file was removed" for the two410s.
Media IDs are 128 random bits, and a file is useless without the key in the message that refers to it, so the homeserver serves any file to any signed-in user who names it.
Call relays
Calls are WebRTC between the participants, with signaling inside the chat's encrypted group (§15.2). The one server part is the relay: each homeserver runs a TURN (Traversal Using Relays around NAT) server for its own users, and calls are relayed by default so nobody sees anybody else's IP address (§15.1).
Get relay credentials
GET turn:
turn-res = {
"urls": [+ tstr], ; turn: and turns: URLs, as in an RTCIceServer
"username": tstr,
"credential": tstr,
"ttl": uint, ; seconds the credentials stay valid
}
The credentials are time-limited, in the TURN REST API format that coturn and most other TURN servers accept: the username is the expiry time, a colon, and the identifier, and the credential is a base64 HMAC-SHA1 of the username under a secret the homeserver shares with its TURN server. Clients pass the fields to WebRTC as an RTCIceServer. A client SHOULD fetch new credentials before ttl runs out during a long call. One that doesn't loses the relay when they expire. Errors: not-implemented (501) if this homeserver runs no relay. Its users' calls then can't be relayed, and clients that use relay-only ICE can't connect.
Reports
A report is how a user shows an admin messages they received, with proof the sender sent them (§16.2). Nothing is ever reported without the user choosing exactly what to share.
Report messages
POST report:
report-req = {
"report": report, ; §16.3
? "forward": bool, ; true: also send it to the reported identity's homeserver
}
202 {}.
- The homeserver checks that
reporteris the token's identity (not-allowed), and keeps the report for its admins. - With
forward, it also sends the report with S2Sreportto the reported identity's home, through the outbox (Server-to-server API). A failure to forward isn't reported back. - The homeserver doesn't verify the evidence when it receives a report. Its admin does that, with the reported identity's chain (§16.2,
docs/operators.md).
Example: two people start chatting
This section is informative. Kat and Ravi are both on chat.example:
Kat registers. Her client:
- generates a device key and a recovery code, and builds the genesis entry
POST registerwith the server's registration codePOST auth/challenge, thenPOST auth/session, to get a token- creates the account key, and stores its
"recovery"wrapped copy withPOST account/keys POST keypackages/upload- opens
GET stream, or pollsGET inbox
Ravi does the same.
Ravi makes an invite. His client calls
POST invites/createand shows@ravi.chat.example::Kx82mPq1Za, which he sends to Kat.Kat adds Ravi. Her client splits the code at
::(§9.4), callsGET resolve/ravi.chat.example, thenGET chain/{ravi}(withhomeset to thehomefromresolve), checks the handle, and callsPOST contacts/requestwithinviteset toKx82mPq1Za. Ravi's inbox gets acontact-requestnotice.Ravi accepts. His client:
POST contacts/acceptPOST keypackages/claimfor Kat- creates the MLS group locally, and
POST groups/createwith the public half ofepoch_key_0 POST sendwith the Add commit, itscommit-request, and the Welcome for Kat
Kat joins. Her inbox gets the commit and the Welcome. Her client processes the Welcome, then
POST groups/register.They chat. Each message is
POST sendwith"mls": "app"and a signedapp-messageinside (§12). Each arrives in the other's inbox. Both ack.
If Ravi were on another homeserver, the steps would be the same. Kat's homeserver would fetch his chain and forward her request over S2S, and their deliveries would go through each homeserver's outbox (Server-to-server API).
Implementation status
rerouted, the reference server, implements every endpoint on this page. Where the API leaves a choice to the server, rerouted:
- closes a stream that falls 256 frames behind (code 1001); the client reconnects and replays from its cursor
- limits key package claims that name a group to 10 a day per claimant and target, and 10 a day per origin server and target
- sets presence to
onlinewhen a device opens a stream and the identity has no state yet, or was setofflineby the server rather than by the user; a user who chose to appear offline stays offline - checks each profile picture format's leading bytes, and doesn't re-encode
- caps backup storage at
REROUTED_BACKUP_QUOTAper identity, 1 GiB by default