Tieroute server-to-server API
This page is the reference for the Tieroute server-to-server (S2S) API: how one homeserver signs a request to another, how the receiver checks it, and what every S2S endpoint accepts and returns. It's for anyone building a Tieroute homeserver that federates with others. Status: draft, wire version 1. What clients see of federation is described with each endpoint in Client API.
Endpoint summary
Every S2S call is a POST, and every one is authenticated by the signed request envelope described below. The handle document is the exception: an unauthenticated GET at a fixed path.
| Endpoint | Auth | Purpose |
|---|---|---|
POST identity/fetch |
signed | Fetch a local identity's chain |
POST identity/push |
signed | Push a changed chain to contacts' servers |
GET https://{handle}/.well-known/tieroute/handle |
no | Serve a handle document |
POST deliver |
signed | Deliver messages to local recipients |
POST contacts/request |
signed | Forward a contact request |
POST contacts/decline |
signed | Forward a decline |
POST keypackages/claim |
signed | Claim key packages for a local identity |
POST commit |
signed | Ask a hub to sequence a commit |
POST group/epoch-key |
signed | Register an epoch key with a hub |
POST commit/fetch |
signed | Fetch missed commits from a hub |
POST group/register |
signed | Register a group with a new hub |
POST presence |
signed | Pass presence changes to contacts on another server |
POST profile/fetch |
signed | Fetch a public profile from its identity's homeserver |
POST media/fetch |
signed | Download a file from the server that stores it |
POST report |
signed | Forward a report to the reported identity's homeserver |
Read the API conventions first. It covers encoding, base URLs, errors, and the default consequence when a request breaks a MUST. Section references (§) are to the Tieroute specification. Schemas use the types from §4.2.
Signing and checking requests
Every S2S call is a POST to an endpoint relative to the receiver's S2S base: the s2s URL in its server document's apis entry for version 1 (§8.1). With rerouted's default base, deliver on b.example is POST https://b.example/.well-known/tieroute/v1/s2s/deliver. Callers find the base from the server document and MUST NOT assume a path.
The body is application/tieroute+json: a signed object (§4.3) with t = "s2s", signed by one of the calling server's keys, with kid set to that key's ID:
s2s-request = {
"from": server-name,
"to": server-name,
"path": tstr, ; the endpoint relative to the S2S base, e.g. "deliver"
"ts": timestamp,
"nonce": nonce,
"body": any, ; the endpoint's request, below
}
The receiver MUST check all of these (§8.3):
tois itself, andpathis the endpoint the request arrived at.tsis within 5 minutes of its clock.- The signature verifies with the key named by
kidinfrom's server document, fetched fromhttps://{from}/.well-known/tieroute/server, and the time is within the key'snbfandexp. Cache the document by its HTTP caching headers, for at most 24 hours (§5.1). (from, nonce)hasn't been seen in the last 10 minutes.
Check the signature before recording the nonce, so unsigned junk can't fill the nonce cache. If any check fails, the receiver answers 401 with bad-signature, or clock-skew for check 2. Responses are plain JSON, authenticated by TLS.
Who can speak for whom
Whenever a request names an identity as its actor (a delivery's from, a claim's claimant, a request's requester), that identity's chain MUST name the calling server as its current home (§10.2). For a delivery's from only, the previous home also counts within 72 hours of the move (the grace window, §6.6): deliveries it had queued keep arriving. Claims, contact requests and declines, presence, and reports need the current home. Check this against the cached chain. If the cache disagrees, fetch the chain from the caller (identity/fetch) once, then decide.
A fetched or pushed chain never replaces the cache outright. It's accepted only under the rules of §6.5:
- It MUST extend the cached chain, or be a valid recovery fork of it (§6.4.1). Signatures prove the entries are real, not that they're the latest, so a server could otherwise roll a chain back to a branch that still names it as home.
- Each new device-signed entry counts only if the server that was home immediately before it vouches for it: either that's the server it came from, or that server's
identity/fetchreturns a chain that contains it. A recovery-signed entry counts from anywhere, and vouches for every entry before it. The update stops at the first entry that isn't vouched for. - With nothing cached (a first fetch), each device-signed
set-homethat no later recovery-signed entry vouches for is checked with the home before it. A home that answers404or410is skipped. A home that returns the identity's chain without the move fails it, and so does one that can't be reached: accept nothing new for the identity, and try again later. A device-signed move away from a home that's gone for good needs a recovery-signed entry after it (§6.6).
spec/vectors/vouch.json has cases for these rules.
Chains and handles
Fetch an identity chain
POST identity/fetch:
fetch-req = { "id": identifier }
fetch-res = { "entries": [+ signed], "head": uint, ? "cached": true }
Returns the full chain of a local identity. Anyone may call it, since chains are public (§6.5). Unknown identities get 404 not-found.
A server MAY also answer for an identity it doesn't host, with its cached copy and "cached": true. That copy says where the identity lives, so members of a group whose servers never met can find each other. The caller treats it as a pointer: it follows the copy's set-home to the identity's home, and a cached answer never vouches for an entry (the rules above): to a caller checking a move, it counts the same as 404.
An identity that moved away keeps a tombstone (§6.6): the server keeps answering with its chain, up to and including the move, for as long as it runs.
The caller MUST validate the chain (§6.4), apply the rules above, and check that it names the server it was fetched from as its home. A caller drops a chain that fails any of these. If the chain says the identity moved (set-home), follow it once to the new server.
When the caller already has the identity cached, it compares the fetched chain with the cache exactly as for identity/push (below). Signatures make a fetched chain authentic, but not current. The comparison keeps a server that answers with an older chain, or another branch, from rolling the cache back.
Clients reach this through GET chain/{id} with home (Client API).
Push a chain
POST identity/push. When a local identity's chain changes (through client chain/append or recover), its homeserver makes one identity/push call, through the durable outbox, to each remote server that is home to at least one of the identity's contacts.
push-req = { "id": identifier, "entries": [+ signed] } ; the full chain
The receiver MUST validate the chain (§6.4). If it has nothing cached for the identity, the chain MUST name the caller as home, and its moves are checked as on a first fetch (see Who can speak for whom). If it has the identity cached, it compares the pushed chain with the cache (§6.5):
- Equal to, or a prefix of, the cache: nothing new. Ignore it (
202). - Extends the cache: accept it.
- Forks from the cache: accept it only if it's a valid recovery fork under the fork rule (§6.4.1). The first entry where the chains differ is recovery-signed, and every cached entry it overrides is device-signed and at most 72 hours older than it. A recovery fork is often shorter than the chain it replaces, because it removes the thief's entries, so length MUST NOT decide anything. Anything else is
409 invalid-chain, including an attempt to replace a recovery branch with the device-signed branch it overrode.
Of the new entries, it keeps only those that are vouched for (§6.5, see Who can speak for whom): a device-signed entry needs the home before it to have sent or confirmed it, and a recovery-signed entry counts from any server. When anything new is accepted, it replaces the cache and sends chain-update to every local user who has the identity as a contact. Answers 202 {}, including when nothing new was accepted.
Handle documents
A homeserver serves GET /.well-known/tieroute/handle for every handle it gives out, choosing the handle by the request's Host (§5.3):
handle-doc = { "id": identifier, "home": server-name }
For handles like kat.chat.example, that means wildcard DNS (*.chat.example) pointing at the homeserver, plus TLS for each subdomain. On-demand TLS in a reverse proxy works well for this.
A homeserver resolving a handle for its client (GET resolve/{handle}, Client API) fetches https://{handle}/.well-known/tieroute/handle, or, when there's no document, reads the DNS TXT record _tieroute.{handle} (§5.3). It then fetches the chain and checks that it names the handle.
Deliveries
Deliver messages
POST deliver:
deliver-req = { "deliveries": [+ delivery] } ; delivery from §10.1; `to` lists only recipients on the receiving server
For each delivery:
- The receiver checks that
fromlives on the caller (see Who can speak for whom). - It applies the recipient's allowlists and rate limits (§10.3), exactly as for local senders.
- It deduplicates on
id, and appends the delivery to each allowed recipient's inbox.
The answer is 202 {}. Allowlist drops are silent. The exception is a delivery whose from doesn't live on the caller, even after re-fetching its chain: the receiver delivers the rest of the batch, then answers 409 wrong-home with the failed delivery IDs in ids (§10.2). Deliveries not listed were handled as with 202, and the sender MUST NOT retry them.
Batches MUST fit in 1 MiB (API conventions); larger bodies get 413 too-large, so a sender splits larger ones. Batching several deliveries to the same server into one call is allowed, and receivers MUST handle it.
The sender narrows each delivery's to to the destination server's recipients, and leaves out recipients it has never fetched a chain for (client POST send, Client API).
The outbox
A homeserver doesn't make S2S calls inline while answering its own client. It queues them in a durable outbox and sends them in the background (§10.5):
- One backoff schedule per destination server. The wait before retry n is
min(10 s × 2^(n−1), 1 hour), times a random factor between 0.75 and 1.25. A success resets it, and new calls to a server in backoff wait their turn. 2xxmeans delivered.- Network errors,
5xx,408, and429are temporary. Aretryin the error (or aRetry-Afterheader, if a proxy sent one) means wait at least that long, never sooner. This way a rate-limited peer doesn't make messages disappear. - Every other
4xxis a refusal, and isn't retried. A409 wrong-homerefuses only the deliveries in itsids. - A delivery that's refused, or hasn't succeeded 72 hours after it was queued, is dead. The homeserver tells the sending identity's devices with a
delivery-failednotice (Client API), withreasonset to the refusal's error code, orexpired.
Contacts
Forward a contact request
POST contacts/request:
s2s-request-req = {
"request": signed, ; the contact-request, signed by a current device of the requester
"chain": [+ signed], ; the requester's chain, which must name the caller as home
}
The receiver takes chain into its cache under the rules above, checks that it names the caller as home (400 invalid-chain), and checks the request's signature and that from is the chain's identity (400 bad-signature or bad-request). Past those checks the answer is always 202 {}, so the recipient's invite codes, blocks, and existence stay private (§10.3).
If the recipient is local, the server checks the invite code and the rate limits (§9.1, §9.5), including at most 20 failed codes per origin server per hour. A request that passes is recorded as pending-in, and the recipient gets a contact-request notice that includes chain. A request that fails is dropped silently.
The requester's own homeserver records the request as pending-out before forwarding it, and needs the recipient's chain cached first. That pending-out record is what later lets the recipient's Welcome and key-package claim through (§9.3, §10.3).
Forward a decline
POST contacts/decline:
s2s-decline-req = { "decline": signed } ; t = "contact-decline", signed by a current device of `from` (§9.3)
202 {}. The receiver checks that from lives on the caller, and that the decline's request is the hash of a pending outgoing request from to to from. If so, it clears that request, blocks new requests from to to from for 30 days, and puts a contact-declined notice in to's inbox. Otherwise it drops the decline silently.
Claim key packages
POST keypackages/claim. A homeserver calls it for client POST keypackages/claim when the target identity lives on the receiver (Client API).
s2s-claim-req = {
"id": identifier, ; a local identity
"claimant": identifier, ; who wants the packages; must live on the caller
? "group": group-id,
? "devices": [+ device-key], ; claim only for these current devices
}
The response is kp-claim-res, as for the client endpoint (Client API). The same allow rules apply (§11.4), evaluated for claimant, and claims that name a group are rate-limited per claimant and per origin server. devices works as on the client endpoint: only the listed devices that are current in the chain. 403 not-allowed if none of the rules hold, or if claimant doesn't live on the caller.
Groups and hubs
A group's hub is the homeserver that sequences its commits (§11.6). Every group starts with its creator's homeserver as hub (§11.5). Members on other servers reach the hub through these endpoints, which their homeservers call when a client names a hub (Client API).
Sequence a commit
POST commit asks this server, as a group's hub, to sequence a commit (§11.6). A member's homeserver calls it for client POST send with "mls": "commit" and hub set to this server, then delivers the commit with the returned receipt as usual.
s2s-commit-req = { "commit": signed, "data": b64 } ; the commit-request, and the MLS commit bytes
s2s-commit-res = { "receipt": signed } ; the commit-receipt
The hub runs exactly the same step as for its own users (§11.6). The epoch key's signature on the commit-request is the authorization, not the calling server's identity. A request identical to the one the hub accepted for its epoch is a retry after a lost reply, and gets the same receipt. Errors: 409 stale-epoch, 409 awaiting-key, 404 not-found, 400 bad-signature or bad-request, 503 unavailable.
Register an epoch key
POST group/epoch-key registers the key for the epoch a reg commit created, for a group whose hub is this server (§11.6). Members' homeservers call it for client POST groups/epoch-key with hub set (Client API).
s2s-epoch-key-req = { "key": signed } ; t = "epoch-key", signed with the `reg` key
200 {}. The hub takes it only if the group is awaiting a key at that epoch and k is the stored reg. One registration per reg. Errors: 400 bad-signature or bad-request, 404 not-found.
Fetch missed commits from a hub
POST commit/fetch, for client GET groups/{group}/commits when the group's hub is another server (§11.6):
commit-fetch-req = { "group": group-id, "after": uint }
commit-fetch-res = { "items": [* { "epoch": uint, "data": b64, "receipt": signed }] } ; epochs > after, in order
Commits are encrypted, so the hub needs no access check beyond a rate limit per caller (§10.3). Errors: 404 not-found (the hub doesn't know the group), 410 expired (epoch after + 1 is past the 30 days the hub keeps).
Register a group with a remote hub
POST group/register, for a group whose hub isn't the creator's homeserver. In v1 every group starts with its creator's homeserver as hub (§11.5), so this is only used after a hub handoff (§11.11), when the new hub has to learn the group. A homeserver calls it for client POST groups/create with hub set (Client API).
group-register-req = { "group": group-id, "epoch": uint, "epoch-key": b64-32 }
204. The hub starts sequencing the group at epoch, with epoch-key as that epoch's key. Errors: 409 already-exists (the hub already knows the group).
The new hub can't read the handoff commit, so it can't check that the caller speaks for the group (§11.11). A hub SHOULD accept group/register only from a server that's home to at least one full contact of its own users, and answer 403 not-allowed otherwise. If a handoff's registration fails, the admin's client offers one-click re-create instead, so a server that registers a group first only blocks that handoff.
Presence and profiles
Pass presence between servers
POST presence, with the presence-update body from §13.2:
presence-update = { "updates": [+ { "id", "state", ? "status", "pv", "ts", "to" }] } ; as in §13.2
202 {}. For each update, the receiver checks that id lives on the caller (see Who can speak for whom), then passes it to each identity in to that has id as a mutual contact, on that identity's stream. Other recipients are dropped silently, like any allowlist drop (§10.3). Senders SHOULD batch updates, at most one call per destination server every 5 seconds (§13.2).
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 (Client API).
Fetch a profile from another server
POST profile/fetch, for client GET profile/{id} when the identity is on this server:
profile-fetch-req = { "id": identifier }
profile-fetch-res = { "profile": signed }
Profiles are public, so anyone may call it. Errors: 404 not-found.
Files
Fetch a file from another server
POST media/fetch, for client GET media/{server}/{id} when {server} is this server:
media-fetch-req = { "id": b64-16 }
The response is the raw bytes, up to 64 MiB, with the same Content-Type and errors as the client endpoint (Client API): not-found, 410 expired, and 410 removed. It's the one S2S response that isn't JSON. Servers SHOULD rate-limit it per caller. A server that doesn't can be made to serve the same large file over and over.
Reports
Forward a report
POST report, sent through the outbox for client POST report with forward set:
s2s-report-req = { "report": report }
202 {}. The receiver checks that reporter lives on the caller (403 not-allowed) and that reported is one of its own identities (404 not-found), and keeps the report for its admins. Receivers SHOULD rate-limit forwarded reports per origin server. Without a limit, another server can fill an admin's queue.
Implementation status
rerouted, the reference server, implements every endpoint on this page. Where the API leaves a choice to the server, rerouted:
- sends one delivery per
delivercall - allows 100
group/registercalls per server per day - has a development mode (
REROUTED_DEV=on) for federating on one machine.REROUTED_DEV_PEERS=a.test=http://127.0.0.1:18091,b.test=http://127.0.0.1:18092maps server names to plain-http addresses; a handle likekat.a.testis sent to its peera.test, with the handle as theHost; and DNS isn't used. The names are still DNS-style names, never IP literals (§5.1). This is an implementation feature, not protocol, and a server in dev mode MUST NOT federate with the public network.