# Microwaveprop REST 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 (user management, beacon
approval, contact moderation) are deliberately excluded from this API.

* **Base URL:** `https://prop.w5isp.com/api/v1`
* **Versioning:** path-based (`/api/v1`). Breaking changes will ship a
  `/api/v2` rather than mutate `/api/v1`.
* **Auth:** opaque bearer tokens (`Authorization: Bearer mwp_...`).
  The easiest way to mint a token is from your
  [account settings page](/users/settings#api-tokens) — log in, scroll
  to **API tokens**, name it, and copy the value (shown once). You can
  also mint one programmatically at `POST /api/v1/auth/tokens`. Either
  way, your password is never sent to API clients.
* **Format:** `application/json` for requests and successful responses;
  errors use [RFC 9457 problem+json](https://www.rfc-editor.org/rfc/rfc9457).
* **OpenAPI 3.1 spec:** [`openapi.yaml`](/docs/api/openapi.yaml).

## Quickstart

```bash
# 1. Mint a long-lived bearer token (one-time, requires your password).
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_..."}    <-- copy the token; it's shown once

# 2. Use the token for everything else.
TOKEN=mwp_...

curl -sS -H "Authorization: Bearer $TOKEN" https://prop.w5isp.com/api/v1/me
```

## Authentication

| Endpoint                | Auth     | Notes                                       |
|-------------------------|----------|---------------------------------------------|
| `POST /auth/tokens`     | password | The only endpoint that accepts a password.  |
| `GET /me`, `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 format

Tokens are 32 random bytes URL-base64-encoded, prefixed with `mwp_`. The
prefix lets static-analysis tools (`gitleaks`, `truffleHog`, etc.) match
leaked tokens. Only the SHA-256 of the token is stored server-side.

### 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.

## Errors

All error responses follow [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457):

```json
{
  "type": "about:blank",
  "title": "validation_failed",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "errors": {
    "callsign": ["must be 3-10 letters and digits"]
  }
}
```

| Status | When                                                              |
|--------|-------------------------------------------------------------------|
| 400    | Missing/malformed query or 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 (private contact, etc.). |
| 409    | Duplicate — an equivalent resource already exists.                |
| 422    | Validation failed; `errors` carries per-field messages.           |
| 429    | Rate limit exceeded; check `RateLimit-*` headers + `Retry-After`. |
| 5xx    | Bug. Please open an issue.                                        |

## Rate limiting

Each response carries the [RFC 9651](https://datatracker.ietf.org/doc/rfc9651/)
`RateLimit-*` headers:

| 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 secs. |

Defaults:

| Caller                      | Limit                      |
|-----------------------------|----------------------------|
| Anonymous (per IP)          | 60 req / minute            |
| Authenticated (per token)   | 600 req / minute           |
| `POST /auth/tokens` (per IP)| 30 req / minute            |

## Endpoint reference

### `POST /auth/tokens` — issue an API token

Prefer the [account settings page](/users/settings#api-tokens) for
interactive use; this endpoint is here for scripts and CLI tools that
need to mint a token without leaving the terminal.

Request:

```json
{
  "email": "you@example.com",
  "password": "your password",
  "name": "iPad",
  "expires_at": "2027-01-01T00:00:00Z"
}
```

`expires_at` is optional; omit it for a token that lives until revoked.

Response (201):

```json
{
  "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..."
}
```

### `GET /me` — current user

Returns the authenticated user's profile (callsign, name, email, home
QTH, is_admin flag).

### `PATCH /me` — update home QTH

Accepts any subset of `home_grid` (Maidenhead), `home_lat`, `home_lon`,
`home_elevation_m`. The grid is auto-derived from lat/lon and vice
versa.

### `GET /me/contacts`

Every QSO submitted under the authenticated user's account, newest first.

### `GET /me/beacons`

Every beacon (approved or pending) submitted by the user.

### `GET /me/api-tokens`

List the user's non-revoked API tokens.

### `DELETE /me/api-tokens/:id`

Revoke a token. Returns the updated record (with `revoked_at` set).

### `GET /me/beacon-monitors`, `POST /me/beacon-monitors`, `DELETE /me/beacon-monitors/:id`

CRUD for the user's distributed beacon monitor stations. Each monitor
has a `token` field — the credential the monitor program uses to
identify itself when reporting.

### `GET /contacts`

Paginated public list of QSOs.

| Query     | Default | Notes                                               |
|-----------|---------|-----------------------------------------------------|
| `page`    | `1`     | 1-based.                                            |
| `per_page`| `50`    | Capped at 200.                                      |
| `search`  | —       | One or two callsigns; matches station1 / station2.  |

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

### `GET /contacts/:id`

A single QSO. Private QSOs return 404 to non-owners.

### `POST /contacts`

Submit a new QSO. Required fields:

```json
{
  "station1": "W5XD",
  "station2": "K5XD",
  "qso_timestamp": "2026-05-08T12:34:00Z",
  "band": "10000",
  "grid1": "EM12",
  "grid2": "EM13",
  "mode": "CW"
}
```

Optional: `user_declared_prop_mode`, `height1_ft`, `height2_ft`,
`private`, `notes`. The QSO is automatically attributed to the
authenticated user; their email is recorded as `submitter_email`.

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

### `GET /beacons`, `GET /beacons/:id`, `POST /beacons`

Approved beacons listing, single-beacon read, and unauthenticated submit
(the new beacon starts in `approved=false` state until an admin approves
it via the website).

### `GET /scores/bands`

Lists every band the propagation engine scores for, with their humidity-
effect classification.

### `GET /scores`

Returns the propagation score + factor breakdown at a grid point.

| Query        | Notes                                              |
|--------------|----------------------------------------------------|
| `band`       | MHz integer. Required.                             |
| `lat`, `lon` | Decimal degrees. Required.                         |
| `valid_time` | ISO 8601 UTC. Optional; defaults to latest hour.   |

### `GET /forecast`

The 18-hour score timeline at a grid point. Same `band` + `lat` + `lon`
parameters; returns an array of `{valid_time, score}` tuples.

### `GET /profiles/:callsign`

Public per-user profile (callsign + name + home QTH) plus all public
contacts involving the callsign and all approved beacons submitted by
the user. Email is **not** exposed.

## 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 grid squares 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 422 responses; these are not
  considered breaking either, but will be called out in the changelog.

## See also

* [`openapi.yaml`](/docs/api/openapi.yaml) — the machine-readable spec.
* `/.well-known/api-catalog` — RFC 9727 service descriptor (public).
* `/algo` — the scoring algorithm in detail.
