This document describes the Fernsicht protocol as implemented by the V2 connectionless signaling server and every sender (the Go bridge embedded in the CLI, the Python SDK, the R SDK) plus the browser viewer.
- The sender (CLI / Python / R) calls
POST /sessionon the signaling server. The server registers a room and returns asender_secret. - Sender polls
GET /poll/{room_id}?secret=…on a fixed interval. - Viewer (browser) creates a WebRTC offer locally, then
POST /watchwith the offer. The server returns a ticket ID. - Sender's next poll returns the pending ticket with the viewer's offer.
- Sender creates an answer,
POST /ticket/{id}/answerwith the answer SDP. - Viewer polls
GET /ticket/{id}/answeruntil it receives the answer. - Both sides exchange ICE candidates via
POST|GET /ticket/{id}/ice/senderandPOST|GET /ticket/{id}/ice/viewer. - WebRTC DataChannel opens. Progress data flows P2P, bypassing the server.
There is no persistent socket on either side. The server holds only the room registry and short-lived tickets (default TTL 25s).
All endpoints are on the signaling server (default https://signal.fernsicht.space).
Create a new room. Optional JSON body:
{"max_viewers": 4}Optional header: X-Fernsicht-Api-Key if the server requires it.
Response:
{
"room_id": "abc...",
"sender_token": "v2.<exp>.<max_viewers>.<hmac>",
"sender_secret": "<base64url 16 bytes>",
"viewer_url": "https://app.fernsicht.space/#room=abc...&role=viewer",
"signaling_url": "https://signal.fernsicht.space",
"expires_at": "2026-04-18T12:00:00Z",
"expires_in": 43200,
"max_viewers": 1,
"poll_interval_hint": 25
}Sender polls for pending viewer tickets. Requires the sender_secret from
session creation.
Response:
{
"tickets": [
{"ticket_id": "abc...", "offer": {"type": "offer", "sdp": "…"}}
]
}Viewer submits an offer to join a room.
Body:
{"room_id": "abc...", "offer": {"type": "offer", "sdp": "…"}}Response: {"ticket_id": "…", "status": "queued", "ttl": 25}.
Returns 429 with Retry-After header when room or server is at capacity.
| Method | Path | Caller | Purpose |
|---|---|---|---|
| POST | /ticket/{id}/answer |
Sender | Submit SDP answer (requires secret) |
| GET | /ticket/{id}/answer |
Viewer | Poll for SDP answer |
| POST | /ticket/{id}/ice/sender |
Sender | Submit ICE candidates (requires secret) |
| GET | /ticket/{id}/ice/sender |
Viewer | Fetch sender's ICE candidates |
| POST | /ticket/{id}/ice/viewer |
Viewer | Submit ICE candidates |
| GET | /ticket/{id}/ice/viewer |
Sender | Fetch viewer's ICE candidates |
ICE GET endpoints accept ?since=N for incremental polling.
- Character set:
[A-Za-z0-9_-] - Length: server-configurable (
ROOM_ID_MIN_LEN…ROOM_ID_MAX_LEN)
In V2 the viewer creates the offer and the DataChannel, and the sender creates the answer. This allows the sender to remain connectionless — it only reaches out to the server on its poll schedule.
DataChannel label: fernsicht. Ordered delivery.
ID|<peer_id>
Emitted by the sender once the DataChannel opens.
START|<task_id>|<label>
P|<task_id>|<value>|<elapsed>|<eta>|<n>|<total>|<rate>|<unit>
| Field | Format | Notes |
|---|---|---|
value |
Float 0.0000 – 1.0000 |
4-decimal fraction |
elapsed |
Float seconds (1 decimal) or - |
Time since task start |
eta |
Float seconds (1 decimal) or - |
Estimated seconds remaining |
n |
Integer or - |
Items completed |
total |
Integer or - |
Total items (if known) |
rate |
Float (2 decimals) or - |
Items per second |
unit |
String | it, epochs, files, etc. |
Fields after value are optional. Parsers must treat - as "unknown".
END|<task_id>
K
Sent periodically (every ~20 seconds) to keep the DataChannel warm.
ID → START → P* → END
Senders should send K while idle. Viewers must tolerate duplicate or
malformed frames defensively and ignore anything they don't recognise.