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=dit-1
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=dit-1",
["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=dit-1'
Parameters
stream is required and names what you want.
station_id is the station, by either of its identities: the UUID or the
client_id (dit-1). Both work.
booking_id is optional and binds the socket to one pass. Send it when you
care about a specific booking rather than whatever the station is doing.
Streams
Pass-scoped, relayed from the station:
| stream | carries |
|---|---|
telemetry |
decoded TT&C frames from the spacecraft |
command |
telecommands going up, and the one stream you can send them on |
spectrum |
SDR spectrum samples |
constellation |
IQ constellation samples |
doppler |
doppler shift over time |
ephemeris |
predicted position |
antenna |
azimuth and elevation as the rotator reports them |
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": "dit-1",
"reason": "booking",
"closes_at": "2026-09-22T09:25:31+00:00"
}
station is always the client_id, whichever form you addressed it by.
closes_at is when the grant expires and the socket will be closed from the
server side; it is null for an open-ended session and for the service-wide
streams.
Frames after that
Relayed exactly as the station sent them. Binary stays binary, JSON stays
JSON, and the hub adds no envelope. A telemetry frame is the spacecraft's
own bytes: what they mean is your spacecraft's business, not Umoja's.
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 exists so a client already holding the socket does not have to keep an
HTTP client alongside it purely to transmit. POST /stations/{station_id}/ttc
is unchanged and remains the right call if you are not holding a socket.
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 — the same
two fields, with the same limits, as the HTTP body. 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.
The payload goes to the modem unaltered. The hub does not frame, encode or validate it against any spacecraft.
The answer
Every send is answered with one text frame:
{"type": "ttc.ack", "cmd_id": "acq-7", "ok": true, "data": "..."}
{
"type": "ttc.ack",
"cmd_id": "acq-7",
"ok": false,
"error_code": "command_not_permitted",
"message": "Your organisation is not permitted to transmit from 'dit-1'."
}
error_code and message are the ones the HTTP route would have returned, so
a client that already handles POST /stations/{station_id}/ttc errors can
reuse that handling. ok: true means the modem accepted the command. It is
not an acknowledgement from the spacecraft; there is none.
Commands are served one at a time per socket. Sending the next before the ack arrives does not transmit them in parallel — it queues.
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-pass:
- the transmit permission on the station, which is separate from the reach that lets you read and book it,
- an active booking of your own on that station —
station_not_permittedwithout one, - the command right for that station —
command_not_permittedwithout it.
Holding the antenna is not the same as being allowed to radiate from it.
station_offline means no agent is connected, and upstream_error that the
modem rejected the command.
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 pass 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,
- the booking stops being yours to watch, which includes asking 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 pass 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-pass starts from that moment, so anything already downlinked has to come from the archive:
- TT&C:
GET /bookings/{booking_id}/ttc - Sampled streams:
GET /bookings/{booking_id}/streamsand the chunks under it
Recording runs for the whole pass whether or not anyone is watching, so the archive is complete. See Replaying a pass for reading it back, including how to stitch archived history onto a live view.
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
pass as plain local TCP ports per stream, which is what an MCS expects. See
Realtime & Data.