MSP · Version 0.1
An open protocol for satellite ground stations.
MSP defines how a receiving station joins a network, declares what it can do, reports its state, receives work, and returns results. Four endpoints, JSON bodies, and a design constraint that shapes everything else: it has to be implementable on a microcontroller.
The protocol is open and free to implement. A station does not have to run our software, or any particular software, to take part — it has to speak MSP. That is the point of specifying it separately from the platform that happens to serve it.
Design constraints
In priority order, because they conflict and the order is how the conflicts get settled:
- Implementable on a microcontroller. A station with no operating system, kilobytes of RAM and no TLS library must be able to speak it. This constraint drives every other decision — it is why the error body is two flat strings, why assignment responses are capped at a fixed count a client can size its buffer for at compile time, and why there are four endpoints rather than fourteen.
- Stateless server, resumable client. A station that loses power mid-pass and reboots must rejoin without anyone intervening.
- Listening state is explicit. The network must be able to distinguish heard nothing from was not listening. This is not optional; it is the difference between a valid reliability metric and a meaningless one.
- Transport-agnostic semantics. HTTP is the reference binding. The message semantics do not depend on it.
Signal processing, decoding, antenna control and how a station chooses to fulfil an assignment are all out of scope. MSP says what to receive and when, never how.
The model
- station
- An independently operated receiving installation with a stable identity, a fixed location and declared capabilities.
- capability
- What a station can receive: frequency ranges, modulations, polarisation, whether it can track, and its usable elevation floor.
- assignment
- An instruction to attempt reception of one satellite over one time window.
- observation
- The report of an attempt — including attempts that produced nothing, which are as informative as the ones that succeeded.
- heartbeat
- A periodic statement of liveness and current state.
station platform
│ │
│──── register ───────────────────────────▶ │ once, on first join
│◀─── station_id + token ────────────────── │
│ │
│──── heartbeat ──────────────────────────▶ │ every 30 s
│◀─── assignments (0..n) ────────────────── │
│ │
│ [executes assignment] │
│ │
│──── observation ────────────────────────▶ │ after each attempt
│◀─── ack ───────────────────────────────── │
The reference binding
HTTP/1.1, JSON bodies, four endpoints:
POST /msp/v0/register
POST /msp/v0/heartbeat → assignments
POST /msp/v0/observations
GET /msp/v0/time → server time, for clock offset estimation
Four is deliberate. A protocol a student can implement on a microcontroller in an afternoon
is more likely to be adopted than a complete one. GET /msp/v0/time is
unauthenticated: a station that has lost its token still needs to establish its clock offset
before it can re-register, and the response contains nothing that is not already public.
Registering
Registration requires an invite token, issued out of band by the operator. It is not open — an unauthenticated write endpoint on a publicly reachable platform is not defensible, and the network has no scale problem that open registration would solve. Relaxing this later is a policy change at one endpoint, not a protocol change.
{
"invite_token": "…",
"registration_key": "…",
"name": "nec-rooftop-01",
"operator": "NTTF NEC",
"location": { "lat": 12.9716, "lon": 77.5946, "alt_m": 920 },
"simulated": false,
"capabilities": [
{
"band": "vhf",
"freq_min_hz": 136000000,
"freq_max_hz": 138000000,
"modes": ["lrpt", "fsk", "afsk"],
"polarisation": "rhcp",
"tracking": true,
"min_elevation_deg": 10
}
],
"client": { "impl": "meridian-reference", "version": "0.1.0" }
}
The station generates registration_key once, persists it locally
before sending the request, and the platform stores only its hash. It exists for one
specific failure: the platform commits the registration, the response is lost in flight, and the
station is left holding a consumed invite and no token. Presenting the same invite with the same
key then returns the same station and a newly minted token, so retrying is safe. The same invite
with a different key is rejected — that is a second station trying to use a spent invite.
The heartbeat, and the field that matters most
Every thirty seconds a station states what it is doing. The response carries any assignments due.
{
"station_id": "st_7fa3c1",
"sent_at": "2026-08-14T09:31:02Z",
"state": "listening",
"held_assignments": ["as_44b2", "as_44b9"],
"listening": {
"assignment_id": "as_44b2",
"satellite_id": "norad:57166",
"centre_freq_hz": 137900000,
"mode": "lrpt"
},
"clock_offset_s": 0.184,
"clock_uncertainty_s": 0.05,
"health": { "uptime_s": 84213, "disk_free_pct": 62, "errors": [] }
}
The listening block is the most important field in this protocol.
Without it, an absence of observations is ambiguous. With it, the platform can assert that a
station was tuned to a specific frequency, for a specific target, at a specific time, and heard
nothing — which is a real measurement rather than a gap in the record.
All four of its fields are stored, mode included: a station tuned to the right
frequency running the wrong demodulator did not observe the pass, and that has to be
distinguishable from a miss. The block is all-or-nothing, because a partial block cannot support
the assertion it exists to make.
There is no decline message. A decline is absence from
held_assignments. The heartbeat states current holdings rather than announcing a
transition, which makes it idempotent and self-healing — a lost message is not a lost decline,
because the next heartbeat carries the same truth thirty seconds later. It also covers cases an
explicit decline never would: a station that rebooted and lost its assignments reports
identically to one that refused them, and the platform's correct response is the same either
way.
Clock offset is reported against a convention stated once for the whole network —
clock_offset = platform clock − station clock, so a station whose clock runs fast
reports a negative offset. It is named explicitly rather than left implied by a formula because
the same quantity is written by the client, stored in a column, consumed by the timing analysis
and printed in a report, and a sign flip in any one of those is silent, survives review, and
inverts a published figure. null means unknown and is never the same as
0.0: a station claiming a perfect clock and a station that cannot measure its own
are opposite cases.
Assignments
{
"assignment_id": "as_44b2",
"satellite_id": "norad:57166",
"start_at": "2026-08-14T09:41:20Z",
"end_at": "2026-08-14T09:52:07Z",
"centre_freq_hz": 137900000,
"mode": "lrpt",
"expected_max_elevation_deg": 61.4,
"predicted_yield": 0.91,
"element_set": { "epoch": "2026-08-14T02:11:00Z", "line1": "1 …", "line2": "2 …" },
"timing_uncertainty_s": 4.2,
"priority": 1.0
}
The element set is carried inline. A microcontroller station cannot be expected to fetch and
cache orbital data independently, and carrying it guarantees that platform and station propagate
from identical inputs — which is what makes any subsequent timing-error measurement mean
something. timing_uncertainty_s is the platform's stated confidence in the window
edges; a station should widen its recording window accordingly rather than trusting the
boundaries exactly.
An assignment the station already holds is returned again on every heartbeat until it is
reported or its window has passed. Delivery is not once-only, so a lost response is not lost
work. Clients deduplicate by assignment_id.
Observations, including the ones that found nothing
A station reports after every attempt, failures included. The outcome field
carries five values, and the distinctions between them are the whole point:
| Value | Meaning |
|---|---|
decoded | Signal received and successfully decoded |
signal_no_decode | Signal present, decoding failed |
no_signal | Station verifiably listening, nothing detected |
aborted | Station started but could not complete |
not_attempted | Station never began — offline or unhealthy |
no_signal and not_attempted must never be conflated.
The first is data — a measurement that the sky was quiet. The second is an operational failure.
Neither of them is a decline: a declined assignment produces no observation at all.
The observation also carries first_detection_at, which is what makes
pass-timing-error measurement possible. The difference between it and the predicted acquisition
time, plotted against element-set age, is the primary measurement of orbital data quality.
Simulated stations declare themselves
A station declares whether it is simulated at registration. simulated is
required and top-level — never nested, never omitted, never inferred. A simulated station also
sends a run id and a seed, which together are what make a run reproducible.
The platform must propagate this flag to every derived record, every API response and every dashboard element, and simulated observations must never be aggregated with measured ones in any reported figure. This is a protocol-level requirement rather than a platform convention, because the integrity of every result depends on it.
Errors
Standard HTTP status semantics. Every error response carries this body and no other shape:
{ "error": "invalid_invite", "message": "Invite token has already been used." }
Two flat string fields. No nesting, no arrays, no optional members — a microcontroller client
can extract both with a substring scan and never needs a JSON tree walker, which is the entire
reason for the shape. error is a stable machine-readable code and the only field a
client may branch on; message is human text for logs and must never be parsed.
Two behaviours matter more than the code table. A station that receives 401
stops, logs, and surfaces the failure to its operator — it does not re-register and does not
retry on a thirty-second loop, which would be a denial of service a network inflicts on itself.
And a station that cannot reach the platform continues executing the assignments it
already holds, queueing observations for later submission. Reception is never blocked
on connectivity.
Versioning
An MSP-Version header on every request carries major.minor; the
path carries the major only, so MSP-Version: 0.1 is served at
/msp/v0/. The platform supports the current major version and one previous. An
unrecognised minor within a supported major is accepted, because minor versions are additive by
definition and a station built against 0.1 must keep working when the platform speaks 0.2.
Status
MSP is frozen at version 0.1. All four questions left open in the first draft have been resolved and written into the text. The specification is the authoritative document; this page is an introduction to it.
Read the full MSP 0.1 specification on GitHub, or see the architecture for what sits on the other side of this boundary.