Umoja API
The Umoja hub: book passes on ground stations, configure what the modem does during them, and collect what comes back.
Getting a token
Every call carries an OAuth2 client_credentials access token as
Authorization: Bearer <token>.
Your first credential is issued out of band. When Remos provisions your organization you are given a client id and a client secret; there is no public endpoint that mints them, because you would need a token to call it. Start there:
-
Exchange the client id and secret for a token at the realm token endpoint.
securitySchemes.oauth2carries the URL, and the grant isclient_credentials. -
With that token,
GET /api/v1/me/organizationconfirms which organization it acts for and echoes theapi_client_idyou just used. -
To roll the secret later,
POST /api/v1/me/organization/rotatereturns a new one. It is shown once, and the previous secret stops working the moment it is issued, so update your integrations before you call it.
POST /api/v1/me/organization/token is a convenience for a signed-in operator
in the portal: it mints an organization token without you handling the secret.
An integration should use the token endpoint directly.
There are no token scopes. What a caller may do is decided by the grants its organization holds over each station, satellite and mission field, so two tokens from the same realm can have entirely different reach. The answer changes when your grants change rather than when you mint a new token. Ask Remos to widen a grant; minting a fresh token will not.
Every response has the same envelope
{"status": "ok", "message": "...", "data": {...},
"request_id": "9f0c...", "timestamp": 1790000000000}
data holds the payload. timestamp is Unix epoch MILLISECONDS. Quote
request_id when reporting a problem. It appears in the hub's logs and in the
X-Request-ID response header.
Failures use the same envelope with status: "error" and an ErrorPayload in
data. Branch on error_code, never on message: the code is the
contract, the message is prose that may be reworded. The full set of codes is
the ErrorCode enum in the schemas.
Things that will surprise you
- 404 does not mean "absent". A resource another organization owns is indistinguishable from one that does not exist, deliberately, so an id cannot be probed. Where the hub can safely say more, it returns 403.
-
Batch booking answers 207, not 409.
POST /api/v1/passes/bookreturns a row per requested pass; one conflict does not cost you the rest. Check each row'sresult, and note thatpending_syncmeans the pass IS reserved, held for a station that was offline and reconciled when it reconnects. Treating onlybookedas success will make you double-book. -
Nullable fields are absent, not null. Responses are serialised with
exclude_none, so an optional field you were not given is missing from the object rather than present asnull. -
A
pass_idis a handle on an offer; abooking_idis yours to keep. A pass keeps its id across re-predictions, but a pass the station stops listing and lists again, or one whose window moves by more than its own length, comes back under a new id. List passes, then book. Store thebooking_idyou get back and address everything after that by it:/api/v1/bookings/{booking_id}for status and mission parameters, andPOST /api/v1/passes/unbook, whosepass_idsaccepts a booking id, to cancel. Thepass_idon a booking follows the pass it now matches, so a stored copy can go stale. -
Mission parameters lock before AOS. Editing them after the hub has
pushed the configuration to the station returns
409 pass_lockedwithdetails.locks_at.
Idempotency and rate limits
Send Idempotency-Key on any write you may retry; a replayed key returns the
original response with Idempotent-Replay: true. Exceeding the rate limit
returns 429 with Retry-After in seconds.
Operating a satellite end to end
This walks one satellite through a full cycle: keeping its orbit current, finding and reserving a pass, configuring the radio for it, and giving the slot back if plans change. Each step is a couple of calls. The order matters more than the individual requests.
Everything here is scoped to the organisation your token names. Call
GET /me first if you are not sure which that is.
Keep the orbit current
Umoja steers every station from an element set. By default that comes from the mirrored CelesTrak catalog, refreshed by the hub. If you operate the satellite you can override it at any time.
Read what the hub currently holds:
GET /api/v1/satellites/{satellite_id}/tle
{
"status": "ok",
"data": {
"satellite_id": "9b1f...",
"name": "AURORA-1",
"norad_id": "55042",
"tle1": "1 55042U 23001A 26264.17538...",
"tle2": "2 55042 97.4501 210.3312 ...",
"omm": { "OBJECT_NAME": "AURORA-1", "EPOCH": "2026-09-20T04:12:33.120576", "...": "..." },
"operator_set": false,
"updated_at": "2026-09-20T06:00:11Z"
}
}
operator_set is the field to read. False means the satellite is following the
public catalog. True means somebody replaced it here.
To update, send either an OMM record or two TLE lines. Not both, not neither:
PUT /api/v1/satellites/{satellite_id}/tle
Content-Type: application/json
{
"omm": {
"OBJECT_NAME": "AURORA-1",
"NORAD_CAT_ID": 55042,
"EPOCH": "2026-09-21T11:37:02.000000",
"MEAN_MOTION": 15.19472182,
"ECCENTRICITY": 0.0011733,
"INCLINATION": 97.4501,
"RA_OF_ASC_NODE": 210.3312,
"ARG_OF_PERICENTER": 88.1204,
"MEAN_ANOMALY": 272.0417,
"BSTAR": 0.00012345
}
}
Prefer OMM if you have one. Rendering it to two lines and parsing them back drops every field a TLE has no column for.
Do not send a name in the body. The hub fills it from the satellite's own name when it dispatches to a station, because a station catalogs by name and a mismatch creates a second entry there instead of updating the first.
The record has to be SGP4 compatible. Umoja serves every station TLE lines
rendered from it, so elements a different propagator reads would be
propagated as though they were SGP4, which is wrong pointing rather than a
clean refusal. A non-SGP4 MEAN_ELEMENT_THEORY is rejected, as is a record
missing the mandatory elements.
Writing elements requires ownership of the satellite, not just access to it. Access lets you book passes and read the orbit. Ownership lets you change it. Without it you get 403.
Confirm it took, at the hub and at the stations
Read GET /satellites/{satellite_id}/tle back and check operator_set is now
true and updated_at moved. That confirms the hub.
The stations are a separate question, and this is the step people skip. A
station predicts its own passes from the elements it holds, so one carrying an
older set points its antenna on its own answer. PUT .../tle does not push to
anything.
GET /api/v1/satellites/{satellite_id}/elements
{
"status": "ok",
"data": {
"drift": true,
"stations": [
{
"station_id": "930793e4-f9d4-41ef-9750-f9a63d8ad439",
"station_name": "DIT-1",
"epoch": "2026-09-18T02:11:44Z",
"in_sync": false,
"reason": "stale_online_update",
"read_at": "2026-09-21T09:02:00Z",
"stale": false,
"status": "online"
}
]
}
}
drift: true means at least one assigned station disagrees. read_at is
absent when a modem has never been asked, which reason: not_read also says.
Convergence is never automatic. Re-read the modems with
POST /satellites/{satellite_id}/elements/refresh, which changes nothing, and
push with POST /satellites/{satellite_id}/elements/push, which writes the
hub's elements to every assigned station. Both answer with one result row per
station, each with its own ok and detail, so an offline station fails its
own row without failing the others.
Find passes
One endpoint answers both "what is scheduled" and "what is free", because the availability is already resolved onto each row. There is no separate free-slots call.
GET /api/v1/passes?satellite_id=AURORA-1&from=2026-09-22T00:00:00Z&to=2026-09-23T00:00:00Z
Each item carries availability.status, which is one of:
open, meaning bookable now.
booked, meaning reserved. Whose reservation it is, is only disclosed if it
is your organisation's.
unavailable, with a reason: another organisation holds it, the antenna is
busy on a different pass, somebody drove the station directly, an adjacent
booking's turnaround covers it, or a claim from a pass that was stopped part
way.
Filter to what you want with these:
Scheduled passes, all of them: ?scope=org. Just yours: ?scope=me.
Everything reachable, booked or not: ?scope=any, which is the default.
Available slots only: ?status=open.
One ground station: ?station_id=<station-uuid>. The parameter is repeatable
and takes the station's UUID; a client id resolves to no station and would
return an empty page.
A single pass: GET /api/v1/passes/{pass_id}, which adds the reservation and
its realtime window when you may see them.
Paging is by opaque cursor. Send next_cursor back as cursor; a response
without one is the last page. A reversed range, from after to, is a 400
rather than an empty page.
Reserve
POST /api/v1/passes/book
Content-Type: application/json
{ "pass_ids": ["0f4f2b8c-1d2e-4a3b-9c8d-7e6f5a4b3c2d"] }
The response is always 207, even for one id and even when everything
succeeded. Read items, not the status code:
{
"status": "ok",
"data": {
"items": [
{
"pass_id": "0f4f2b8c-...",
"result": "booked",
"booking_id": "45d5fbb6-...",
"starts_at": "2026-09-22T09:14:02Z",
"ends_at": "2026-09-22T09:25:31Z"
}
]
}
}
booked and pending_sync both hold the slot. The second means the station
was unreachable and the hub reserved it anyway; the reconnect reconciler
pushes it when the station returns. Everything else reserved nothing. The
values you will actually hit are conflict, already_booked and
station_refused.
Two of the refusals are about windows and are worth understanding once. A
satellite that rises and sets already has a predicted window, so sending your
own session gets window_not_allowed. A continuously visible satellite has
no window of its own and must carry one, so omitting it gets
window_required.
Keep the booking_id. Everything from here on is addressed by it.
You can book and configure in one call by including mission_config in the
book request, which saves both in the same transaction. Useful when a pass is
close and you do not want the configuration to land after the prepass push.
Configure the radio
Read what the pass will fly with:
GET /api/v1/bookings/{booking_id}/mission-params
The response has four parts and they answer different questions.
config plus the named fields are this pass's own overrides, and only what
was set here.
resolved is what will actually be sent, after the satellite default and the
station binding are merged underneath those overrides. Read this to know what
the modem gets. resolved_from names the layers it came from.
editable_fields is which paths you may set at this station. The catalog is
per station and per policy, so it is not the same list everywhere.
editable, locks_at and lock_reason say whether it can still change.
Write with a PATCH. Sending config replaces this pass's configuration
wholesale; anything previously set and not sent again is cleared, and
report.cleared lists exactly what that removed. The named fields are
individual, so omitting one leaves it alone.
PATCH /api/v1/bookings/{booking_id}/mission-params
Content-Type: application/json
{
"config": {
"telemetry": { "fec": "RS_255_223", "scrambler": "CCSDS" },
"rxdsp": { "frequency_hz": 2245000000 }
},
"downlink_bitrate_bps": 9600
}
Nothing is written unless every field is permitted. A field you may not set at
that station fails the whole request with 403 and
error_code: mission_field_not_permitted, carrying the per-field report. A
half-applied configuration would be worse than a refusal.
Add ?dry_run=true to run every check and write nothing. The response is the
same shape, report included, so you can show what a save would do before
offering it.
The lock
Configuration is editable until the scheduler pushes it to the station ahead
of the contact. After that a write is 409 with error_code: pass_locked and
details.locks_at telling you when that happened. There is no override: the
station is already staged for the contact, and the only way to change anything
is live on the station through
PATCH /api/v1/stations/{station_id}/transceiver, which needs an active pass
you hold.
So the working order is: book, configure, then let the lock fall. If you are scripting this, do the PATCH immediately after the book rather than close to AOS.
Recording has its own route because it is toggled separately:
PATCH /api/v1/bookings/{booking_id}/recorder
{ "enabled": true }
null removes the override and returns the pass to the satellite's
organisation-wide default, which is not the same as false. Set that default
with PUT /api/v1/satellites/{satellite_id}/recording-default.
Give the slot back
POST /api/v1/passes/unbook
Content-Type: application/json
{ "pass_ids": ["0f4f2b8c-1d2e-4a3b-9c8d-7e6f5a4b3c2d"] }
Also always 207, one row per id. Accepts pass ids or booking ids.
unbooked released it. not_booked means nothing was holding it.
station_refused means the station would not let go and the booking still
stands, which is deliberate: the slot is not freed while the modem is still
committed to it. ambiguous means a continuously visible satellite is holding
more than one live session and naming the pass does not say which to release,
so cancel that one by booking id.
Cancelling a pass that is already running stops it. The station stands down, the record is trimmed to what actually flew, and the unused remainder of the window goes back on the market.
After the pass
The recording is addressed by booking id, not pass id. See the Replaying a pass guide.
Realtime & Data Integration
Three ways to get pass data out of Umoja programmatically:
-
portal-link: a small client that exposes the live pass as local TCP ports for a Mission Control System (Yamcs, COSMOS/OpenC3, Gpredict, scripts). -
The realtime WebSocket:
/ws/realtime, the same live feed the portal itself consumes. - The mission-data API: list and download recordings after a pass.
All three sign in with your Umoja account or your organisation's API client and show only your organisation's passes, the same as the portal.
portal-link (TCP bridge)
portal-link opens local TCP ports on your machine so an MCS can exchange
telemetry and telecommands with the hub during a pass. It speaks the hub's TCP
bridge protocol and handles sign-in for you.
Quick start
You need your station id, your organisation's API client id (api-<company>)
and its secret. In the portal-link/ directory:
cp .env.example .env # fill in PORTAL_LINK_STATION, PORTAL_LINK_CLIENT_ID, PORTAL_LINK_CLIENT_SECRET
docker compose up -d --build
docker compose logs -f
Or with the binary for your platform:
./portal-link --env-file .env
portal-link opens the stream ports on localhost, watches the station's
bookings, connects to the hub 4.5 minutes before each pass and keeps running
until stopped. Point your MCS at localhost:10025 (TM) and localhost:10026
(TC). Run ./portal-link -h for all flags; see the portal-link/README.md
for MCS configuration examples.
Streams and ports
Each requested stream is exposed on its own local TCP port. Multiple tools may connect to the same port at once. Every connection gets a copy of the stream.
| Stream | Default port | Direction | Purpose |
|---|---|---|---|
tm |
10025 | hub → MCS | Telemetry frames from the satellite. |
tc |
10026 | MCS → hub | Telecommand frames to the satellite. |
doppler |
10027 | hub → MCS | Doppler shift updates during the pass. |
ephemeris |
10028 | hub → MCS | Predicted trajectory for the pass. |
antenna |
10029 | hub → MCS | Antenna pointing telemetry (az/el). |
command |
10030 | hub → MCS | Echo of telecommands as they leave the radio. |
Request more than the default tm,tc by listing them:
./portal-link \
--hub bridge.umoja.remosspace.com:10100 \
--station <your-station-id> \
--kc-url https://umoja.remosspace.com/login/realms/umoja \
--streams tm,tc,antenna,doppler,ephemeris,command
MCS configuration examples
Yamcs
dataLinks:
- name: remos-tm
class: org.yamcs.tctm.TcpTmDataLink
host: localhost
port: 10025
stream: tm_realtime
- name: remos-tc
class: org.yamcs.tctm.TcpTcDataLink
host: localhost
port: 10026
stream: tc_realtime
COSMOS (OpenC3)
INTERFACE REMOS_TM_INT tcpip_client_interface.rb localhost 10025 10025 10.0 nil
INTERFACE REMOS_TC_INT tcpip_client_interface.rb localhost 10026 10026 10.0 nil
Custom script (Python)
import socket
# Receive TM
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("localhost", 10025))
while True:
data = sock.recv(4096)
if not data:
break
process_telemetry(data)
# Send TC
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("localhost", 10026))
sock.sendall(bytes.fromhex("A1B2C3D4"))
Realtime WebSocket
If you'd rather consume the feed directly (the same one the portal uses), open a WebSocket per stream:
wss://<host>/ws/realtime?station_id=<station-uuid>&stream=<stream>[&booking_id=<uuid>]
Authenticate by sending your Keycloak bearer token as a WebSocket subprotocol:
new WebSocket(url, ["bearer", "<your-jwt>"])
Available streams: spectrum, constellation, doppler, ephemeris,
antenna, telemetry, command.
Frames are binary, prefixed with a small header: a 2-byte magic (EX), a
1-byte stream tag, and the 16-byte station client ID (NUL-padded), followed by
the stream payload (e.g. spectrum is 1024 × [freq, power] float32 pairs). The
hub refreshes nothing for you: keep your token fresh and reconnect on drop.
Mission-data API
After a pass, list and download its recordings over REST. Send your Keycloak
token as Authorization: Bearer <jwt>.
| Endpoint | Method | Purpose |
|---|---|---|
/api/data/files |
GET | List files (filter by station_id, booking_id, status, from_ts/to_ts, search; group_by_pass). |
/api/data/files/summary |
GET | Aggregate counts and bytes. |
/api/data/files/{file_id}/link |
POST | Mint a short-lived signed download URL for one file. |
/api/data/bookings/{booking_id}/link |
POST | Mint a signed URL for the whole pass as a ZIP bundle. |
Minting a link, then downloading, keeps the long-lived token off the wire:
# 1. mint a signed URL (bearer token)
curl -X POST https://umoja.remosspace.com/api/data/files/$FILE_ID/link \
-H "Authorization: Bearer $TOKEN"
# -> { "download_url": "...", "expires_in": 900 }
# 2. download (the signed URL carries its own token)
curl -L -o capture.iq "<download_url>"
See the REST API tab for the full, interactive reference.
Recorded pass data (replay)
Every completed pass is recorded, and the same data Umoja's replay view renders
is available over /api/v1 so you can pull it into your own MCS and build your
own visualization. You only ever see your own organisation's passes. Send
Authorization: Bearer <jwt>.
Two data classes are recorded:
- TT&C frames (telemetry and command) are the raw wire frames as relayed during the pass, returned base64 in JSON.
- Graph streams (spectrum, constellation, antenna, doppler, ephemeris) are the demodulated graph frames the live console renders, archived as binary chunks. They are not raw IQ. Umoja archives per-frame demodulated products (FFT bins, IQ constellation points, az/el), rate-capped at 5 fps.
Start with the manifest, which inventories everything recorded for a pass:
# `pass_id` is the pass UUID from the bookings endpoints.
curl https://umoja.remosspace.com/api/v1/passes/$PASS_ID/replay \
-H "Authorization: Bearer $TOKEN"
| Endpoint | Purpose |
|---|---|
GET /api/v1/passes/{id}/replay |
Discovery manifest: TT&C counts, per-stream chunk inventory, endpoints, tle_url, and a self-describing formats block. |
GET /api/v1/passes/{id}/ttc?after=&limit= |
TT&C frames, cursor-paginated (page until next_after is null). |
GET /api/v1/passes/{id}/streams |
List archived graph-stream chunks per stream. |
GET /api/v1/passes/{id}/streams/{stream}/{chunk} |
One chunk as application/octet-stream (raw binary, no JSON envelope). |
GET /api/v1/satellites/{satellite_id}/tle |
The pass's TLE, to compute the predicted track yourself (SGP4). |
A pass that has not flown yet has no archive. The endpoints return empty
payloads for it rather than 404.
Decoding the binary streams
Each stream chunk is a sequence of records, back to back:
[ capture_ms : u64 big-endian ][ payload_len : u32 big-endian ][ payload : payload_len bytes ]
capture_ms is the Unix epoch millisecond of capture. Read the 12-byte header,
then payload_len payload bytes, repeat to end of chunk. Payloads (multi-byte
numbers little-endian unless noted):
| Stream | Payload |
|---|---|
spectrum |
1024 bins × [frequency_hz: f32][power: f32] (8 bytes/bin, 8192 bytes/frame). |
constellation |
1024 samples × [I: f32][Q: f32]. Demodulated constellation points, not raw IQ. |
antenna |
Az/el frame: JSON {"azimuth": deg, "elevation": deg}, or an Expedite binary frame (≥36 bytes) with azimuth = f32 at byte 28, elevation = f32 at byte 32. |
doppler, ephemeris |
Vendor demodulated graph frames. Listed, but the payload layout is experimental. |
TT&C frame_b64 decodes to the raw wire frame, which you parse with your own
TM/TC parser. Pre-2026 recordings may carry a 19-byte station envelope ("EX" =
0x45 0x58, a type byte 0x07/0x08, then a 16-byte client id); strip it if
bytes[0..1] == 0x45,0x58.
The predicted elevation and ground track are not stored. Fetch the TLE
(satellites/{id}/tle) and propagate it with SGP4, then overlay the antenna
stream for the actual measured track.
Session window
A live session (portal-link or WebSocket) is tied to a booking and only flows during its window:
- Earliest: AOS − 5 minutes. Latest: LOS.
-
If you connect before the window opens, the hub answers
AUTH_FAIL:pass_not_started. Keep retrying and the session opens when the window does. portal-link does this for you. - At LOS the hub sends a
pass_endedevent and closes the session.
Troubleshooting
| Symptom | Cause | What to do |
|---|---|---|
AUTH_FAIL:invalid_token |
Your token expired or is malformed. | Re-run portal-link or refresh your token. |
AUTH_FAIL:pass_not_started |
Your booking exists, but its window has not opened yet. | Wait. The window opens 5 minutes before AOS, and portal-link keeps retrying until then. |
AUTH_FAIL:pass_ended |
The booking's window has closed, or the pass is already marked as completed. | Nothing more will flow for this booking. Connect to your next pass instead. |
AUTH_FAIL:booking_cancelled |
The booking was cancelled, a cancel is in progress, or the station dropped or refused the pass. | Check the booking's status under Bookings. |
AUTH_FAIL:no_such_booking |
The hub found no booking with that id on that station that you can use. | Check the booking id and the station. The hub gives this same answer whether the booking is missing, on another station or belongs to someone else, so it cannot tell you which. |
AUTH_FAIL:unknown_station |
The station does not exist, or your organization has no access to it. | Check the station under Stations. |
AUTH_FAIL:invalid_station_id |
The handshake's station_id was not a UUID. |
portal-link takes it from the booking, so this points to a mismatch between hub and client versions rather than a wrong --station. |
AUTH_FAIL:transmit_not_permitted |
You asked for the TC stream, but your organization may not transmit from this station. | Ask Umoja support for the transmit right, or connect without TC. |
AUTH_FAIL:invalid_json, missing_fields, envelope_too_large |
The handshake envelope was malformed. | Usually a mismatch between hub and client versions. Update portal-link. |
AUTH_FAIL:server_full |
The bridge has too many sessions open. | Retry shortly, or contact support. |
AUTH_FAIL:rate_limited |
Your address failed the handshake too many times in a short period. | Fix whatever made the earlier attempts fail, then wait a few minutes before you retry. |
| TM stops mid-pass | The network dropped, or the pass reached LOS. | portal-link reconnects on its own until LOS. |
| TC not delivered | The station is offline. | Check the station's live state under Stations. |
Orbital Elements (TLE / OMM)
Umoja tracks every satellite from an element set. By default that set comes from CelesTrak's GP catalog, polled by the hub every six hours in OMM form. If you operate the satellite, you can replace it at any time, before, between or during passes, and Umoja will steer the ground stations on your set.
Who can set elements
Setting elements requires ownership of the satellite, which Remos grants
alongside booking access. Ask your contact if you need it. Booking access
alone lets you schedule passes and read the current elements; it does not let
you change them (403 satellite_not_owned).
GET /api/v1/satellites/{satellite_id} answers whether you have it: owned
is true for a satellite your organization owns, and can_edit is what to gate
an edit control on.
Formats
Send either classic TLE lines or an OMM object to
POST /api/v1/satellites/{satellite_id}/tle:
{ "tle1": "1 25544U 98067A 24112.50000000 .00012345 00000-0 22663-3 0 9992",
"tle2": "2 25544 51.6400 123.4567 0008721 21.6789 338.5234 15.49321800123453" }
{ "omm": {
"NORAD_CAT_ID": 25544, "EPOCH": "2024-04-21T12:00:00.000000",
"MEAN_MOTION": 15.493218, "ECCENTRICITY": 0.0008721, "INCLINATION": 51.64,
"RA_OF_ASC_NODE": 123.4567, "ARG_OF_PERICENTER": 21.6789, "MEAN_ANOMALY": 338.5234,
"BSTAR": 0.00022663, "MEAN_MOTION_DOT": 0.00012345 } }
-
OMM uses the CCSDS / CelesTrak GP JSON field names (case-insensitive).
NORAD_CAT_IDis a plain integer, so 6-digit catalog numbers need no special encoding. Optional fields (OBJECT_NAME,OBJECT_ID,CLASSIFICATION_TYPE,EPHEMERIS_TYPE,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT) default sensibly when absent. -
TLE lines must be 69 characters with valid modulo-10 checksums. Catalog
numbers above 99999 use Alpha-5 in columns 3-7 (
A0000= 100000,Z9999= 339999; letters I and O are skipped).
The catalog number in the element set must match the satellite in the URL.
What happens when you set elements
-
The hub stores your set and serves it from every read
(
GET /api/v1/satellites/{id}/tlereturnssource: "override", plus the set in both TLE and OMM form). It outranks the CelesTrak feed and survives the feed refresh. -
The hub pushes the set into every online ground station that tracks your
satellite, so the antenna steers on it from the next pass. The response's
pushedarray reports each station; a station that is offline receives the set automatically when it reconnects.
Reading elements never requires ownership. GET .../tle needs only booking
access and api:read.
Replaying a pass
Everything a pass produced is addressed by its booking, never by the pass.
This is the part that trips people up, so it is worth stating plainly before
anything else: a pass is a prediction, a booking is the reservation that flew
it, and a pass flown twice has two recordings. If you hold a pass id, resolve
it to a booking id first with GET /passes/{pass_id} and read booking.id.
There are two kinds of recording and they live in different places.
TT&C frames are decoded telemetry and telecommand, stored row by row in the
hub. You read them through GET /bookings/{booking_id}/ttc.
Graph streams are the sampled feeds (antenna pointing, doppler, spectrum and
so on), stored as binary chunks in object storage. You list them with
GET /bookings/{booking_id}/streams and fetch each chunk individually.
A pass can have one, both, or neither. An empty list is not an error.
Start with the summary
GET /bookings/{booking_id}/replay is an index. It tells you what exists
before you fetch any of it:
GET /api/v1/bookings/0b8bb813-20be-451d-8df0-cc20809a6da7/replay
Authorization: Bearer <token>
{
"status": "ok",
"data": {
"booking_id": "0b8bb813-20be-451d-8df0-cc20809a6da7",
"starts_at": "2026-09-21T21:29:18Z",
"ends_at": "2026-09-21T21:40:37Z",
"ttc": [
{
"stream": "telemetry",
"frames": 119,
"first_at": "2026-09-21T21:29:49.396Z",
"last_at": "2026-09-21T21:39:30.192Z"
}
],
"graph_streams": ["antenna", "constellation", "doppler", "ephemeris", "spectrum"]
}
}
ends_at is null while an open-ended session is still running. Until it is
released there is no end to draw a scrubber against.
If the booking is not one you can reach, this answers 404 rather than 403. Whether somebody else's pass exists is not yours to learn.
Reading TT&C frames
Paging here is by cursor, not by offset. Take next_after from the response
and send it back as after. A response without one is the last page.
GET /api/v1/bookings/{booking_id}/ttc?after=0&limit=2000
{
"status": "ok",
"data": {
"frames": [
{
"id": 84021,
"stream": "telemetry",
"captured_at": "2026-09-21T21:29:49.396Z",
"frame_b64": "GgAAAB4AAAB..."
}
],
"total": 119,
"next_after": 84520
}
}
Three things about this response are easy to get wrong.
total is sent on the first page only, when you omit after. It does not
change while a finished pass is read back, so keep the number you were given
instead of asking again. Counting frames is the most expensive part of the
request and a pass can hold tens of thousands.
frame_b64 is the raw frame, base64 encoded. The hub does not parse it. What
the bytes mean is your spacecraft's business.
captured_at is the modem's own timestamp, not when the hub stored it. Use it
to place the frame inside the pass. Only frames captured within the recording
window of this booking come back, which matters on a shared dish: a station
flushes its whole decoded backlog when its agent reattaches, and another
organisation's contact must not be served here.
Narrow to one stream with ?stream=telemetry if you only want one side of the
conversation.
Reading graph streams
GET /bookings/{booking_id}/streams returns the chunk inventory, grouped by
stream:
{
"status": "ok",
"data": {
"streams": {
"antenna": [
{ "chunk": "1790026072167-00000.bin", "size_bytes": 1126, "start_ms": 1790026072167 },
{ "chunk": "1790026093162-00001.bin", "size_bytes": 1126, "start_ms": 1790026093162 }
]
}
}
}
Chunk names sort chronologically, so the listing is the timeline. A player
scrubbing to a particular moment picks the chunk covering it rather than
pulling the whole pass to show one second of it. The name is
{start_ms:013d}-{seq:05d}.bin and start_ms is repeated in the metadata so
you never have to parse it.
Fetch one with:
GET /api/v1/bookings/{booking_id}/streams/antenna/1790026072167-00000.bin
The body is application/octet-stream, not JSON. Records repeat until the end
of the body:
[u64be capture_ms][u32be length][payload of `length` bytes]
Decoding one in Python:
import struct
def records(blob: bytes):
offset = 0
while offset < len(blob):
captured_ms, length = struct.unpack_from(">QI", blob, offset)
offset += 12
yield captured_ms, blob[offset:offset + length]
offset += length
Sharing a recording
To hand files from a pass to somebody outside Umoja, mint a link:
POST /api/v1/bookings/{booking_id}/files/link
Content-Type: application/json
{ "expires_seconds": 3600 }
The response carries a URL with a signed token in its query string. It needs no bearer credential, so treat it as a credential itself: anyone holding the URL can download those files until it expires.
The file set is fixed and signed into the token when you mint it. It is never
"whatever the pass holds when the link is opened", so a file uploaded
afterwards is not included, and the link can never reach further than you
could at the moment you asked. Send file_ids to choose a subset. There is no
revocation, so keep expires_seconds short.
When a replay is empty
A booking with no TT&C and no chunks usually means the pass recorded nothing, not that something was lost. The common causes are a recorder that was off for that pass, or a station that never had its configuration pushed.
Check recorder_enabled under resolved in
GET /bookings/{booking_id}/mission-params to see what the pass was actually
told to do. Recording defaults to on unless somebody turned it off for the
satellite.