https://api.floe.one by default, or whatever you set as
--server or FLOE_SERVER for the CLI, or as Server address in Floe
Desktop’s settings.
The short version
GET /health tells a monitor the server is up. GET /api/turn-credentials hands every client its
STUN and TURN servers. POST /api/code and GET /api/code/:code turn a room into a short code and
back, and GET /api/stats and POST /api/stats/report read and feed the public byte counter.
Status and health
Two endpoints take no input, are never rate limited, and tell you whether the server is up.GET /
A liveness check that also returns the server’s clock.200 OK
string
required
Always
"ok".string
required
The server’s current time as an ISO 8601 string.
GET / on the signaling server
is only reachable when you address the server directly.
GET /health
The endpoint to point a monitor at. It survives every deployment layout, including a one-domain reverse proxy setup (Run behind one domain).200 OK
string
required
Always
"healthy". Floe Desktop’s Test button matches this value exactly, so a proxy that
answers with anything else fails the check.number
required
Seconds since the process started, as a float. Useful for confirming a restart actually landed.
string[]
What the server offers beyond ordinary transfers:
["request-1"] while request links are
turned on in its policy file, and [] while they are off. It is
read fresh on every request, so it follows a policy file edit within a minute. Floe Desktop reads
it each time you click Make link, and makes no request-link call before that. Every server
from 1.11.0 on sends the field; an older one has no features field at all.Origin header is always
answered.
Relay credentials
One endpoint hands every client the STUN and TURN servers to try.GET /api/turn-credentials
Returns the ICE server list a client should use. Every Floe client calls it before it builds a peer connection. The web app’s main page reads it when it loads, and again before a connection when no read has succeeded yet or the list is an hour old. Floe Desktop holding a request link calls it again for each sender who arrives, from its own address. The response is a JSON array, not an object. What is in it depends on how the server is configured, in a fixed order. Cloudflare Realtime TURN comes first, if both Cloudflare variables are set. Then coturn, if bothTURN_SECRET and TURN_DOMAIN are set and TURN_DOMAIN is a hostname
alone. Then Google’s public STUN
servers.
TURN_TLS=off leaves the turns: entry out of the coturn list.
Note the difference in the urls field between the first shape and the other two. Cloudflare
entries carry an array of URLs; coturn and the STUN fallback carry a string. Every Floe
client accepts either, and anything else consuming this endpoint has to as well.
string | string[]
required
One or more ICE server URLs. STUN and TURN are always split into separate array entries, which
is what lets a client strip TURN while keeping STUN when its user turns off relay fallback.
string
Present on TURN entries only. For coturn this is
{expiry_unix}:floeuser.string
Present on TURN entries only. For coturn this is
base64(HMAC-SHA1(TURN_SECRET, username)).200 in every one of
these cases.
Cloudflare’s raw list is trimmed before it is served, down to one STUN URL, one UDP TURN URL, and
one TCP-based TURN URL, preferring TLS on port 443. Clients gather ICE candidates per URL per
network interface, so every redundant URL multiplies connection setup time on a machine with VPN
or virtual-machine adapters.
Rate limit: 20 requests per IP per 60 seconds, configurable with MAX_TURN_REQUESTS_PER_IP.
Over the limit returns 429 with { "error": "Too many requests" }, and the client falls back
to STUN only. A transfer that may only use the relay stops instead. What each app shows then is in
MAX_TURN_REQUESTS_PER_IP.
Room codes
Room codes are three lowercase words joined by hyphens, with a four-word fallback. How they are drawn, and every event that retires one, is in Room codes.POST /api/code
Registers a room code for a room. Called byfloe send
and by Floe Desktop. The web app never calls it.
string
required
The room UUID, in canonical
8-4-4-4-12 form. Any UUID version is accepted, in either letter
case. The code resolves to the id exactly as it was registered.Request
200 OK
string
required
The room code to share. It resolves for at most 10 minutes from this moment, and stops resolving
earlier once the room pairs or empties.
The limiter runs before
roomId is checked, so a caller over the limit gets the 429 rather than
the 400.
A second POST for the same room retires that room’s previous code, so a room only ever has one
working code and a retrying sender does not widen its own exposure.
The 503 is a global guard, not a per-IP one. It bounds the memory the code registry can use. A
slot is freed as soon as its code is retired: when the room pairs, when the room empties, or when
the same room registers again. A code past its 10 minutes frees its slot at the next once-a-minute
sweep, or when someone looks it up, so the count can briefly include codes that have already
expired.
GET /api/code/:code
Resolves a room code back to its room id. Called byfloe receive and by Floe Desktop’s
Receive tab.
string
required
The room code, matched exactly: the server looks up the code exactly as it arrives.
floe receive and Floe Desktop lowercase and trim what you type before calling this, so
Olive-Tiger-Castle resolves from either of them. A direct caller has to send lowercase itself.200 OK
Rate limit: 60 requests per IP per 60 seconds, shared with
POST /api/code and configurable
with MAX_CODE_REQUESTS_PER_IP.
Failed-lookup budget: 10 misses per IP per 60 seconds, configurable with
MAX_FAILED_CODE_RESOLVES. Only a 404 counts against it, and the budget is checked before the
lookup. So once an address has spent it, every lookup from that address answers 429, a working
code included. The two 429s above cannot be told from each other, or from a refusal about a
code that does not exist. Neither ever carries a room id. Why the budget works this way is in
Rate limiting.
A resolve does not retire the code. A receiver that resolves and then fails before it can join,
with no relay credentials or an output path it cannot write, can retry the same code. When a code
stops working, as the people sharing it meet it, is in
How a transfer works.
Global byte counter
Two endpoints read and feed the public total of bytes transferred. Who reports, and how the server stores the total, is in Global statistics counter.GET /api/stats
Returns the all-time total shown on the Floe homepage. It is answered from an in-memory value, so the homepage polling it every 10 seconds never reaches Redis.200 OK
integer
required
A single cumulative figure across every transfer this server has been told about. It carries no
file names, no per-transfer records, and no link to any user.
POST /api/stats/report
Adds one transfer’s byte count to the counter. Only the receiving side of a completed transfer calls this, plus Floe Desktop for a request link drop that stopped after it was accepted. Senders never report.integer
required
A positive integer, no larger than
MAX_REPORT_BYTES, which defaults to 5 TiB
(5,497,558,138,880). Zero, negatives, fractions, and strings are all rejected, so a transfer of
only empty files adds nothing.Request
200 OK
Of the per-IP limiters on these endpoints, this is the one with no environment variable: it is
fixed at 60 reports per IP per 60 seconds. It runs before
bytes is checked, so a report answered
400 still spends one of the 60.
Every receiver can opt out before the request is ever made. See
Aggregate statistics.
GET /api/config belongs to the web client
There is one path that looks like it belongs here and does not.GET /api/config is served by
the Next.js client, not by the signaling server, and it is how the browser learns which
signaling server to use:
Request
200 OK
socketUrl means the browser should use its own origin, which is the right answer
behind a single domain. It is also what floe.one answers, because floe.one compiles its server
address into the build and never sets SOCKET_URL; a self-hosted instance that sets it sees the
value here. The answer is computed for every request and sent with Cache-Control: no-store.
commit is the source revision the client was deployed from, so anyone can check what an
instance is running without taking the page’s own word for it. On floe.one it is the full
40-character SHA of the commit Vercel deployed, read from VERCEL_GIT_COMMIT_SHA, and it names
a commit in the public repository. A self-hosted instance reports whatever SOURCE_COMMIT is set
to in the client’s environment (see Configuration), and null
when it is unset. The value is informational: nothing signs it, and it describes the web client
only. The signaling server has no version endpoint.
Route all of /api/ to the signaling server and this request returns 404. Any signaling URL
you configured is then silently discarded and the browser falls back to its own origin. Behind
one domain that fallback happens to be correct, so the mistake stays invisible until the day
you set a value and nothing happens. See
Run behind one domain for the exact-path rule.
Behavior shared by every endpoint
CORS. Browser requests are checked against an allow-list: whatever you set asCLIENT_URL,
plus https://floe.one, https://www.floe.one, and http://localhost:3000. The match is exact,
including the scheme and with no trailing slash, and CLIENT_URL holds one origin. A
comma-separated list matches nothing. Requests with no Origin header always pass. HTTP has no
same-host rule: an Origin that names the server’s own host is refused unless it is on the list.
An HTTP request from a rejected origin does not come back as a recognizable CORS error. The server
answers 403 with { "error": "Bad request" } and no Access-Control-Allow-Origin header, while
the browser reports a generic CORS failure. If you see that pairing, check CLIENT_URL first.
WebSocket origins. Both WebSocket paths check Origin against the same
list. They also let in an Origin naming the same host as the request’s Host header. The port is
compared only when Host still carries one, since some proxies drop it. That second rule is how
the CLI and Floe Desktop reach a self-hosted server regardless of CLIENT_URL: they send the
server’s own address as their origin.
A WebSocket handshake that is refused gets one of these answers before the connection opens:
The server logs the first origin refusal on each of the two paths, once, with the
Origin it
refused and the Host it compared against. The 404 and the 400 are never logged. A server
image of version 1.11.0 or earlier gives an upgrade on another path no answer at all.
Errors. Every endpoint above answers a failure with JSON, each with its own message, and those
are the bodies you will normally see. An error that is thrown rather than returned falls through
to a final handler that collapses it:
A rejected CORS origin takes the first path, which is why it surfaces as a bare
403. This holds at
every NODE_ENV. Apart from the refused WebSocket handshakes above, one answer is not JSON. For a
path or method the server does not serve, Express answers 404 with a short HTML page that reads,
for example, Cannot GET /nope.
Request bodies are parsed at the default 100 kB limit (102,400 bytes). A larger body gets
413 and malformed JSON gets 400, both with { "error": "Bad request" }.
Security headers. Every answer from these endpoints carries the standard security headers of the Helmet library,
and no X-Powered-By header. Among them, Strict-Transport-Security tells
browsers to use HTTPS for the server’s own hostname for one year. A server image of version 1.11.0
or earlier adds includeSubDomains, which extends that to every name under the hostname. Answers
the CORS check lets through also carry Access-Control-Allow-Credentials: true and
Vary: Origin.
Client IP. Per-IP rate limits read X-Forwarded-For according to TRUSTED_PROXY_COUNT. Setting
it higher than your real number of proxy hops lets a client forge the header and slip past every
limit. See Configuration. “Per IP” means one IPv4 address or one
IPv6 /64, as Rate limiting describes.
Related
Transfer protocol
What happens after the two peers connect, on the connection between them.
Architecture
The components, the two signaling transports, and how a session fits together.