The realtime WebSocket
Live data does not come over the REST API. It comes over one socket,
/ws/realtime, with the stream you want chosen by query parameter.
None of this appears in the OpenAPI reference, and it never will: OpenAPI describes request and response pairs and has no vocabulary for a long-lived socket. This page is the contract for that socket.
The socket is receive-only on every stream but one. command is two-way: you
can send a telecommand up it as well as watch the ones that go out. See
Sending a telecommand.
wss://umoja.remosspace.com/ws/realtime?stream=telemetry&station_id=<station-uuid>
Authenticating
Your token goes in the WebSocket subprotocol, not a header and not the query string:
Sec-WebSocket-Protocol: bearer, <access token>
A browser cannot set an Authorization header on a handshake, and a token in
the query string is written into every proxy access log on the way. The server
echoes the chosen subprotocol back on accept, which browsers require.
In JavaScript the second argument to the constructor is the subprotocol list:
const ws = new WebSocket(
"wss://umoja.remosspace.com/ws/realtime?stream=telemetry&station_id=<station-uuid>",
["bearer", accessToken],
);
A missing or invalid token closes the socket with 1008 and a reason of
no token or bad token. Station agents authenticate somewhere else entirely
(/ws/agent, with their own secret); an agent credential is refused here.
Outside a browser (wscat, Postman, a Python client) set the header yourself
on the handshake. It is the only credential this socket accepts:
there is no query-string token, no Authorization header, and no separate
step once the socket is open. A telecommand sent up the socket is authorised
against this same token on every message, so nothing else has to be attached
to the message itself.
wscat -H 'Sec-WebSocket-Protocol: bearer, <access token>' \
-c 'wss://umoja.remosspace.com/ws/realtime?stream=command&station_id=<station-uuid>'
Parameters
stream is required and names what you want.
station_id is the station's UUID. Its client_id is the credential its
agent authenticates with, not a second address, and is not accepted here.
access_window_id is optional and binds the socket to one access window.
Send it when you care about a specific session rather than whatever the
station is doing. It is id in GET /api/v1/access-windows, and a booking
names the window it holds as access_window_id.
Streams
Window-scoped, relayed from the station:
| stream | carries |
|---|---|
telemetry |
frames decoded from the spacecraft, each in an Expedite telemetry packet (type 0x00) |
command |
each telecommand as the station handed it to the modem (type 0x01) and the modem's acknowledgement (0x02); the one stream you can send on |
spectrum |
SDR spectrum samples |
constellation |
IQ constellation samples |
doppler |
the modem's predicted Doppler shift (type 0x06 packets) |
ephemeris |
the modem's predicted azimuth and elevation (type 0x03 packets) |
antenna |
azimuth and elevation as the rotator reports them (type 0x03 packets) |
Service-wide, available to any authenticated caller with no station:
| stream | carries |
|---|---|
station-status |
a station going online or offline, with a reason |
station-bookings |
a signal that the booking calendar moved |
notifications |
notifications addressed to you |
For spacecraft telemetry, stream=telemetry is the one you want.
The first frame
On accept, the server sends one JSON frame before any data:
{
"type": "ready",
"stream": "telemetry",
"station": "6f1c2a9e-4b7d-4c1e-9a3f-2d8b5e7c1a40",
"reason": "window",
"closes_at": "2026-09-22T09:25:31+00:00",
"transmit_from": "2026-09-22T09:15:31+00:00"
}
station is the station's UUID, the one you passed as station_id. It is
null on the service-wide streams.
reason says what the socket was granted on: window for a stream served
inside an access window, station for the antenna stream, and broadcast for
the service-wide streams.
closes_at is when the grant expires and the socket will be closed from the
server side; it is null for the service-wide streams.
transmit_from is when uplink opens on this window. A booked pass's window
opens a turnaround before the pass so you can watch the dish slew on, and
takes telecommands only from the pass's start. It is null when the window
sets no such instant, and on streams that carry no uplink.
Frames after that
Station data arrives as binary messages, with nothing added in front. On
telemetry, command, antenna, doppler and ephemeris each message is
one complete Expedite packet, exactly as the station's modem emitted it. On
telemetry, your spacecraft's own bytes are that packet's data, and what they
mean is your spacecraft's business, not Umoja's. Expedite packets
shows how to unwrap them. On spectrum each message is 1024 little-endian
float32 pairs [frequency_hz, power], and on constellation 1024 pairs
[I, Q]. That is the payload of one archived record, without the record's
12-byte header; see Decoding the binary streams.
On these streams the only text messages are ready and, on command,
ttc.ack.
Sending a telecommand
On stream=command, and only there, the socket carries traffic in both
directions. Anything you send on another stream is ignored.
This is the only way to transmit. There was an HTTP route for it,
POST /stations/{station_id}/ttc; it was a second transport to the same
station behind the same checks, and it has been removed. Open the socket on
stream=command and send on it.
Two forms are accepted. As text, a JSON envelope:
{
"type": "ttc.send",
"format": "HEX",
"data": "1ACFFC1D0100000A",
"cmd_id": "acq-7"
}
format is HEX for a binary telecommand (data being its hex digits) or
TEXT for an ASCII payload, and data is at most 65536 characters. They are
the same two fields, with the same limits, as the HTTP body. The hub does not
check the hex digits: the station skips anything that is not a hex digit and
drops an odd last one, so check them before you send. cmd_id is your own label
for the send and is echoed back on the answer; the server invents one if you
omit it.
As binary, the raw telecommand bytes, with no envelope. It is transmitted
as HEX exactly as if you had hex-encoded it yourself. One message is one
telecommand, of at most 32768 bytes.
Send the payload only, never a complete Expedite packet. The hub does not frame, encode or validate it against any spacecraft. The station wraps it in an Expedite telecommand packet, and the modem then applies the satellite's packet, FEC and scrambler settings. A payload you have wrapped yourself is wrapped twice, and the modem refuses it. The modem also takes at most 16368 bytes, and drops a longer telecommand. See Expedite packets.
The answer
Every send is answered with one text frame:
{"type": "ttc.ack", "cmd_id": "acq-7", "ok": true, "data": {"ok": true}}
{
"type": "ttc.ack",
"cmd_id": "acq-7",
"ok": false,
"error_code": "command_not_permitted",
"message": "Your organisation is not permitted to transmit from 'Dithmarschen 1'."
}
error_code and message are the same values the rest of the API uses for
these conditions, so existing error handling applies. ok: true means the
station handed the telecommand to the modem, not that the modem could encode
it. The modem's verdict follows on this stream as a binary acknowledgement
packet (type 0x02), whose status is 0 when the command was queued for the
transmitter, 1 for a length or CRC failure, 2 for an FEC failure and 3
for a data-format failure. Neither is an acknowledgement from the spacecraft;
there is none.
Commands are served one at a time per socket. Sending the next before the ack arrives queues it rather than transmitting the two in parallel.
What is checked
The same three gates as the HTTP route, re-read on every message rather than at connect, because a licence can be withdrawn mid-session:
- the transmit permission on the station, which is separate from the reach that lets you read and book it,
- an access window of your own on that station, open now and past its
transmit_from, without which you getstation_not_permitted, - the command right for that station, without which you get
command_not_permitted.
Holding the antenna is not the same as being allowed to radiate from it.
may_transmit on the window (GET /api/v1/access-windows/{access_window_id})
says whether you hold the transmit right on its station, so a client can tell
before anyone types a command. It does not say that uplink is open: that is
transmit_from.
station_offline means no agent is connected, or its answer was lost.
upstream_error means the station could not hand the command to the modem,
or no longer holds your window. The modem's own verdict is the type 0x02
acknowledgement on the stream.
Ten malformed frames in one socket closes it with 1008 and a reason of
too many bad frames.
When the socket closes
Access is re-checked while the socket is open, not only at connect, and the close happens at the end of the granted window rather than on the next poll after it. A window ends while somebody is watching, and the socket has to go when the antenna stops being theirs, or a viewer who connected legitimately would keep receiving the next organization's downlink.
You will be closed when:
- the granted window ends, or is released before its end,
- the hub suspends the window. On a booked pass that happens as soon as somebody asks to cancel it: a cancel that has been requested ends your claim on the console even though the slot is still held against other tenants until the station confirms,
- you exceed the per-caller concurrent socket cap.
Reconnect on close if the window should still be running. Do not treat a close as an error on its own.
Getting what you missed
The socket only carries what arrives while you are connected. A refresh mid-session starts from that moment, so anything already downlinked has to come from the archive:
- TT&C:
GET /access-windows/{access_window_id}/ttc - Sampled streams:
GET /access-windows/{access_window_id}/streamsand the chunks under it
These take the access_window_id you bind the socket with. The hub archives TT&C
and the track streams for the whole window whether or not anyone is watching,
so the history up to now is there. See Replaying a session for
reading it back.
Not a browser
For a Mission Control System (Yamcs, COSMOS/OpenC3, Gpredict) use
portal-link instead of this socket. It signs in for you and exposes the
session as plain local TCP ports per stream, which is what an MCS expects. See
Realtime & Data.