https://api.floe.one by default, or whatever you set as
--server, FLOE_SERVER, or Server address.
Status and health
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.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.
Relay credentials
GET /api/turn-credentials
Returns the ICE server list a client should use. Both the browser and the Go engine call this once before connecting. The response is a JSON array, not an object. What is in it depends on how the server is configured, and the precedence is fixed: Cloudflare Realtime TURN if both Cloudflare variables are set, then coturn if bothTURN_SECRET and TURN_DOMAIN are set, then Google’s public STUN
servers.
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)).MAX_TURN_REQUESTS_PER_IP.
Over the limit returns 429 with { "error": "Too many requests" }, and the client falls back
to STUN only. The browser and Floe Desktop show nothing; the CLI prints Warning: signaling server returned 429 for TURN credentials. Using STUN only.
Room codes
Codes are three lowercase words joined by hyphens, drawn from a 288-word list with a cryptographically secure random source. On a collision with a live code the server draws again, up to ten times, and only then falls back to a four-word phrase.POST /api/code
Registers a short 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, case-insensitively.Request
200 OK
string
required
The phrase to share. It resolves for 10 minutes from this moment.
The
503 is a global guard, not a per-IP one. It bounds the memory the code registry can use.
Only expiry frees a slot: resolving a code does not delete it, and a sweeper clears lapsed
entries once a minute, so the count can briefly include codes that have already expired.
GET /api/code/:code
Resolves a code back to its room. Called byfloe receive and by Floe Desktop’s Receive tab.
string
required
The phrase, 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.
Global byte counter
GET /api/stats
The all-time total shown on the Floe homepage. 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
How the counter is fed. Only the receiving side of a completed transfer calls this, so each transfer is counted once. 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.Request
200 OK
The rate limit here is the one limiter with no environment override. Every other limit is
configurable; this one is fixed at 60 per IP per minute.
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.
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, which is why
the CLI and the desktop app reach your server regardless of this setting.
A rejected origin does not come back as a recognizable CORS error. The server answers 500 with
{ "error": "Internal server error" } while the browser reports a generic CORS failure. If you
see that pairing, check CLIENT_URL first.
Errors. Every failure returns JSON and nothing else. Each endpoint above answers 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: anything under 500 becomes
{ "error": "Bad request" }, anything at or above it becomes { "error": "Internal server error" },
and the stack trace goes to the server’s own output. A rejected CORS origin takes that path, which
is why it surfaces as a bare 500. This holds at every NODE_ENV.
Request bodies are parsed at the default 100 kB limit. A larger body gets 413, and
malformed JSON gets 400.
Client IP. Per-IP 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.
Related
Transfer protocol
What happens after the two peers connect, on the data channel itself.
Architecture
The components, the two signaling transports, and how a session fits together.