Satellite link
An open WebSocket feed of the satellite you are working, for any amateur radio app.
Satellite link, or Sat link, is the modern replacement for the SatPC32 DDE interface. OscarWatch Tracker runs a small WebSocket server and streams plain JSON: the focused satellite, radio-corrected frequencies and modes, ADIF bands, live azimuth and elevation, and every contact logged in OscarWatch Logbook. If your app speaks WebSocket, it can use it. We would love more satellite apps to support it.
Why build on Satellite link?
DDE only works on Windows and is awkward from most languages. Satellite link works anywhere, from any language, on this PC or across the shack network.
Plain WebSocket and JSON
No SDK and no Windows-only plumbing. Python, C#, JavaScript, Go or Rust: if it has a WebSocket client, it works.
Live tracking data
Satellite name and NORAD ID, the frequencies the radio is actually on, modes, ADIF bands, azimuth, elevation, range and range rate.
Contacts as they are logged
Every contact added, edited or deleted in OscarWatch Logbook, including FT4 contacts from OscarWatch FT4, arrives as structured JSON with a ready-to-import ADIF record.
Friendly to older code
Each status message also carries a SatPC32-style text string, so adapters written for the old DDE format keep working.
What you could build
- Logging programs that fill in the satellite, mode, band and frequencies for every contact
- Contest and rove loggers that mirror OscarWatch Logbook as it happens
- Tools that pick up FT4 contacts from OscarWatch FT4 the moment they complete
- SDR software that follows the downlink through the pass
- Antenna and rotator controllers, or a pointing display in the garden
- Station dashboards, stream overlays and club demo screens
The protocol
Protocol version 1. Everything below is what OscarWatch Tracker sends today.
Connecting
The user turns the server on in OscarWatch Tracker under Settings, Integrations, Satellite link. The default port is 7373. Connect with any standard WebSocket client:
ws://127.0.0.1:7373/ this PC only
ws://<pc-ip-address>:7373/ when local network access is turned on
- On connect you get the latest satellite snapshot straight away, then updates whenever the focus, mode, frequencies or tracking data change.
- Identical status messages are throttled by the user's update interval, 1000 ms by default. Focus and mode changes are sent immediately.
- QSO events are sent immediately and are not replayed. A client that connects later only sees events from then on.
- There is no authentication in version 1. Use it on this PC or a trusted local network only.
satelliteStatus
Live tracking data for the satellite focused in OscarWatch Tracker.
{
"type": "satelliteStatus",
"version": 1,
"timestampUtc": "2026-07-07T11:04:00.000Z",
"inRange": true,
"satellite": {
"name": "SO-50",
"noradId": "27607",
"modeType": "FM VOICE"
},
"frequencies": {
"uplinkHz": 435300000,
"downlinkHz": 145850000,
"uplinkMode": "FM",
"downlinkMode": "FM",
"nominalUplinkKHz": 435300,
"nominalDownlinkKHz": 145850,
"isBeaconOnly": false
},
"bands": { "tx": "70cm", "rx": "2m" },
"tracking": {
"azimuthDeg": 91.7,
"elevationDeg": 1.9,
"rangeKm": 2100.5,
"rangeRateKmPerSec": -4.92,
"isSunlit": true
},
"dopplerStrategy": "full",
"wispDde": "SO-50 AZ91,7 EL1,9 UP435300000 UFM DN145850000 DFM MA0,0 RR-4,92"
}
When no satellite is focused, or it is below the horizon and the user only broadcasts above it:
{
"type": "satelliteStatus",
"version": 1,
"timestampUtc": "2026-07-07T11:20:00.000Z",
"inRange": false,
"satellite": null,
"wispDde": "** NO SATELLITE **"
}
satellite.name: OscarWatch and LoTW naming, the same as Cloudlog sat_name.frequencies.uplinkHz/downlinkHz: radio-corrected Hz, the values CAT is using, not the catalogue nominals.dopplerStrategy:full,downlinkOnlyoruplinkOnly.wispDde: SatPC32 field order with European decimal commas, for legacy parsers.
qsoLogged and qsoUpdated
Sent when a contact is added to OscarWatch Logbook, including contacts completed in OscarWatch FT4. qsoUpdated has the same shape and is sent when a contact is edited.
{
"type": "qsoLogged",
"version": 1,
"timestampUtc": "2026-07-11T14:30:05.000Z",
"logbook": {
"id": 3,
"name": "Field day",
"myCallsign": "G0ABC",
"myGridSquare": "IO91"
},
"qso": {
"id": 42,
"qsoUtc": "2026-07-11T14:30:00.000Z",
"call": "DL1ABC",
"rstSent": "59",
"rstRcvd": "59",
"gridSquare": "JO62",
"satellite": { "name": "SO-50", "noradId": "27607" },
"frequencies": {
"uplinkHz": 435300000,
"downlinkHz": 145850000,
"uplinkMode": "FM",
"downlinkMode": "FM"
},
"bands": { "tx": "70cm", "rx": "2m" },
"propMode": "SAT"
},
"adif": "<CALL:6>DL1ABC<QSO_DATE:8>20260711<TIME_ON:4>1430<RST_SENT:2>59<RST_RCVD:2>59<GRIDSQUARE:4>JO62<STATION_CALLSIGN:5>G0ABC<MY_GRIDSQUARE:4>IO91<SAT_NAME:5>SO-50<PROP_MODE:3>SAT<FREQ:10>435.300000<BAND:4>70cm<MODE:2>FM<FREQ_RX:10>145.850000<BAND_RX:2>2m<EOR>"
}
qsoDeleted
Sent when a contact is removed.
{
"type": "qsoDeleted",
"version": 1,
"timestampUtc": "2026-07-11T14:35:00.000Z",
"logbook": {
"id": 3,
"name": "Field day",
"myCallsign": "G0ABC",
"myGridSquare": "IO91"
},
"qso": { "id": 42, "call": "DL1ABC" }
}
qso.id: a stable OscarWatch ID. Use it to match qsoUpdated and qsoDeleted to the contact you stored.qso.qsoUtc: contact time in UTC (ISO 8601), matching ADIF QSO_DATE and TIME_ON.qso.satellite.name: the LoTW-style name captured at log time, the same as ADIF SAT_NAME.qso.propMode: always SAT.adif: a single ADIF 3.1 record ending at EOR, with no header and no trailing newline. Included on qsoLogged and qsoUpdated.- Empty optional values are left out of the JSON rather than sent as empty strings.
Example clients
A working client is a few lines in most languages.
Python
import asyncio
import json
import websockets
async def main():
async with websockets.connect("ws://127.0.0.1:7373/") as ws:
while True:
msg = json.loads(await ws.recv())
kind = msg.get("type")
if kind == "satelliteStatus":
if msg.get("inRange"):
sat = msg["satellite"]["name"]
rx = msg["frequencies"]["downlinkHz"]
print(f"{sat} RX {rx} Hz")
else:
print("No satellite")
elif kind == "qsoLogged":
qso = msg["qso"]
print(f"Logged {qso['call']} on {qso['satellite']['name']}")
elif kind == "qsoUpdated":
print(f"Updated QSO {msg['qso']['id']}")
elif kind == "qsoDeleted":
print(f"Deleted QSO {msg['qso']['id']}")
asyncio.run(main())
C#
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri("ws://127.0.0.1:7373/"), CancellationToken.None);
var buffer = new byte[8192];
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(buffer, CancellationToken.None);
var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
switch (root.GetProperty("type").GetString())
{
case "satelliteStatus" when root.GetProperty("inRange").GetBoolean():
Console.WriteLine(root.GetProperty("satellite").GetProperty("name").GetString());
break;
case "qsoLogged":
Console.WriteLine($"Logged {root.GetProperty("qso").GetProperty("call").GetString()}");
break;
}
}
JavaScript
const ws = new WebSocket("ws://127.0.0.1:7373/");
ws.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "satelliteStatus" && msg.inRange) {
console.log(msg.satellite.name, msg.frequencies.downlinkHz);
}
if (msg.type === "qsoLogged") {
console.log("Logged", msg.qso.call, msg.adif);
}
});
Good client behaviour
- Ignore JSON fields you do not recognise. New fields may be added without a version change.
- Check the version field and switch on type, rather than assuming every message is a status update.
- Reconnect quietly if OscarWatch Tracker is closed or restarted, and wait for the next snapshot.
- Use qso.id to avoid logging the same contact twice.
- Tell your users to keep the server on this PC or their own network. Do not expose it to the internet.
Building something with Satellite link?
Tell us about it. We are happy to help with questions, hear what the protocol is missing, and point OscarWatch users at apps that support it.