openapi: 3.0.3 info: title: WeeChat Radio Hub API version: 1.0.15-rc2 description: | Public read endpoints and signed telemetry write for the live map and integrations. Message text is never accepted on the write path. servers: - url: https://hub.weechatradio.com paths: /healthz: get: summary: Liveness responses: "200": description: Plain text `ok` /version: get: summary: Build of the running hub responses: "200": description: JSON `{ "version", "commit" }`. `commit` is the git commit the hub image was built from (empty in a local build). /api/v1/stats: get: summary: Hub counters responses: "200": description: JSON with nodes_online, forwarded, uptime_secs /api/v1/nodes: get: summary: Live station metadata parameters: - in: query name: since schema: { type: integer, format: int64 } description: Unix seconds. Returns `{ "trail": [ … ], "since" }` of mode and position samples for map replay. - in: query name: at schema: { type: integer, format: int64 } description: Unix seconds. Returns the stations heard in the prior 30 minutes, with the mode they had then. - in: query name: heard_since schema: { type: integer, format: int64 } description: Unix seconds. Returns `{ "nodes" }` whose last_seen is at or after this time. The nodes table is not aged out; the map asks for the last 48 hours. responses: "200": description: JSON `{ "nodes": [ … ] }` with band, freq_khz, grid, mode, tower, last_seen. With `since`, `{ "trail", "since" }` instead. /api/v1/events: get: summary: Recent TX/RX/relay events parameters: - in: query name: since schema: { type: integer, format: int64 } - in: query name: limit schema: { type: integer, default: 200 } responses: "200": description: JSON `{ "events": [ … ] }` /api/v1/links: get: summary: Latest contact per station pair description: | One payload per origin/dest pair, the newest tx, rx, relay, hub forward, or mail event at or after `since`. Events are retained for 7 days. The map requests the last 48 hours and caps the pair count. parameters: - in: query name: since schema: { type: integer, format: int64 } - in: query name: limit schema: { type: integer, default: 2000, maximum: 4000 } responses: "200": description: JSON `{ "links": [ … ], "since" }` /api/v1/hubs: get: summary: Hub status and band rollup responses: "200": description: JSON `{ "hubs": [ { id, connected_nodes, forwarded, uptime_secs, bands } ] }` /api/v1/bands: get: summary: Stations grouped by band responses: "200": description: JSON `{ "bands": [ { band, freq_khz, frequency, gateways, stations } ] }` /api/v1/report: post: summary: Signed node telemetry (metadata only) parameters: - in: header name: X-Radio-Callsign required: true schema: { type: string } - in: header name: X-Radio-Pubkey required: true schema: { type: string, description: hex Ed25519 public key } - in: header name: X-Radio-Signature required: true schema: { type: string, description: hex signature of raw body } requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NodeReport" responses: "200": description: Accepted "401": description: Bad signature "403": description: Blocked pubkey "429": description: Rate limited /ws/live: get: summary: WebSocket event firehose description: Same JSON events as the map ticker (hub_forward, node, telemetry events). responses: "101": description: Switching Protocols (WebSocket) /api/v1/mail/send: post: summary: Send mail through Resend description: | Signed by the station key. From is `{callsign}@mail.weechatradio.com`. The hub returns ok only after Resend accepts the message. `[gateway] third_party` does not apply. responses: "200": description: JSON `{ "ok": true, "id" }` "401": description: Bad signature "502": description: Resend did not accept the message /api/v1/mail/inbox: post: summary: List mail waiting for this callsign responses: "200": description: JSON `{ "ok": true, "headers": [ … ] }` /api/v1/mail/fetch: post: summary: Fetch waiting mail bodies by id responses: "200": description: JSON `{ "ok": true, "messages": [ … ] }` /api/v1/mail/copy: post: summary: Start copy-to confirmation, or turn it off description: Sends a code to the personal address. Default is off. An empty `address` turns copy-to off. responses: "200": description: JSON `{ "ok": true }` /api/v1/mail/copy/confirm: post: summary: Confirm copy-to with the code responses: "200": description: JSON `{ "ok": true }` when the code matches. The same code is accepted again after it is used. /api/v1/mail/webhook/resend: post: summary: Inbound mail from Resend description: | Resend `email.received`. The body is signed with Svix headers `svix-id`, `svix-timestamp`, and `svix-signature`. `RESEND_WEBHOOK_SECRET` is the Resend webhook signing secret. The Resend API key is `RESEND_API_KEY` on the hub only. The event has no message body; the hub fetches it from Resend. responses: "200": description: JSON `{ "ok": true }` "401": description: Missing or wrong webhook secret components: schemas: NodeReport: type: object required: [callsign, ts, mode] properties: callsign: { type: string } ts: { type: integer, format: int64 } grid: { type: string } mode: { type: string } ptt: { type: string } preset: { type: string } snr: { type: number } ber: { type: number } queue: { type: integer } hub_ok: { type: boolean } freq_khz: { type: integer } band: { type: string } tower: type: boolean description: True when an internet-radio station asked to appear as a map tower. settings: { type: object } events: type: array items: { type: object } heard_grids: type: array description: >- Stations this one heard on the air that sent a grid square. The hub places each on the map with `settings.via` set to this station, unless it reported itself in the last 15 minutes. items: type: object required: [callsign, grid, ts] properties: callsign: { type: string } grid: { type: string } ts: { type: integer, format: int64 } snr: { type: number } freq_khz: { type: integer }