The HTTP API is how people and programs drive Stellar Control: the CLI, the web console and the editor all go through it. It submits direct telecommands, launches and follows runs, answers their questions, acknowledges alarms, receives passes, schedules runs, transfers files, and describes the topology and its state. This page explains its conventions and walks through the main flows; the API Reference lists every operation with its schemas.
Base URL and versions#
Every API instance of a cell serves the API under /v1, on api.listen (0.0.0.0:8080 by
default): http://localhost:8080/v1/… on a development machine. In a deployment, a reverse
proxy terminates HTTPS and WSS in front of it, one sub-domain per cell. The CLI takes the base
URL with --api or STELLAR_API.
- Stateless. The API resolves each request against the current compiled configuration and
publishes it on NATS. Any instance answers any request; instances of the same host share their
port (
SO_REUSEPORT). - One version. Every route is under
/v1. An unknown path under/v1answers404 api::unknown-route, never a page of the web console served beside it. - Its own description.
GET /v1/openapi.jsonreturns the OpenAPI 3.1 document of the API, generated from its code; the reference of this site is rendered from it. - Editor.
/v1/editor/…is relayed to the web editor service whenapi.editor_urlis set (404 editor::disabledotherwise); its routes are the editor's. See Web Editor.
Identity#
Callers identify themselves in one of two ways:
| Way | Headers | Accepted |
|---|---|---|
| OIDC token | Authorization: Bearer <JWT> | Everywhere; checked against the key set of the provider (auth.jwks_file), iss, aud, expiry. Roles are read at auth.roles_claim. |
| Declared identity | X-Stellar-User: <name>, X-Stellar-Role: operator,supervisor | Where the environment of the run allows it: no human orchestration, or identity: declared |
A token wins over a declared identity. What each environment requires, and which requests need
an identity (launching a run, answering, controlling a run, commanding an alarm, validating a
schedule…), is described in Identity and Roles. Refusals are 401
(auth::invalid-token, auth::jwt-required, api::anonymous) or 400 api::anonymous for a
command that needs a name.
# Declared identity, in an environment that accepts it
curl -H 'X-Stellar-User: alice' -H 'X-Stellar-Role: operator' http://localhost:8080/v1/runs
# OIDC token
curl -H "Authorization: Bearer $STELLAR_TOKEN" http://localhost:8080/v1/runsRequests and replies#
- Bodies are JSON (
Content-Type: application/json), exceptPOST /v1/uploads(raw bytes) andGET /v1/runs/{id}/report(HTML). Unknown fields in a request body are refused (400 api::invalid-body, or422with the code of the domain). - Quantities are written as in run requests and procedures:
"28 V","30 min","47.3 deg","64 kbps". They are converted into the unit expected, and refused when not compatible. - Times are RFC 3339 in UTC; identifiers of telecommands and runs are ULIDs.
- Errors answer a status and
{"errors": [{code, message, help}]}: see Error Codes. 503 api::unavailablemeans the bus (NATS) is unavailable;503 api::no-configurationthat no compiled configuration has been published yet (stellar compile --publish).
Asynchronous commands: 202#
Commands that the MCS carries out over time answer 202 Accepted once the request is
resolved and published, not when it is done:
| Request | 202 body | Follow with |
|---|---|---|
POST /v1/tc | {id, duplicate} | GET /v1/tc/{target}/{id}/events (WebSocket) |
POST /v1/targets/{target}/files/refresh | {id, duplicate} | the same |
POST /v1/runs | {run, ir, warnings} | GET /v1/runs/{id}/events (WebSocket), GET /v1/runs/{id} |
POST /v1/runs/{id}/answers | empty | the log of the run (answered or answer_refused) |
POST /v1/runs/{id}/suspend, …/resume, …/abort | empty | the log (suspended, continued, finished) |
POST /v1/alarms/{target}/{alarm}/{action} | empty | GET /v1/alarms/watch (WebSocket) |
A 202 on a telecommand says nothing of its outcome: a telecommand can still be REJECTED,
fail to encode or to be sent, or fail its verification. Follow its events.
Idempotency#
- Telecommands.
POST /v1/tctakes an optionalid: a ULID chosen by the client. It becomes theNats-Msg-Idof the submission, so a retry with the sameid(after a timeout, a lost reply) is de-duplicated by JetStream and answers{"id": …, "duplicate": true}: nothing new is sent. Withoutid, the API generates one, and a retry is a new telecommand. - Passes. A pass is written by identifier: sending it again refines it (upsert). A
replace_windowsnapshot can be sent again as is. - Schedules. A schedule not fired yet is replaced when created again under the same
id. - Transfers. Creating the download of a file already being transferred returns the existing transfer. An upload content is stored under its SHA-256: storing it twice gives the same hash.
Main flows#
Submit a telecommand and follow it#
curl -X POST http://localhost:8080/v1/tc \
-H 'Content-Type: application/json' -H 'X-Stellar-User: alice' \
-d '{"target": "sim-1", "telecommand": "set_anode_voltage", "instance": "TCU1",
"args": {"voltage": "100 V"}}'
# 202 {"id": "01JA2Y8S5V5E6F7G8H9J0K1M2N", "duplicate": false}
websocat ws://localhost:8080/v1/tc/sim-1/01JA2Y8S5V5E6F7G8H9J0K1M2N/events
# {"tc":"01JA2Y…","state":"PENDING","at":"…"}
# {"tc":"01JA2Y…","state":"ENCODED","at":"…"}
# {"tc":"01JA2Y…","state":"SENT","at":"…"}
# {"tc":"01JA2Y…","state":"VERIFIED","at":"…"}The telecommand is written alone when its name is unique in the platform, else
component.name; instance names the instance of a multi-instance component. link chooses a
link other than the default one, environment the environment whose link parameters apply (see
Direct Telecommands and Values).
import httpx, json, websockets, asyncio
async def send():
async with httpx.AsyncClient(base_url="http://localhost:8080",
headers={"X-Stellar-User": "alice"}) as api:
reply = (await api.post("/v1/tc", json={
"target": "sim-1", "telecommand": "tcu.ping", "instance": "TCU1",
})).raise_for_status().json()
url = f"ws://localhost:8080/v1/tc/sim-1/{reply['id']}/events"
async with websockets.connect(url) as events:
async for text in events: # closes on the final state
event = json.loads(text)
print(event["state"], event.get("detail", ""))
asyncio.run(send())Launch a run and answer it#
The body of POST /v1/runs is a run request, as written for stellar run:
curl -X POST http://localhost:8080/v1/runs \
-H 'Content-Type: application/json' -H 'X-Stellar-User: alice' \
-d '{"run": "Hot standby test", "environment": "AIT",
"targets": {"sat": "sim-1", "psu": "psu-sim-1"},
"inputs": {"tcu": "TCU2", "bus_voltage": "28 V"}}'
# 202 {"run": "01JA2Z…", "ir": "9f3c…"}library names the library when several have a procedure of that name; plan binds the run to
a pass (usually set by the scheduler). A request that does not resolve answers 422 with every
error found; a run refused by the passes 409 (see Running Procedures).
Follow the log with GET /v1/runs/{id}/events (WebSocket), or poll the state:
curl http://localhost:8080/v1/runs/01JA2Z…
# {"run": "01JA2Z…", "procedure": "Hot standby test", "step": "Hot standby test / Power the PPU",
# "suspended": false, "pending": {"path": "…", "kind": "ask", "prompt": "Power the PPU?"}}When pending is set, answer it with the path of the step asking:
# A confirmation
curl -X POST http://localhost:8080/v1/runs/01JA2Z…/answers -H 'X-Stellar-User: alice' \
-H 'Content-Type: application/json' -d '{"path": "…", "accepted": true}'
# A typed answer (ask … as f32 V into measured)
curl … -d '{"path": "…", "accepted": true, "value": "27.9 V"}'
# A decision after a failure: replay, skip, fail or abort
curl … -d '{"path": "…", "accepted": true, "value": "replay"}'Control a run with POST /v1/runs/{id}/suspend, …/resume (body {"choice": "replay"},
"skip" or "abort") and …/abort; get its HTML report with GET /v1/runs/{id}/report. See
Questions, Decisions and Control.
Feed passes#
A feeder declared in passes.feeders writes the passes of its source:
curl -X POST http://localhost:8080/v1/passes/replace_window \
-H 'Content-Type: application/json' -H 'X-Stellar-User: fds-feeder' \
-d '{"source": "fds", "target": "sat1-fm",
"from": "2026-10-02T00:00:00Z", "to": "2026-10-03T00:00:00Z",
"passes": [{"id": "gs-a-20261002T1014-sat1", "target": "sat1-fm", "station": "gs-a",
"aos": "2026-10-02T10:14:32Z", "los": "2026-10-02T10:24:05Z",
"max_elevation": "47.3 deg", "source": "fds", "status": "predicted"}]}'
# 200 {"upserted": ["gs-a-20261002T1014-sat1"], "cancelled": []}Download a file#
# List the on-board directory, then read it
curl -X POST http://localhost:8080/v1/targets/sat1-fm/files/refresh -H 'X-Stellar-User: alice'
curl http://localhost:8080/v1/targets/sat1-fm/files
# Download file 12 (its generation as last listed), with a priority
curl -X POST http://localhost:8080/v1/transfers -H 'Content-Type: application/json' \
-H 'X-Stellar-User: alice' -d '{"target": "sat1-fm", "file_id": "12", "priority": 5}'
# 201 {"target": "sat1-fm", "file_id": "12", "generation": 3, "state": "REQUESTED", …}
curl http://localhost:8080/v1/transfers/sat1-fm/12/3A file never listed is refused (422 transfer::not-listed). An upload starts from a run: store
the content with POST /v1/uploads (raw body, at most 256 MiB), which answers its SHA-256, and
give that hash as the file input of the run. See File Transfers.
Read the state of the system#
| Route | What |
|---|---|
GET /v1/topology | Targets, links as declared and as bound, readiness with reasons, leases |
GET /v1/instances, GET /v1/instances/{kind}/{instance} | Registered drivers, transports, gateways, connectors and components, their health and bindings |
GET /v1/targets/{target}/catalogue | Components, measures and telecommands of a target |
GET /v1/targets/{target}/values | Current values of a target |
GET /v1/procedures | Procedures to launch, with their roles and inputs |
GET /v1/runs?limit=N | Recent runs |
GET /v1/alarms?target=…&all=true | Alarms not NORMAL or shelved (all with all) |
GET /v1/passes, GET /v1/schedules, GET /v1/schedule-rules | Passes, schedules and rules |
GET /v1/connectors | Output connectors and the lag of their consumers |
Python client#
The Python SDK has a client of this API for runs of procedures: Client.launch and
Client.execute post the run request, Run.events() follows the WebSocket of its log (taking a
lost connection up again), Run.wait(on_ask=…) answers questions and decisions as they come,
and Run.suspend, resume, abort, status and report call the routes above. A refusal
raises ApiError with the codes of the error body.
from stellar_mcs import Client
async with Client("http://localhost:8080", "alice") as mcs:
result = await mcs.execute(
"Hot standby test",
environment="AIT",
targets={"sat": "sim-1", "psu": "psu-sim-1"},
inputs={"tcu": "TCU2", "bus_voltage": "28 V"},
on_ask=lambda question: True,
)stellar generate client adds typed functions per procedure on top of it. See
API client and Code Generators.
Following over NATS#
A person or a program following many runs or alarms can subscribe to NATS directly with
credentials of follow-up: POST /v1/auth/nats exchanges an OIDC token and a user nkey for a NATS
JWT that may only subscribe to stellar.run.evt.>, stellar.alarm.evt.> and _INBOX.>
(stellar auth nats-creds <file> writes the credentials file). See
NATS Accounts and Credentials.
The reference#
The API Reference is generated from GET /v1/openapi.json and groups the
operations by tag: telecommands, values, runs, alarms, passes, schedules, files, monitoring,
auth, simulation and meta. Load the document in any OpenAPI tool to generate a client:
curl -o openapi.json http://localhost:8080/v1/openapi.json