Skip to content

Draft. This text can change at any time, and there's no version history until the draft is frozen. Comments stay attached to the passage they were made on; if it changes, they move to its section, marked as on an earlier version.

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 its handles, registration, and invite members.
  • 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:

  • invite is a valid registration code (403 bad-invite), or the server's registration is closed (403 registration-closed)
  • the genesis entry is valid (§6.4 rule 1)
  • its ts is within 10 minutes of the server's clock, in both directions (§6.4 rule 5)
  • its set-home names this server
  • its set-handle is either under this server's handles domain, 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 answers 200 with the id, 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. check without chain is 400 bad-request.

  • The newest entry's ts has 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 signed move is what has to be recent.

  • Doesn't sign you in. Follow with auth/challenge and auth/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-closed
    • invalid-chain, clock-skew, bad-signature
    • handle-taken: the handle belongs to someone else, or is reserved
    • already-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 (key isn'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 (server isn't this server), not-allowed (k isn'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
}
  • home is the identity's homeserver. Use the home that resolve returned. For an identity not cached here, the homeserver fetches the chain from home with S2S identity/fetch (Server-to-server API).
  • fresh=1 asks 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 answers 503 unavailable (unreachable, or the home's chain didn't pass §6.5), 404 not-found (it doesn't know where the identity lives), or 429 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.

  • prev MUST be the current head. If it isn't, the server returns stale-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 recover instead (see Recovery).
  • Side effects:
    • A device-signed set-home or remove-device triggers a chain-alert notice to all of the user's devices: alarm, or notice for a self-rotation (§6.4.1).
    • A device-signed add-device triggers a chain-alert at level notice (§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).
  • 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-update notice 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/push call through the durable outbox (Server-to-server API). That server sends chain-update to 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)
    • group is 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/claim on the identity's home, with the caller as claimant (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 uses instead.
  • Errors: bad-request (expires is 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 that from is the token's identity. Bad requests get bad-signature or bad-request.
  • It records a pending outgoing request (pending-out) for to, 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 (from resolve or chain).
  • The recipient's homeserver checks the invite code and the rate limits (§9.5). If both pass, it puts a contact-request notice in the recipient's inbox (see Fetch the inbox).
  • Requests in both directions (§9.2): if to already has a pending request to from, both become contacts at once, and both get a contact-request notice 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:

  1. claims id's key packages (keypackages/claim)
  2. creates the direct group (groups/create)
  3. 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 from from the token. Clients don't send it.
  • sent means 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 say sent.
  • Recipients on other servers get the delivery through the outbox, with to narrowed 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-failed notice (§10.5), with reason set to the refusal's error code, or expired.
  • Commits ("mls": "commit"):
    1. The server checks that commit is a valid commit-request whose group and epoch match, whose hash is SHA-256 of the decoded data, and which carries exactly one of next and reg.
    2. It runs the hub step (§11.6). With hub set to another server, it forwards the commit to that hub with S2S commit (Server-to-server API) instead.
    3. If the hub accepts, the result is sent with the receipt. The commit is delivered with the receipt in its receipt member, and then every Welcome.
    4. If not, the result is rejected, and nothing is delivered. The error is stale-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 a reg commit, so retry shortly), bad-signature (k isn't the hub's key for this epoch), not-found (the hub doesn't know the group), or bad-request. Errors from a remote hub come back in the per-item result unchanged; an unreachable hub is unavailable.
  • welcome deliveries can also be sent on their own, as "mls": "welcome" with no commit.
  • Idempotent per id. Resubmitting an id returns 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 of data, and goes only to your own inbox (§7.2, §10.1). Anything else is bad-request. In the inbox it has no group, epoch, or mls.
  • Errors for the whole request (400 bad-request): a malformed body, or no deliveries. Per-delivery problems go in each send-result: bad-request, unknown-kind (a kind or mls value 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 after after in order, then new items live.
  • Ephemeral deliveries ("ephemeral": true) go only to devices with an open stream, and arrive without a cursor. 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

  1. 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.
  2. 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.
  3. 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).
  4. 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 one add-device op (POST chain/append), and send the link bundle (below). A wrong code is reported, and the existing device keeps waiting.
  5. New device. It tries to sign in (POST auth/challenge, then POST auth/session) every couple of seconds. auth/session answers 403 not-allowed until the key is in the chain. Then it uploads key packages, reads GET contacts, opens the link bundle from its inbox, and waits for Welcomes.
  6. Everyone. The chain change reaches the user's contacts as a chain-update notice (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-alert at level notice, §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-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.

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.

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

  1. List the identity's leaves in the group, and its current devices from the chain.
  2. 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.
  3. Then adds. For any device with no leaf, claim key packages for only those devices (devices filter on POST keypackages/claim) and commit Adds through the hub. Send the Welcome to that identity.
  4. 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:

  1. Reads the identity's chain (GET chain/{id}) and counts the entries already signed by a recovery key; call that n.
  2. Derives recovery key n + 1 from the 12 words (§6.3) and checks that SHA-256 of its public key equals the chain's current set-recovery commitment. If not, the words are wrong. It stops here, without asking the server.
  3. Signs one entry with that key, holding add-device for a fresh device key, remove-device for every existing device (recovery means they're lost, or one was stolen), and set-recovery with the commitment to key n + 2.
  4. 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.

  • set stores the copy, replacing any existing copy with the same via. A device stores the "recovery" copy at setup, and the "passphrase" copy when the user turns on backups or changes the passphrase.
  • remove deletes 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: set the "recovery" copy, remove "recovery-next". Recovery wraps carry commit, 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 without commit is from before this, and still accepted.
  • Errors: bad-request (a "passphrase" copy without argon2 or with commit, a "recovery-next" copy without a 32-byte commit, or remove with 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 replaces is the current version. Otherwise it answers 409 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.

  • ct is the 12-byte nonce, then the AES-128-GCM output, under backup-key with the associated data from §7.5. A segment's plaintext is at most 1 MiB, so ct is at most 1 MiB + 28 bytes. This endpoint takes bodies up to 2 MiB (API conventions).
  • seq MUST be higher than every stored segment's seq (409 stale-version otherwise), except for a retry: the same seq with the same ct is accepted again, and the same seq with a different ct is 409 already-exists.
  • With snapshot, the server stores the segment, then deletes every segment with a lower seq (§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

  1. GET account/keys, and unwrap the account key: with the recovery code after losing every device, or with the passphrase.
  2. GET account/data, and decrypt it. It names the newest seq the backup device recorded.
  3. GET backup/list, then fetch the newest snapshot and every segment after it with GET backup/get.
  4. Decrypt each segment, and import its history-item lines (§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 presence for the rest (Server-to-server API).
  • A client sets offline to 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 send offline on 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)
  • v is higher than the stored profile's v (409 stale-version)
  • name is at most 64 characters, bio at most 500, and links at most five https:// URLs (bad-request)
  • avatar, if present, names a file uploaded with POST media/upload-public on 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 sha256 matches what it encrypted, then puts id in the media-ref it 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 S2S media/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 two 410s.

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 reporter is the token's identity (not-allowed), and keeps the report for its admins.
  • With forward, it also sends the report with S2S report to 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:

  1. Kat registers. Her client:

    1. generates a device key and a recovery code, and builds the genesis entry
    2. POST register with the server's registration code
    3. POST auth/challenge, then POST auth/session, to get a token
    4. creates the account key, and stores its "recovery" wrapped copy with POST account/keys
    5. POST keypackages/upload
    6. opens GET stream, or polls GET inbox

    Ravi does the same.

  2. Ravi makes an invite. His client calls POST invites/create and shows @ravi.chat.example::Kx82mPq1Za, which he sends to Kat.

  3. Kat adds Ravi. Her client splits the code at :: (§9.4), calls GET resolve/ravi.chat.example, then GET chain/{ravi} (with home set to the home from resolve), checks the handle, and calls POST contacts/request with invite set to Kx82mPq1Za. Ravi's inbox gets a contact-request notice.

  4. Ravi accepts. His client:

    1. POST contacts/accept
    2. POST keypackages/claim for Kat
    3. creates the MLS group locally, and POST groups/create with the public half of epoch_key_0
    4. POST send with the Add commit, its commit-request, and the Welcome for Kat
  5. Kat joins. Her inbox gets the commit and the Welcome. Her client processes the Welcome, then POST groups/register.

  6. They chat. Each message is POST send with "mls": "app" and a signed app-message inside (§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 online when a device opens a stream and the identity has no state yet, or was set offline by 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_QUOTA per identity, 1 GiB by default

© 2026 Reroute Holdings LLC

A public draft of Tieroute v1, open for comments.