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, 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.
Not rate limited. 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.
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.
Not rate limited.

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 both TURN_SECRET and TURN_DOMAIN are set, then Google’s public STUN servers.
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. Cloudflare credentials are minted upstream and cached in the server’s memory for 12 hours, so a burst of page loads costs one upstream call and a restart re-mints. 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. 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 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, 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 by floe 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.
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

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
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. 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, 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.

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.