Skip to main content
The signaling server exposes seven HTTP endpoints. They exist so the three Floe clients can find each other and fetch relay credentials, and they are documented here for self-hosters checking a deployment and for contributors. Treat this as a description of the current implementation rather than a stable public contract. Floe ships the clients and the server together, so these shapes change when both sides change. There is no authentication on any of them, and no endpoint ever touches file data. The base URL is your signaling server: 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.
On a one-domain deployment this path belongs to the web client, so 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.
Neither endpoint is rate limited, but both pass the same origin check as every other route (see CORS). A monitor that sends no 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 both TURN_SECRET and TURN_DOMAIN are set and TURN_DOMAIN is a hostname alone. Then Google’s public STUN servers.
A server with 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)).
Both credential paths issue a 24-hour expiry. coturn credentials are computed for each request. Cloudflare credentials are minted upstream and cached in the server’s memory for 5 minutes, so a burst of page loads costs one upstream call and a restart re-mints. The upstream call times out after 10 seconds. While Cloudflare’s API fails, the server keeps serving the last good copy until it is 23 hours old, then falls back to coturn or public STUN. The status is 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 by floe 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 by floe 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.
Not rate limited. On a self-hosted instance this counter is local to that server, and without Upstash Redis configured it lives in memory only and resets to zero on restart.

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.
The example posts to a server running locally. A report sent to floe.one is added to the public total for good, so never send it test values.
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
An empty 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 as CLIENT_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.

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.
Last modified on November 3, 2026