Relay API
Every route a Flamenet relay serves, what authenticates it, and what it is for.
Read from Sources/FlamenetRelay/Entrypoint.swift rather
than from memory. A route not in this document is not served, and a
document that lists routes a relay does not serve is worse than no
document, because it is believed. That is not hypothetical: the relay
once published its own index under a second prefix it served nothing on,
and every client that read it broke.
Base URL is https://<your-relay>. There is
one REST prefix, api.
Discovery
| Route | Auth | Purpose |
|---|---|---|
GET /.well-known/flamenet/relay |
none | What this relay is |
GET /api/flamenet/v1 |
none | The route index |
GET /e2e/log/head |
none | Signed transparency-log head |
/.well-known/flamenet/relay is what a client reads
before sending any credential:
{
"audience": "relay.example.com",
"issuerPublicKey": "<base64 Ed25519>",
"protocolVersions": [1, 2, 3],
"keyLogHead": "https://relay.example.com/e2e/log/head",
"federation": "closed",
"federationKey": null,
"accounts": "local",
"accountsURL": null,
"reports": true,
"features": { "padAttachments": false, "perDeviceDigests": true }
}
features holds switches a client reads before doing
something an older client could not handle.
features.padAttachments says whether senders may pad
attachments (FN_PAD_ATTACHMENTS); a client that does not
find the key treats it as false. features.perDeviceDigests
says the relay keeps a delivery-key digest and a presence-token set per
device rather than per account, so an app may publish its digest again
to land in its own row, and may send device_id with its
roster tags.
accounts decides where a passphrase
goes. local means this relay is its own identity
provider and authenticates the password itself. shared
means accounts live in a separate service named by
accountsURL, the passphrase is stretched on the device, and
only a derived secret is sent, to that service, never to the relay.
A client must not believe accountsURL blindly. It should
accept only account services it already trusts, and treat anything else
as local. Otherwise an operator could name a host they
control and collect passphrase-derived secrets. The relay's own code
pins its accountsURL as a constant for the same reason.
Everything else is unauthenticated on purpose: a descriptor only this relay's users could read would be useless to the peers it exists to inform, and a transparency log only its own users could audit would not be a transparency log.
The Today file
| Route | Auth | Purpose |
|---|---|---|
GET /today.json |
none | The signed Today file, when this relay offers one (§8.12) |
The same bytes for everybody, served exactly as they were signed and
only after the signature checks against FN_TODAY_KEYS. 404
{"error":"not_offered"} when no source is configured, 503
while nothing has verified yet. A 200 carries an ETag and
Cache-Control: public, max-age=900, and
If-None-Match gets a 304. Nothing else is read from the
request: no credential, no cookie, no query.
Accounts
Present only when this relay runs the built-in identity provider
(FN_ISSUER_PRIVATE_KEY set).
| Route | Auth | Purpose |
|---|---|---|
GET /account/challenge |
none | The proof-of-work parameters a sign-up must satisfy, when
FN_SIGNUP_POW_BITS is set |
POST /account/signup |
none | Create an account |
POST /account/signin |
none | Exchange a password for a credential |
POST /account/token |
Basic | Mint a device-scoped relay token |
POST /account/reset/request |
none | Ask for a reset code |
POST /account/reset/confirm |
none | Redeem a reset code |
POST /account/verify |
none | Redeem an emailed address-verification code |
POST /account/vouch |
Basic | Mint an invite to hand to somebody (relay#43) |
Also mounted under
/api/flamenet/v1/messenger/{signup,signin}.
signup takes
{username, email, password, display_name?} and answers:
{ "user_id": 7, "username": "alice", "display_name": "Alice",
"app_password": "<returned once, never retrievable>" }
The credential is returned once and only its hash is kept. Losing it means signing in again. It is not the password, and changing the password invalidates it, which is what changing a password should do.
signin accepts a username or an
email. The field has always been labelled "username or email";
a client that sends only usernames rejects half the people who typed
exactly what they were asked for.
Reset answers identically whatever the truth is: unknown handle, no address on file, an account that authenticates elsewhere. A client cannot report whether it worked, and a UI that claims it can is lying.
Shared identity
| Route | Auth | Purpose |
|---|---|---|
POST /api/flamenet/v1/messenger/session |
none | Exchange an account-service token for a relay credential |
Present only when FN_ENCLAVE_JWT_SECRET or
FN_ENCLAVE_PUBLIC_KEY is set. Takes
{token, handle, display_name?}.
The handle travels beside the token, not inside it. The account service will not put a handle in a token so that a relay cannot correlate its users with another product's; the token carries a product-scoped pseudonym. The relay uses the handle only the first time it sees that pseudonym, after that the pseudonym decides, and honouring the handle would let somebody rename themselves into an existing account.
Protocol routes (§8)
All under /api/flamenet/v1/e2e/…, and also at
/e2e/….
These refuse the account credential. They take a short-lived token scoped to one device and one relay, plus a per-request signature proving possession of that device's identity key. A credential good for every route on every device would mean a token lifted from one device drains another's prekey pool and reads its inbox.
| Route | Purpose |
|---|---|
POST /e2e/devices |
Publish a device's identity and prekeys |
GET /e2e/devices/{user} |
List a user's devices, costs no prekey. Registration and last-poll
times (created_at, last_seen) only when
{user} is the caller's own account, kept there because both
apps' Devices screens read created_at from it |
GET /e2e/devices/me |
The caller's own devices, with created_at and
last_seen. Where those times belong; the line above keeps
them only until the apps have moved here |
DELETE /e2e/devices/{device_id} |
Retire a device and stop its bundles being served |
GET /e2e/keys/{user} |
Fetch bundles. Pops a one-time prekey per device |
POST /e2e/prekeys |
Top the pool back up |
POST /e2e/messages |
Deposit envelopes. A sealed delivery_token may be one
the recipient issued or one from POST /e2e/intro;
intro, §14.5's name for the field, is taken the same way.
With FN_PAD_ENFORCE=enforce, a request carrying any payload
not exactly 16384 or 65536 characters long is refused with
unpadded |
GET /e2e/messages |
Poll this device's inbox. Stops near 2 MiB of payload, never below
one envelope, and then adds "more": true: poll again from
the returned cursor |
POST /e2e/push/token |
Register this device's APNs token |
POST /e2e/delivery-key |
Publish this device's delivery-key digest for sealed sending (§14.4). A sealed send is accepted against any of the account's device digests |
POST /e2e/delivery-tokens |
Add and drop single-use delivery capabilities (§21.4) |
GET /e2e/handle-cert |
A certificate for the caller's own handle, see below |
POST /e2e/contact-links |
{"add": [hash], "revoke": [hash]}, each a base64
SHA-256 of a contact-link secret. At most 5 per account; a
sixth is refused with 409 rather than dropped. The relay
keeps the hash and the account, no time and no record of use. Answers
{"ok": true, "links": n} |
POST /e2e/attachments |
Upload one encrypted blob |
GET /e2e/attachments/{id} |
Download it, supports Range |
POST /e2e/groups |
Create a group |
GET /e2e/groups |
List groups |
POST /e2e/groups/{gid}/members |
Add or remove members |
POST /e2e/groups/{gid}/leave |
Leave |
GET /e2e/turn |
TURN credentials for calls (§8.10) |
POST /e2e/moderation/report |
File a report about content (§6.25) |
POST /e2e/wipe |
Publish a signed order for one device to erase itself (§6.26) |
GET /e2e/wipe |
Read any order waiting for this device |
Sealed groups (§8.9b) are the same registry with the roster encrypted, so the relay holds a membership list it cannot read.
| Route | Purpose |
|---|---|
POST /e2e/groups/sealed |
Create one |
POST /e2e/groups/fetch |
Read the sealed records this device is named in |
POST /e2e/groups/{gid}/roster |
Replace the sealed roster |
POST /e2e/groups/{gid}/leave-sealed |
Leave one |
The transparency log (§19) is unauthenticated, all four routes. A log only this relay's own users could read would not be one.
| Route | Purpose |
|---|---|
GET /e2e/log/head |
The signed head: size, root, signature, audience |
GET /e2e/log/inclusion?index=&size= |
Audit path for one leaf |
GET /e2e/log/consistency?from=&to= |
Proof that from is a prefix of to |
GET /e2e/log/device/{device_id} |
Every entry for a device, with its index |
GET /e2e/log/subject |
Every entry under one subject, so a monitor can watch an account for devices its owner did not add (relay#32) |
Asking without logging in. These read no credential at all, and carry the account being asked about in the body, never the URL, so it reaches no access log. Every meter that is about an account is keyed by a keyed hash of it, never the id. The relay still sees the address.
| Route | Purpose |
|---|---|
POST /e2e/intro |
{"handle": h, "challenge": c, "nonce": n} (listed
accounts only) or {"link": secret} (base64 or base64url, 32
bytes). A handle needs a solved challenge from
GET /e2e/intro/challenge (FN_INTRO_POW_BITS,
each solution good once; 400 challenge_required or
challenge_failed otherwise, the same for every name); a
link needs none. Returns user_id, fnid,
intro, one token good for one sealed send (as
intro or delivery_token) until
exp in unix seconds, 10 minutes ahead
(expires_in), the target's bundles (devices,
one-time prekeys popped) and handle_cert. An unlisted or
unknown handle, an unknown or revoked link and an account with no
devices all get the same 404 not_found "No such account.",
no sooner than 5 ms. Metered per address (FN_INTRO_RATE),
per network (FN_INTRO_RATE_NETWORK), and once an account is
found, per account (FN_INTRO_RATE_TARGET) and per network
and account (a third of that); at most 30 unspent introductions per
account |
GET /e2e/intro/challenge |
A proof-of-work challenge for an introduction by handle, in the
sign-up challenge's format:
{"challenge", "bits", "expires_in"}. Solve for a
nonce such that
SHA-256(challenge + "." + nonce) has bits
leading zero bits |
POST /e2e/anon/presence |
{"tokens": [...]}, at most 8. The same answer as
POST /messenger/presence. Metered per address, by token
count |
POST /e2e/anon/devices |
{"user_id": n, "cap": token?}. Identity keys and
caps_sig only, no created_at or
last_seen |
POST /e2e/anon/bundles |
{"user_id": n, "cap": token?}. As
GET /e2e/keys/{user}, popping a prekey per device |
For the last two, cap is a presence token the subject
issued to the caller. When it is one of the subject's, the token is the
meter; otherwise, or without one, the address is (and for bundles, the
address and account together, the shape GET /e2e/keys uses
per caller). A cap that does not match is ignored, not
refused, so the route says nothing about whether a token is good.
Handle certificates state that user_id
holds handle on this relay until exp:
{ "v": 1, "aud": "relay.example", "uid": 7, "h": "ada",
"exp": 1760000000, "sig": "<base64 Ed25519>" }
sig is over the bytes
fnhandle-v1|<aud>|<uid>|<h>|<exp>,
made with the key that signs the log head, whose public half is the
descriptor's issuerPublicKey that both apps pin on first
sight. Handles are lowercase letters and digits, so |
cannot occur inside one. Good for 7 days. A relay with no signing key
answers GET /e2e/handle-cert with
503 handle_cert_unsigned and puts null in an
introduction.
Addresses and remote relays. The first two answer without a credential, because a first message needs them before any session exists. Both answer yes or no for a name, which makes them a directory somebody can walk, so both are metered per address.
| Route | Auth | Purpose |
|---|---|---|
GET /e2e/account/identity/{handle} |
none, metered | Resolve a handle to an address. An unlisted handle answers
404 exactly as an unknown one |
GET /e2e/account/homes/{fnid} |
none, metered | Which relays that address publishes on |
POST /e2e/account/identity |
device token | Claim an address for this account |
POST /e2e/account/homes |
device token | Publish the relays this account can be reached on |
POST /e2e/send/remote |
device token | Hand an envelope to this relay for a peer relay |
GET /e2e/remote/{relay}/devices/{fnid} |
device token | List a remote account's devices, pops no prekey |
GET /e2e/remote/{relay}/bundles/{fnid} |
device token | Fetch a remote account's bundles, pops one |
Partial downloads
GET /e2e/attachments/{id} answers
Accept-Ranges: bytes and honours a single
Range header, so an interrupted download can resume and a
long voice note can start playing before all of it has arrived.
Range: bytes=0-499 the first 500 bytes
Range: bytes=500- from 500 to the end
Range: bytes=-200 the last 200 bytes
A satisfiable range comes back as 206 with
Content-Range: bytes 0-499/1000. A Range this
relay will not serve, several ranges in one header, or anything
malformed, is ignored rather than refused, and you get
200 and the whole blob.
416 means the blob is not the length you think it is. A
resume asks for bytes=900- because the client already holds
900 bytes; if the blob was replaced or is shorter than that, the answer
is 416 Range Not Satisfiable carrying
Content-Range: bytes */<real length>. That is not
"your request was malformed", it is the length you need to start again
from zero. Do not retry the same range.
What a range costs you in privacy. Asking for part of a file tells the relay which part you wanted: that somebody stopped listening to a voice note after three seconds, which a whole-file fetch cannot say. There is no way to offer resumable downloads without the server seeing the offset. This relay does not log it, does not count it per blob, and does not store it on the attachment row, but a relay you do not run could, so a client that would rather not say should fetch whole blobs.
The three that bite
GET /e2e/keys/{user} consumes a one-time prekey per
device, every call. Use /e2e/devices/{user} to look at who
somebody is; use keys only when actually starting a
session. Polling it drains the pool and pushes every peer onto the
last-resort key, costing initial forward secrecy for everyone.
POST /e2e/messages names the recipient once, at the top:
not per envelope:
{ "to": 42, "from_device": "…",
"messages": [ { "to_device": "…", "payload": "<base64>" } ] }
One envelope per device of theirs. The relay does no fan-out and no re-encryption; it moves opaque payloads.
POST /e2e/devices publishes prekeys under
prekeys, not opks. A registration carrying the
wrong name is accepted with a 200 and stores nothing, the
device is published and can never be started with, which is a failure
with no error anywhere.
The per-request proof
Each authenticated request carries X-FN-Timestamp,
X-FN-Nonce and X-FN-Signature, an Ed25519
signature over:
METHOD \n LOGICAL_PATH \n TIMESTAMP \n NONCE \n hex(SHA-256(body))
LOGICAL_PATH is the /e2e/… path, not the
URL's path. These routes are mounted twice and signed against the
former; signing the REST-prefixed path produces a valid signature over
the wrong string and a refusal with no hint why.
The nonce is not decoration. Without it, two inbox polls in the same second with the same empty body sign identically, and the replay cache cannot tell an honest repeat from a captured one, so it refuses the honest client.
The messenger surface
Mounted at /api/flamenet/v1/messenger/…. This is the
shipped app's account and contact-list surface, not the protocol: it
carries no ciphertext, and the plaintext message transport that once
lived here was removed (relay#50, and docs/PLAINTEXT.md
records what went with it).
| Route | Auth | Purpose |
|---|---|---|
POST /signup, GET /challenge,
POST /signin |
none | The account routes above, in the namespace the app asks in |
DELETE /account |
Basic | Remove the account |
GET /me |
Basic | The signed-in account, also how a restored session is validated |
PUT /me, POST /me |
Basic | Replace profile fields: name, password,
and discoverable (false is Unlisted).
GET and PUT answer with
discoverable. PUT is the honest verb;
POST is kept for clients that only speak it |
GET /users |
Basic | Exact-username lookup. It refuses without a slug, so it
is not a directory. An unlisted name is [], as an unknown
one, except your own |
GET /users/{id} |
Basic | One user's public profile. An unlisted account is 404,
as an unknown id, except to itself |
GET /buddies, POST /buddies |
Basic | The contact list, read and add. Adding an unlisted name is
404, as an unknown one |
DELETE /buddies/{id} |
Basic | Remove one |
DELETE /buddies |
Basic | Forget the whole list. No id, deliberately |
POST /roster/tags |
Basic | Publish the contact list as opaque tokens (§8.9c). With
device_id (one of this account's devices) it replaces that
device's set; without, the account-wide set an older app publishes |
POST /presence |
Basic | Ask after presence by presenting a token, rather than by being on a list the relay can read |
POST /status, POST /conversation |
Basic | Availability, and accepting a correspondent. star,
unstar, archive and unarchive are
refused with kept_on_device: those live on the device |
GET /notifications |
Basic | This account's notifications |
POST /notifications/{id}/read,
POST /notifications/read_all |
Basic | Mark them read |
GET /blocks, POST /blocks/{id},
DELETE /blocks/{id} |
Basic | Blocked accounts, read and toggle |
Pages
Served as HTML or plain text to a browser, not as API. Listed because
this document says a route not in it is not served: /,
/es, /status, /status.json,
/health, /releases, /join/{code},
/hi, /es/hi, /donate,
/es/donate, POST /donate/intent,
/downloads/android-latest.json, /robots.txt,
/sitemap.xml, /llms.txt,
/llms-full.txt, /.well-known/security.txt, and
the generated documentation pages.
Federation
Off by default. When FN_FEDERATION is not
closed:
| Route | Auth | Purpose |
|---|---|---|
POST /e2e/federation/deposit |
fnfed-v1 |
Accept envelopes from a peer relay |
GET /e2e/federation/bundles/{fnid} |
fnfed-v1 |
Serve bundles for a local account |
GET /e2e/federation/devices/{fnid} |
fnfed-v1 |
List that account's devices, identity keys only (no
created_at or last_seen since 2026-10-07) |
These sit under the same /e2e prefix as everything else,
and under /api/flamenet/v1/e2e as well, because the peer
surface is mounted with the client surface rather than on a path of its
own.
Signed with the relay's federation key, which is deliberately not the issuer key: a key that only ever says "this relay said this" should not also be able to mint a token for any user on it.
Federation costs metadata. Each relay in an exchange
learns the other had traffic for it. If your relay exists so one group
can talk without anyone else involved, closed is the
correct answer, not the cautious one.