Contact us

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:

  1. the transmit permission on the station, which is separate from the reach that lets you read and book it,
  2. an active booking of your own on that station — station_not_permitted without one,
  3. the command right for that station — command_not_permitted without 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}/streams and 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.