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:

  1. the transmit permission on the station, which is separate from the reach that lets you read and book it,
  2. an access window of your own on that station, open now and past its transmit_from, without which you get station_not_permitted,
  3. 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}/streams and 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.