Contact us
v1.0.0
OpenAPI 3.1.0

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:

  1. Exchange the client id and secret for a token at the realm token endpoint. securitySchemes.oauth2 carries the URL, and the grant is client_credentials.
  2. With that token, GET /api/v1/me/organization confirms which organization it acts for and echoes the api_client_id you just used.
  3. To roll the secret later, POST /api/v1/me/organization/rotate returns 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/book returns a row per requested pass; one conflict does not cost you the rest. Check each row's result, and note that pending_sync means the pass IS reserved, held for a station that was offline and reconciled when it reconnects. Treating only booked as 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 as null.
  • A pass_id is a handle on an offer; a booking_id is 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 the booking_id you get back and address everything after that by it: /api/v1/bookings/{booking_id} for status and mission parameters, and POST /api/v1/passes/unbook, whose pass_ids accepts a booking id, to cancel. The pass_id on 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_locked with details.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:

  1. portal-link: a small client that exposes the live pass as local TCP ports for a Mission Control System (Yamcs, COSMOS/OpenC3, Gpredict, scripts).
  2. The realtime WebSocket: /ws/realtime, the same live feed the portal itself consumes.
  3. 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 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_ended event 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_ID is 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

  1. The hub stores your set and serves it from every read (GET /api/v1/satellites/{id}/tle returns source: "override", plus the set in both TLE and OMM form). It outranks the CelesTrak feed and survives the feed refresh.
  2. 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 pushed array 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.

Client Libraries

Identity

​

Who the caller is, and which organization they act for.

Identity Operations

The caller, as their token proves them.

​

Who the bearer token says you are, and what it is scoped to.

The answer describes the TOKEN, not a resource, so it needs no permissions beyond a valid credential. It is the right first call after obtaining one: organization.id is the tenant every other endpoint scopes to, and everything you can read or write follows from it.

organization is what this request ACTS AS and home_organization is what the credential itself names. They are equal unless the caller has switched into another organization they are permitted to act for, in which case reads and writes follow organization.

is_admin says whether this caller may switch at all, and is false for an ordinary API credential. It does not say this particular request carries that authority; that follows organization.

Both ids come back with the display name beside them, so a client never has to resolve a bare UUID against a directory it cannot reach.

Responses
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
  • application/json
Request Example for get/api/v1/me
curl /api/v1/me \
  --header 'Authorization: Bearer YOUR_SECRET_TOKEN'
{
  "data": {
    "client_origin_id": "string",
    "home_organization": {
      "id": "string",
      "name": "string"
    },
    "is_admin": true,
    "organization": {
      "id": "string",
      "name": "string"
    },
    "user": {
      "id": "string",
      "name": "string"
    },
    "username": "string"
  },
  "message": "string",
  "request_id": "string",
  "status": "string",
  "timestamp": 1
}

Organizations (Collapsed)

​

Your own organization: its members, its API client, and rotating that client's secret.

CelesTrak (Collapsed)

​

Umoja's mirror of the public catalogue, for finding an object by name or NORAD number when registering it.

Stations (Collapsed)

​

Passes (Collapsed)

​

Predicted contact windows, their availability, and why one is unavailable.

Notifications (Collapsed)

​

Events addressed to you: a station going offline, a pass trimmed, elements diverging.

Announcements (Collapsed)

​

Operator-wide notices and release notes, published by Umoja.

System (Collapsed)

​

Hub health and the counts scoped to your own fleet.

System Operations

Models