defmodule MicrowavepropWeb.ApiDocsLive do @moduledoc """ Renders the Microwaveprop REST API documentation at `/docs/api`. Layout is modelled on the Tailwind UI "Protocol" template — a fixed left sidebar with section navigation and a two-column main column where prose sits next to a sticky code sample. """ use MicrowavepropWeb, :live_view # ───────────────────────────────────────────────────────────────────── # Code samples are kept as module attributes so the HEEx template # stays readable. HEEx's attribute parser doesn't accept multi-line # heredoc sigils inline, so we bind once at compile time and reference # by name from the template. # ───────────────────────────────────────────────────────────────────── @sample_quickstart """ curl -sS -X POST https://prop.w5isp.com/api/v1/auth/tokens \\ -H "Content-Type: application/json" \\ -d '{"email":"you@example.com","password":"...","name":"laptop"}' # => {"data": {...}, "token": "mwp_..."} TOKEN=mwp_... curl -sS -H "Authorization: Bearer $TOKEN" \\ https://prop.w5isp.com/api/v1/me """ @sample_auth_header "Authorization: Bearer mwp_AbCdEf123…" @sample_token_format """ # 32 random bytes, URL-base64 mwp_<43 chars> # Server stores SHA-256(token) only. """ @sample_error_422 """ { "type": "about:blank", "title": "validation_failed", "status": 422, "detail": "One or more fields are invalid.", "errors": { "callsign": ["must be 3-10 letters and digits"] } } """ @sample_rate_headers """ RateLimit-Limit: 600 RateLimit-Remaining: 597 RateLimit-Reset: 42 """ @sample_auth_tokens_request """ curl -X POST https://prop.w5isp.com/api/v1/auth/tokens \\ -H "Content-Type: application/json" \\ -d '{ "email": "you@example.com", "password": "your password", "name": "iPad", "expires_at": "2027-01-01T00:00:00Z" }' """ @sample_auth_tokens_response """ { "data": { "id": "01HX...", "name": "iPad", "inserted_at": "2026-05-09T12:34:00Z", "last_used_at": null, "expires_at": "2027-01-01T00:00:00Z", "revoked_at": null }, "token": "mwp_AbCdEf..." } """ @sample_me_get """ curl -H "Authorization: Bearer $TOKEN" \\ https://prop.w5isp.com/api/v1/me """ @sample_me_patch """ curl -X PATCH \\ -H "Authorization: Bearer $TOKEN" \\ -H "Content-Type: application/json" \\ -d '{"home_grid":"EM12kx"}' \\ https://prop.w5isp.com/api/v1/me """ @sample_me_contacts """ curl -H "Authorization: Bearer $TOKEN" \\ "https://prop.w5isp.com/api/v1/me/contacts?per_page=20" """ @sample_me_tokens_list """ curl -H "Authorization: Bearer $TOKEN" \\ https://prop.w5isp.com/api/v1/me/api-tokens """ @sample_me_tokens_revoke """ curl -X DELETE \\ -H "Authorization: Bearer $TOKEN" \\ https://prop.w5isp.com/api/v1/me/api-tokens/01HX... """ @sample_monitor_create """ curl -X POST \\ -H "Authorization: Bearer $TOKEN" \\ -H "Content-Type: application/json" \\ -d '{"name":"shack-pi","grid":"EM12kx"}' \\ https://prop.w5isp.com/api/v1/me/beacon-monitors """ @sample_contacts_search """ curl "https://prop.w5isp.com/api/v1/contacts?search=W5XD&per_page=25" """ @sample_contact_submit """ curl -X POST \\ -H "Authorization: Bearer $TOKEN" \\ -H "Content-Type: application/json" \\ -d '{ "station1": "W5XD", "station2": "K5XD", "qso_timestamp": "2026-05-08T12:34:00Z", "band": "10000", "grid1": "EM12", "grid2": "EM13", "mode": "CW" }' \\ https://prop.w5isp.com/api/v1/contacts """ @sample_beacon_submit """ curl -X POST \\ -H "Content-Type: application/json" \\ -d '{ "callsign": "WB5LUA/B", "frequency_mhz": 10368.025, "grid": "EM13qe", "power_w": 5 }' \\ https://prop.w5isp.com/api/v1/beacons """ @sample_scores """ curl "https://prop.w5isp.com/api/v1/scores?band=10000&lat=32.78&lon=-96.80" """ @sample_forecast """ curl "https://prop.w5isp.com/api/v1/forecast?band=10000&lat=32.78&lon=-96.80" """ @sample_profile """ curl https://prop.w5isp.com/api/v1/profiles/W5XD """ @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:page_title, "API Documentation") |> assign(:sample_quickstart, @sample_quickstart) |> assign(:sample_auth_header, @sample_auth_header) |> assign(:sample_token_format, @sample_token_format) |> assign(:sample_error_422, @sample_error_422) |> assign(:sample_rate_headers, @sample_rate_headers) |> assign(:sample_auth_tokens_request, @sample_auth_tokens_request) |> assign(:sample_auth_tokens_response, @sample_auth_tokens_response) |> assign(:sample_me_get, @sample_me_get) |> assign(:sample_me_patch, @sample_me_patch) |> assign(:sample_me_contacts, @sample_me_contacts) |> assign(:sample_me_tokens_list, @sample_me_tokens_list) |> assign(:sample_me_tokens_revoke, @sample_me_tokens_revoke) |> assign(:sample_monitor_create, @sample_monitor_create) |> assign(:sample_contacts_search, @sample_contacts_search) |> assign(:sample_contact_submit, @sample_contact_submit) |> assign(:sample_beacon_submit, @sample_beacon_submit) |> assign(:sample_scores, @sample_scores) |> assign(:sample_forecast, @sample_forecast) |> assign(:sample_profile, @sample_profile)} end @impl true def render(assigns) do ~H"""

REST API · v1

Microwaveprop API

The Microwaveprop public REST API exposes the read-and-write surface a regular user of the website has access to: contact (QSO) submission, beacon submission, beacon-monitor management, propagation queries, and profile management. Admin-only operations are deliberately excluded.

Base URL
https://prop.w5isp.com/api/v1
Versioning
Path-based (/api/v1). Breaking changes ship as /api/v2.
Auth
Opaque bearer tokens (Authorization: Bearer mwp_…). Mint one from your <.link navigate="/users/settings#api-tokens" class="api-docs-link"> account settings or programmatically at POST /api/v1/auth/tokens.
Format
application/json requests and responses. Errors use RFC 9457 problem+json .
Spec
<.link href="/docs/api/openapi.yaml" class="api-docs-link"> openapi.yaml (OpenAPI 3.1)
<.row id="quickstart"> <:col>

Quickstart

Mint a long-lived bearer token once, then use it for every subsequent call. Tokens are 32 random bytes, URL-base64 encoded with an mwp_ prefix so leak scanners can spot them.

The interactive way to mint a token is the <.link navigate="/users/settings#api-tokens" class="api-docs-link"> account settings page — the value is shown once. Keep it like a password.

<:code> <.code_block title="Mint a token" tag="POST" label="/auth/tokens" code={@sample_quickstart} /> <.row id="authentication"> <:col>

Authentication

Every endpoint that mutates state, or returns the authenticated user's own data, requires a bearer token. Public read endpoints accept an optional bearer that unlocks the caller's private rows.

Endpoint auth matrix

Endpoint Auth Notes
POST /auth/tokens password The only endpoint that accepts a password.
GET / PATCH /me bearer The user behind the bearer token.
POST /contacts bearer Regular user QSO submission.
GET /contacts optional Public; bearer reveals viewer-private rows.
GET /beacons none Approved beacons only.
POST /beacons bearer New beacons start unapproved.
GET /scores none Public read of propagation scores.
GET /profiles/:call none Public per-callsign profile.

Token lifecycle

  • Tokens may carry an optional expires_at (ISO 8601 UTC). Without one they live until revoked.
  • GET /me/api-tokens lists every non-revoked token belonging to the authenticated user (without plaintext).
  • DELETE /me/api-tokens/:id revokes a token (soft delete — the row remains for audit).
  • Revoking the token used by the current request immediately invalidates every subsequent request from that token.
<:code> <.code_block title="Authorization header" code={@sample_auth_header} /> <.code_block title="Token format" code={@sample_token_format} /> <.row id="errors"> <:col>

Errors

All error responses follow RFC 9457 — a structured application/problem+json body with a stable title field and per-field errors on validation failures.

Status When
400 Missing or malformed query / body parameter.
401 No / invalid / revoked / expired bearer token.
403 Authenticated, but the action is forbidden for the caller.
404 Resource not found, or hidden by privacy.
409 Duplicate — an equivalent resource already exists.
422 Validation failed; errors carries per-field messages.
429 Rate limit exceeded; check RateLimit-* + Retry-After.
5xx Bug. Please open an issue.
<:code> <.code_block title="422 validation failure" lang="json" code={@sample_error_422} /> <.row id="rate-limiting"> <:col>

Rate limiting

Every response carries the RFC 9651 RateLimit-* headers so clients can self-throttle before hitting a 429.

Header Meaning
RateLimit-Limit Requests permitted in the current window.
RateLimit-Remaining Requests remaining in the current window.
RateLimit-Reset Seconds until the window resets.
Retry-After Sent on 429s; retry no sooner than this many seconds.

Defaults

Caller Limit
Anonymous (per IP) 60 req / minute
Authenticated (per token) 600 req / minute
POST /auth/tokens (per IP) 30 req / minute
<:code> <.code_block title="Response headers" code={@sample_rate_headers} />

Endpoint reference

<.endpoint id="auth-tokens" method="POST" path="/auth/tokens"> <:summary>Issue an API token <:body>

Prefer the <.link navigate="/users/settings#api-tokens" class="api-docs-link"> account settings page for interactive use; this endpoint is here for scripts that need to mint a token without leaving the terminal.

Request body

<.properties> <:property name="email" type="string" required> Account email address. <:property name="password" type="string" required> Account password. <:property name="name" type="string" required> Human-readable label so you can identify the token later. <:property name="expires_at" type="datetime"> ISO 8601 UTC. Omit for a token that lives until revoked. <:code> <.code_block title="Request" tag="POST" label="/auth/tokens" code={@sample_auth_tokens_request} /> <.code_block title="Response · 201" lang="json" code={@sample_auth_tokens_response} /> <.endpoint id="me" method="GET" path="/me"> <:summary>The authenticated user <:body>

Returns the user behind the bearer token: callsign, name, email, home QTH, and admin flag.

PATCH /me accepts any subset of home_grid, home_lat, home_lon, home_elevation_m. Grid is auto-derived from lat/lon and vice versa.

<:code> <.code_block title="Request" tag="GET" label="/me" code={@sample_me_get} /> <.code_block title="Update home QTH" tag="PATCH" label="/me" code={@sample_me_patch} /> <.endpoint id="me-collections" method="GET" path="/me/contacts"> <:summary>User-scoped collections <:body>

Two sibling endpoints return everything submitted under the authenticated user's account, newest first:

  • GET /me/contacts — every QSO submitted by the user.
  • GET /me/beacons — every beacon (approved or pending) submitted by the user.

Both honour standard page + per_page pagination.

<:code> <.code_block title="List my contacts" tag="GET" label="/me/contacts" code={@sample_me_contacts} /> <.endpoint id="me-tokens" method="GET" path="/me/api-tokens"> <:summary>Manage your API tokens <:body>

GET /me/api-tokens lists every non-revoked token belonging to the authenticated user. The plaintext value is never returned — only the metadata (id, name, timestamps).

DELETE /me/api-tokens/:id revokes a token. The row is soft-deleted (its revoked_at is set) and any in-flight request using it is invalidated on the next call.

<:code> <.code_block title="List tokens" tag="GET" label="/me/api-tokens" code={@sample_me_tokens_list} /> <.code_block title="Revoke a token" tag="DELETE" label="/me/api-tokens/:id" code={@sample_me_tokens_revoke} /> <.endpoint id="me-monitors" method="GET" path="/me/beacon-monitors"> <:summary>Beacon monitor stations <:body>

Distributed beacon monitor stations that report SNR samples back to Microwaveprop. Each monitor row has a token field — the credential the monitor program uses to identify itself when reporting.

Operations

  • GET /me/beacon-monitors — list.
  • POST /me/beacon-monitors — create. Returns the new monitor's plaintext token (shown once).
  • DELETE /me/beacon-monitors/:id — revoke and remove.
<:code> <.code_block title="Create a monitor" tag="POST" label="/me/beacon-monitors" code={@sample_monitor_create} /> <.endpoint id="contacts" method="GET" path="/contacts"> <:summary>Public contact (QSO) feed <:body>

Paginated list of QSOs.

Query parameters

<.properties> <:property name="page" type="integer"> 1-based. Defaults to 1. <:property name="per_page" type="integer"> Defaults to 50; capped at 200. <:property name="search" type="string"> One or two callsigns; matches station1 or station2.

When called with a bearer token, the user's own private contacts are included in addition to the public set.

GET /contacts/:id returns a single QSO. Private QSOs return 404 to non-owners.

<:code> <.code_block title="Search by callsign" tag="GET" label="/contacts" code={@sample_contacts_search} /> <.endpoint method="POST" path="/contacts"> <:summary>Submit a QSO <:body>

Create a new QSO under the authenticated user's account; their email is recorded as submitter_email.

Required fields

<.properties> <:property name="station1" type="string" required>Submitter callsign. <:property name="station2" type="string" required> Other operator's callsign. <:property name="qso_timestamp" type="datetime" required>ISO 8601 UTC. <:property name="band" type="string" required> MHz integer string ("10000", "24000"). <:property name="grid1" type="string" required> 4- or 6-character Maidenhead. <:property name="grid2" type="string" required>Other end's grid. <:property name="mode" type="string" required> Mode (e.g. CW, SSB).

Optional fields

<.properties> <:property name="user_declared_prop_mode" type="string"> e.g. tropo, aircraft_scatter. <:property name="height1_ft" type="number">Antenna height above ground. <:property name="height2_ft" type="number">Other end's antenna height. <:property name="private" type="boolean">Hide from the public feed. <:property name="notes" type="string">Free-form text.

A duplicate (same stations + same hour + same band) returns 409 Conflict with the existing record in the existing field.

<:code> <.code_block title="Submit a QSO" tag="POST" label="/contacts" code={@sample_contact_submit} /> <.endpoint id="beacons" method="GET" path="/beacons"> <:summary>Beacons <:body>

GET /beacons lists approved beacons; GET /beacons/:id reads a single one.

POST /beacons is unauthenticated — the new beacon is created in approved=false state until an admin approves it via the website.

<:code> <.code_block title="Submit a beacon" tag="POST" label="/beacons" code={@sample_beacon_submit} /> <.endpoint id="scores" method="GET" path="/scores"> <:summary>Propagation score at a grid point <:body>

Returns the composite propagation score plus the per-factor breakdown (rain, humidity, refractivity, …) for one grid point at one band.

Query parameters

<.properties> <:property name="band" type="integer" required>MHz. <:property name="lat" type="number" required>Decimal degrees. <:property name="lon" type="number" required>Decimal degrees. <:property name="valid_time" type="datetime"> ISO 8601 UTC. Defaults to the latest scored hour.

GET /scores/bands lists every band the engine scores for, with each band's humidity-effect classification (beneficial / harmful).

<:code> <.code_block title="Score for Dallas, 10 GHz" tag="GET" label="/scores" code={@sample_scores} /> <.endpoint id="forecast" method="GET" path="/forecast"> <:summary>18-hour score timeline <:body>

Returns the score at one grid point across the available forecast horizon (currently 18 hours). Same band + lat + lon parameters as /scores.

<:code> <.code_block title="18 h timeline" tag="GET" label="/forecast" code={@sample_forecast} /> <.endpoint id="profiles" method="GET" path="/profiles/:callsign"> <:summary>Public per-user profile <:body>

Public per-user profile: callsign, name, home QTH, all public contacts involving the callsign, plus all approved beacons submitted by the user. Email is not exposed.

<:code> <.code_block title="Read a profile" tag="GET" label="/profiles/W5XD" code={@sample_profile} />

Conventions

  • All timestamps are ISO 8601 UTC (...Z).
  • All identifiers are UUIDv7 (binary_id) strings.
  • Bands are integer MHz strings ("10000", "24000").
  • Latitude / longitude are decimal degrees.
  • Maidenhead grids are 4- or 6-character (EM12, EM12kx).

Stability promise

  • Adding new fields to existing responses is non-breaking.
  • Removing or renaming fields will only happen in a new /api/vN.
  • Adding new endpoints to /api/v1 is non-breaking.
  • Tightening validation may produce new 422s; called out in the changelog.

See also

  • <.link href="/docs/api/openapi.yaml" class="api-docs-link"> openapi.yaml — the machine-readable spec.
  • /.well-known/api-catalog — RFC 9727 service descriptor (public).
  • <.link navigate="/algo" class="api-docs-link">/algo — the scoring algorithm in detail.
""" end # ---------------------------------------------------------------- components attr :title, :string, required: true slot :inner_block, required: true defp nav_group(assigns) do ~H"""

{@title}

""" end attr :href, :string, required: true slot :inner_block, required: true defp nav_link(assigns) do ~H"""
  • {render_slot(@inner_block)}
  • """ end attr :id, :string, default: nil slot :col, required: true slot :code, required: true defp row(assigns) do ~H"""
    {render_slot(@col)}
    {render_slot(@code)}
    """ end attr :id, :string, default: nil attr :method, :string, required: true attr :path, :string, required: true slot :summary, required: true slot :body, required: true slot :code, required: true defp endpoint(assigns) do ~H"""
    <.method_tag method={@method} /> {@path}

    {render_slot(@summary)}

    {render_slot(@body)}
    {render_slot(@code)}
    """ end attr :method, :string, required: true defp method_tag(assigns) do color = case String.upcase(assigns.method) do "GET" -> "is-get" "POST" -> "is-post" "PUT" -> "is-put" "PATCH" -> "is-patch" "DELETE" -> "is-delete" _ -> "is-zinc" end assigns = assign(assigns, :color, color) ~H""" @color}>{String.upcase(@method)} """ end attr :code, :string, required: true attr :title, :string, default: nil attr :tag, :string, default: nil attr :label, :string, default: nil attr :lang, :string, default: "bash" defp code_block(assigns) do assigns = assign(assigns, :code, dedent(assigns.code)) ~H"""
    {@title} <.method_tag :if={@tag} method={@tag} /> {@label}
     @lang}>{@code}
    """ end # Strip uniform leading whitespace so source-side indentation is dropped. defp dedent(text) when is_binary(text) do text = String.trim_trailing(text) lines = String.split(text, "\n") indents = lines |> Enum.reject(&(String.trim(&1) == "")) |> Enum.map(&leading_spaces/1) case indents do [] -> text _ -> Enum.map_join(lines, "\n", &drop_prefix(&1, Enum.min(indents))) end end defp leading_spaces(line) do line |> String.graphemes() |> Enum.take_while(&(&1 == " ")) |> length() end defp drop_prefix(line, n) do case line do "" -> "" _ -> String.slice(line, min(n, String.length(line))..-1//1) end end slot :property do attr :name, :string, required: true attr :type, :string, required: true attr :required, :boolean end defp properties(assigns) do ~H"""
    {prop.name} {prop.type} required
    {render_slot(prop)}
    """ end end