prop/docs/api/openapi.yaml
Graham McIntire bd731685de
feat(api): ingest endpoint for propmonitor beacon measurements
POST /api/v1/beacon-monitor/measurements accepts one measurement per
integration window from a propmonitor client, authenticated by the
BeaconMonitor token. Records noise floor, signal peak/avg dBFS, SNR,
signal-active-fraction, gain, and frequency for later correlation
against weather and propagation scores.

Beacon UUID must resolve to an approved + on-the-air beacon (404
otherwise — the client drops 404s without retry). Retries with the
same (monitor, beacon, measured_at) are idempotent: a unique index
makes the second insert a 409. Every accepted upload stamps the
monitor's last_seen_at — measurements *are* the heartbeat.

MonitorAuth is a separate plug from the user API-token Auth plug;
monitors are not users. The rate limiter buckets each monitor by id so
retry storms after a 5xx don't burn the shared anon-IP bucket.
2026-05-13 16:07:04 -05:00

797 lines
23 KiB
YAML

openapi: 3.1.0
info:
title: Microwaveprop Public API
version: "1.0.0"
summary: REST API for QSO submission, beacon management, and propagation queries.
description: |
Public REST API for the NTMS microwave propagation prediction service.
Implements every action a regular signed-in user can take on the
website (submit contacts and beacons, manage beacon monitors,
manage their own API tokens, query propagation scores), plus public
read-only endpoints for contacts, beacons, profiles, and scores.
Admin-only operations (user management, beacon approval, contact
moderation) are intentionally **not** exposed here.
See `docs/api/README.md` for prose, examples, and the stability
promise.
contact:
name: NTMS
url: https://w5hn.org/
servers:
- url: https://prop.w5isp.com/api/v1
description: Production
- url: http://localhost:4000/api/v1
description: Local development
security:
- bearerAuth: []
tags:
- name: auth
- name: me
- name: contacts
- name: beacons
- name: monitors
- name: scores
- name: profiles
paths:
/auth/tokens:
post:
tags: [auth]
summary: Issue a long-lived bearer token
security: []
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AuthRequest" }
responses:
"201":
description: Token created
content:
application/json:
schema: { $ref: "#/components/schemas/AuthResponse" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
/me:
get:
tags: [me]
summary: Current user profile
responses:
"200":
description: Profile
content:
application/json:
schema: { $ref: "#/components/schemas/MeResponse" }
"401": { $ref: "#/components/responses/Unauthorized" }
patch:
tags: [me]
summary: Update home QTH
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/HomeQthRequest" }
responses:
"200":
description: Updated profile
content:
application/json:
schema: { $ref: "#/components/schemas/MeResponse" }
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/ValidationFailed" }
/me/contacts:
get:
tags: [me]
summary: My QSOs
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/ContactList" }
"401": { $ref: "#/components/responses/Unauthorized" }
/me/beacons:
get:
tags: [me]
summary: My beacons
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconList" }
"401": { $ref: "#/components/responses/Unauthorized" }
/me/api-tokens:
get:
tags: [me]
summary: List my API tokens
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/TokenList" }
"401": { $ref: "#/components/responses/Unauthorized" }
/me/api-tokens/{id}:
delete:
tags: [me]
summary: Revoke an API token
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Revoked record
content:
application/json:
schema: { $ref: "#/components/schemas/TokenResponse" }
"404": { $ref: "#/components/responses/NotFound" }
/me/beacon-monitors:
get:
tags: [monitors]
summary: List my beacon monitors
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconMonitorList" }
post:
tags: [monitors]
summary: Create a beacon monitor
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconMonitorCreate" }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconMonitorResponse" }
"422": { $ref: "#/components/responses/ValidationFailed" }
/me/beacon-monitors/{id}:
delete:
tags: [monitors]
summary: Delete a beacon monitor
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"204":
description: No content
"404": { $ref: "#/components/responses/NotFound" }
/beacon-monitor/measurements:
post:
tags: [monitors]
summary: Upload a beacon-signal-level measurement
description: |
Ingest endpoint for the `propmonitor` client. The Bearer token here
is the `BeaconMonitor` token (issued once by
`POST /me/beacon-monitors`), not a user API token. One POST per
integration window per monitor; payloads are never batched.
`signal_peak_dbfs`, `signal_avg_dbfs`, and the `snr_*` fields are
computed only over FFT frames whose in-band power exceeded
`noise_floor + 3 dB`. `signal_active_fraction` reports the duty
cycle of those frames over the window.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconMeasurementCreate" }
responses:
"204":
description: Accepted and recorded. The monitor's `last_seen_at` is updated.
"401": { $ref: "#/components/responses/Unauthorized" }
"404":
description: The supplied `beacon_id` is unknown, unapproved, or off-the-air.
"409":
description: A measurement for this monitor + beacon + measured_at already exists.
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
/contacts:
get:
tags: [contacts]
summary: List public QSOs
security: []
parameters:
- in: query
name: page
schema: { type: integer, minimum: 1, default: 1 }
- in: query
name: per_page
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
- in: query
name: search
schema: { type: string }
responses:
"200":
description: Paginated list
content:
application/json:
schema: { $ref: "#/components/schemas/PaginatedContactList" }
post:
tags: [contacts]
summary: Submit a QSO
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ContactCreate" }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/ContactResponse" }
"401": { $ref: "#/components/responses/Unauthorized" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/ValidationFailed" }
/contacts/{id}:
get:
tags: [contacts]
summary: Show a single QSO
security: []
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: QSO
content:
application/json:
schema: { $ref: "#/components/schemas/ContactResponse" }
"404": { $ref: "#/components/responses/NotFound" }
/beacons:
get:
tags: [beacons]
summary: List approved beacons
security: []
responses:
"200":
description: List
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconList" }
post:
tags: [beacons]
summary: Submit a beacon
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconCreate" }
responses:
"201":
description: Created (pending approval)
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconResponse" }
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/ValidationFailed" }
/beacons/{id}:
get:
tags: [beacons]
summary: Show a single beacon
security: []
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Beacon
content:
application/json:
schema: { $ref: "#/components/schemas/BeaconResponse" }
"404": { $ref: "#/components/responses/NotFound" }
/profiles/{callsign}:
get:
tags: [profiles]
summary: Public per-callsign profile
security: []
parameters:
- in: path
name: callsign
required: true
schema: { type: string }
responses:
"200":
description: Profile + contacts + approved beacons
content:
application/json:
schema: { $ref: "#/components/schemas/ProfileResponse" }
"404": { $ref: "#/components/responses/NotFound" }
/scores/bands:
get:
tags: [scores]
summary: Bands with propagation scores
security: []
responses:
"200":
description: List of bands
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
mhz: { type: integer }
label: { type: string }
humidity_effect:
type: string
enum: [beneficial, harmful, neutral]
/scores:
get:
tags: [scores]
summary: Score + factor breakdown at a grid point
security: []
parameters:
- in: query
name: band
required: true
schema: { type: integer }
- in: query
name: lat
required: true
schema: { type: number, format: double }
- in: query
name: lon
required: true
schema: { type: number, format: double }
- in: query
name: valid_time
schema: { type: string, format: date-time }
responses:
"200":
description: Score
content:
application/json:
schema: { $ref: "#/components/schemas/ScoreResponse" }
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
/forecast:
get:
tags: [scores]
summary: 18-hour score timeline at a grid point
security: []
parameters:
- in: query
name: band
required: true
schema: { type: integer }
- in: query
name: lat
required: true
schema: { type: number, format: double }
- in: query
name: lon
required: true
schema: { type: number, format: double }
responses:
"200":
description: Timeline
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
valid_time: { type: string, format: date-time }
score: { type: integer }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: opaque
responses:
BadRequest:
description: Bad request
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
Unauthorized:
description: Authentication required
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
NotFound:
description: Resource not found
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
Conflict:
description: Duplicate
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
ValidationFailed:
description: Validation failed
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
RateLimited:
description: Rate limit exceeded
headers:
RateLimit-Limit: { schema: { type: integer } }
RateLimit-Remaining: { schema: { type: integer } }
RateLimit-Reset: { schema: { type: integer } }
Retry-After: { schema: { type: integer } }
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
schemas:
Problem:
type: object
properties:
type: { type: string, default: "about:blank" }
title: { type: string }
status: { type: integer }
detail: { type: string }
errors:
type: object
additionalProperties:
type: array
items: { type: string }
AuthRequest:
type: object
required: [email, password, name]
properties:
email: { type: string, format: email }
password: { type: string }
name: { type: string, maxLength: 100 }
expires_at: { type: string, format: date-time, nullable: true }
AuthResponse:
type: object
properties:
token: { type: string }
data: { $ref: "#/components/schemas/Token" }
Token:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
inserted_at: { type: string, format: date-time }
last_used_at: { type: string, format: date-time, nullable: true }
expires_at: { type: string, format: date-time, nullable: true }
revoked_at: { type: string, format: date-time, nullable: true }
TokenList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Token" }
TokenResponse:
type: object
properties:
data: { $ref: "#/components/schemas/Token" }
Me:
type: object
properties:
id: { type: string, format: uuid }
callsign: { type: string }
name: { type: string }
email: { type: string, format: email }
is_admin: { type: boolean }
confirmed_at: { type: string, format: date-time, nullable: true }
home_grid: { type: string, nullable: true }
home_lat: { type: number, format: double, nullable: true }
home_lon: { type: number, format: double, nullable: true }
home_elevation_m: { type: integer, nullable: true }
MeResponse:
type: object
properties:
data: { $ref: "#/components/schemas/Me" }
HomeQthRequest:
type: object
properties:
home_grid: { type: string }
home_lat: { type: number, format: double }
home_lon: { type: number, format: double }
home_elevation_m: { type: integer }
Contact:
type: object
properties:
id: { type: string, format: uuid }
station1: { type: string }
station2: { type: string }
qso_timestamp: { type: string, format: date-time }
grid1: { type: string }
grid2: { type: string }
pos1:
type: object
properties:
lat: { type: number }
lon: { type: number }
pos2:
type: object
properties:
lat: { type: number }
lon: { type: number }
mode: { type: string }
band_mhz: { type: string }
distance_km: { type: string, nullable: true }
private: { type: boolean }
user_declared_prop_mode: { type: string, nullable: true }
propagation_mechanism: { type: string, nullable: true }
propagation_mechanism_confidence:
type: string
nullable: true
enum: [high, medium, low]
notes: { type: string, nullable: true }
"mine?": { type: boolean }
ContactCreate:
type: object
required: [station1, station2, qso_timestamp, band, grid1, grid2]
properties:
station1: { type: string }
station2: { type: string }
qso_timestamp: { type: string, format: date-time }
band: { type: string }
grid1: { type: string }
grid2: { type: string }
mode: { type: string, enum: [CW, SSB, FM, FT8, FT4, Q65] }
user_declared_prop_mode: { type: string }
height1_ft: { type: integer }
height2_ft: { type: integer }
private: { type: boolean }
notes: { type: string, maxLength: 2000 }
ContactResponse:
type: object
properties:
data: { $ref: "#/components/schemas/Contact" }
ContactList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Contact" }
PaginatedContactList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Contact" }
meta:
type: object
properties:
page: { type: integer }
per_page: { type: integer }
total_entries: { type: integer }
total_pages: { type: integer }
Beacon:
type: object
properties:
id: { type: string, format: uuid }
callsign: { type: string }
frequency_mhz: { type: number }
lat: { type: number }
lon: { type: number }
grid: { type: string }
power_mw: { type: number }
height_ft: { type: integer }
on_the_air: { type: boolean }
approved: { type: boolean }
keying: { type: string }
bearing: { type: string }
beamwidth_deg: { type: number, nullable: true }
notes: { type: string, nullable: true }
inserted_at: { type: string, format: date-time }
BeaconCreate:
type: object
required: [frequency_mhz, callsign, lat, lon, power_mw, height_ft, keying]
properties:
frequency_mhz: { type: number }
callsign: { type: string }
grid: { type: string }
lat: { type: number }
lon: { type: number }
power_mw: { type: number }
height_ft: { type: integer }
on_the_air: { type: boolean }
keying: { type: string }
bearing: { type: string }
beamwidth_deg: { type: number }
notes: { type: string }
BeaconResponse:
type: object
properties:
data: { $ref: "#/components/schemas/Beacon" }
BeaconList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Beacon" }
BeaconMonitor:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
token: { type: string, nullable: true }
last_seen_at: { type: string, format: date-time, nullable: true }
inserted_at: { type: string, format: date-time }
BeaconMonitorCreate:
type: object
required: [name]
properties:
name: { type: string, maxLength: 100 }
BeaconMonitorResponse:
type: object
properties:
data: { $ref: "#/components/schemas/BeaconMonitor" }
BeaconMonitorList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/BeaconMonitor" }
BeaconMeasurementCreate:
type: object
required:
- beacon_id
- frequency_hz
- measured_at
- integration_s
- passband_hz
- gain_db
- noise_floor_dbfs
- signal_peak_dbfs
- signal_avg_dbfs
- snr_peak_db
- snr_avg_db
- signal_active_fraction
- propmonitor_version
properties:
beacon_id:
type: string
format: uuid
description: The beacon this measurement is for.
frequency_hz:
type: integer
minimum: 1
description: SDR tuned center frequency in Hz.
measured_at:
type: string
format: date-time
description: UTC ISO-8601 timestamp at the start of the integration window.
integration_s:
type: integer
minimum: 5
maximum: 3600
description: Integration window length in seconds.
passband_hz:
type: number
exclusiveMinimum: 0
description: Bandwidth of the in-band power window.
gain_db:
type: number
minimum: -10
maximum: 80
description: SDR-reported gain at upload time.
noise_floor_dbfs:
type: number
description: Median out-of-passband bin power, dBFS.
signal_peak_dbfs:
type: number
description: Peak in-passband power across detected-on-air frames, dBFS.
signal_avg_dbfs:
type: number
description: Mean in-passband power across detected-on-air frames, dBFS.
snr_peak_db:
type: number
description: signal_peak_dbfs minus noise_floor_dbfs.
snr_avg_db:
type: number
description: signal_avg_dbfs minus noise_floor_dbfs.
signal_active_fraction:
type: number
minimum: 0
maximum: 1
description: Fraction of FFT frames where in-band power exceeded noise_floor + 3 dB.
propmonitor_version:
type: string
maxLength: 32
description: Build version of the reporting client (diagnostic).
ProfileResponse:
type: object
properties:
user:
type: object
properties:
id: { type: string, format: uuid }
callsign: { type: string }
name: { type: string }
home_grid: { type: string, nullable: true }
home_lat: { type: number, nullable: true }
home_lon: { type: number, nullable: true }
contacts:
type: array
items: { $ref: "#/components/schemas/Contact" }
beacons:
type: array
items: { $ref: "#/components/schemas/Beacon" }
ScoreResponse:
type: object
properties:
data:
type: object
properties:
lat: { type: number }
lon: { type: number }
score: { type: integer }
factors:
type: object
additionalProperties: true
profile_source:
oneOf:
- { type: string, enum: [exact, unavailable] }
- { type: array }
valid_time: { type: string, format: date-time }