The activities server is the backend behind Carrier Wave: Field (iOS), CW Sweep (macOS), Carrier Wave for Android, and the logbook web viewer. One service exposes everything described on this page:

  • a versioned JSON API under /v1
  • an OAuth 2.0 / OpenID Connect provider so other apps can act for a user
  • an end-to-end encrypted sync journal for the user’s log
  • server-rendered share and invite pages
  • public reference data: spots, Reverse Beacon Network, POTA statistics, park boundaries, historic trails, satellite status, contest definitions, radio macros, an equipment catalog

This page is the complete wire-level reference. For how the features behave inside the apps, see the Reference Manual .

Who this is for. Anyone can read the public endpoints. The authenticated social endpoints (challenges, friends, clubs, events, feed) are first-party only: they accept Carrier Wave’s own app tokens and reject third-party OAuth tokens. Third-party developers get the user’s identity and the sync journal. If you need more, write to hello@carrierwave.app .

Reading this with a tool or an agent? This page is also served as plain Markdown at https://carrierwave.app/manual/developers/api/index.md , and https://carrierwave.app/llms.txt lists everything machine-readable. The short version with ready-to-run requests is Using the API from agents and tools .

Hosts and versioning

EnvironmentBase URLNotes
Productionhttps://activities.carrierwave.appThe only host you should integrate with.
Staginghttps://staging.carrierwave.appPre-release builds. Not for integration.

All JSON API routes are nested under /v1. There is no /v2: changes are made additively because shipped app builds keep calling old shapes for a long time. Fields may be added to responses at any time; clients must ignore unknown fields.

A few routes live at the domain root because external software expects them at conventional paths:

  • /.well-known/openid-configuration and /.well-known/oauth-authorization-server (OAuth discovery)
  • /.well-known/jwks.json (token verification keys)
  • /.well-known/apple-app-site-association (iOS universal links)
  • /invite/:token and /link/... (HTML landing pages for shared URLs)
  • /metrics (Prometheus exposition)

Unknown paths under /v1 return a JSON 404 (see Error format ).

Conventions

  • JSON everywhere unless an endpoint says otherwise. Request and response bodies use camelCase field names. The exceptions are the OAuth token endpoints, which follow the RFC 6749 snake_case wire format and accept form-encoded bodies, and the Twilio webhook.
  • Identifiers are UUIDs unless the resource has a natural key: program slug, macro slug, park reference, contest id, callsign.
  • Timestamps are RFC 3339 in UTC with a Z suffix.
  • Callsigns are normalised to upper case by the server. Path parameters that take a callsign accept any case.
  • Response envelopes. The original challenge-era endpoints wrap their payload in {"data": ...}. Endpoints added later return the object or array directly. Each endpoint below shows its exact shape; do not assume.
  • Pagination is offset-based (limit and offset query parameters) on list endpoints that paginate. The activity feed uses a cursor. Limits and maxima are listed per endpoint.
  • Request ids. Every response carries X-Request-Id. Send your own and it is echoed back; otherwise one is generated. Quote it when reporting a problem.
  • Conditional requests. Several reference-data endpoints emit an ETag and honour If-None-Match with 304 Not Modified. They are called out individually. Use them: the datasets are large and change slowly.

Authentication

Credentials are sent as Authorization: Bearer <token>. Five situations exist:

CredentialLooks likeWho holds itObtained fromAccepted on
NoneAnyonePublic routes
Device tokenfd_…Older app installsJoining a challenge or POST /v1/registerPublic and first-party routes
Account tokenEd25519-signed JWT without a cid claimCarrier Wave’s own appsPOST /v1/auth/token after email verificationEverything a device token reaches, plus /v1/auth/* account management
Third-party OAuth tokenEd25519-signed JWT with a cid claim and scopeApps written by other developers, acting for a userPOST /v1/oauth/tokenOnly the routes listed under Third-party access : the sync journal and OIDC userinfo. Everything else answers 403 FORBIDDEN.
Admin tokenOpaque shared secretCarrier Wave operatorsConfiguration/v1/admin/* only

How routes treat a bearer:

  • Public routes accept an optional bearer. A valid one personalises the response (for example a leaderboard’s userPosition). An invalid, expired, revoked or out-of-scope token is silently ignored and the request proceeds anonymously, so public routes never return 401.
  • First-party routes require a bearer. Missing or invalid gives 401 INVALID_TOKEN. A third-party token outside its allowed set gives 403 FORBIDDEN.
  • Account-management routes under /v1/auth/ accept only account tokens, never fd_ device tokens.
  • Admin routes compare the bearer to the operator secret.

Some first-party routes also require that the caller has an account (an email-verified identity) and not merely a device token. Those answer 403 ACCOUNT_REQUIRED; the sync journal is the main example.

The sign-in flows are described in full under Authentication and accounts and OAuth 2.0 / OpenID Connect provider .

Third-party access

A token minted for a registered OAuth client is default-denied: it is refused on every route not in this table, regardless of scope. The absence of an entry is itself the policy.

MethodPathScope required
GET/v1/sync/bootstrapsync:read
GET/v1/sync/changessync:read
GET/v1/sync/keysync:read
POST/v1/sync/pushsync:write
POST/v1/sync/snapshotsync:write
POST/v1/sync/snapshot/commitsync:write
PUT/v1/sync/keysync:write
GET/v1/oauth/userinfoopenid

The scopes a client may request, with the wording the user sees on the consent screen:

ScopeConsent text
openidConfirm your Carrier Wave account
profileSee your callsign and public profile
emailSee the email address on your account
sync:readDownload your encrypted log. The app still needs your sync passphrase to read it; this server cannot decrypt it and neither can anyone without that passphrase.
sync:writeAdd and update QSOs in your log, and upload encrypted log data

Unknown scope strings are dropped from the request rather than rejected.

Rate limits, CORS and body size

ScopeLimit
Whole server10 requests per second, burst 20
POST /v1/register1 request per minute, burst 1
POST /v1/auth/verify/start1 request per 20 seconds, burst 3
POST /v1/macros/:slug/run-eventPer-account limiter (see Radio macros)
Event submissionsAt most 10 pending per user

When a limit trips the response is not the JSON error shape. It is 429 Too Many Requests with a plain-text body Too Many Requests! Wait for <n>s and an x-ratelimit-after: <n> header. No X-RateLimit-Limit or X-RateLimit-Remaining headers are sent. The limits are enforced inside the application on the aggregate traffic that reaches it, so treat them as a ceiling, not a per-client allowance, and back off on 429.

CORS allows the origins https://carrierwave.app and https://activities.carrierwave.app, the methods GET POST PUT DELETE OPTIONS, and the headers Authorization and Content-Type. Browser-based third-party apps therefore cannot call the API directly from another origin; use a backend. Native and CLI clients are unaffected.

Request bodies are capped at 1 MiB. Upload endpoints that need more (badge images, club logos, sync pushes and snapshots) say so in their notes. Oversized bodies are rejected before the handler runs, as 400 VALIDATION_ERROR on most endpoints.

Error format

Application errors are JSON:

{
  "error": {
    "code": "CHALLENGE_NOT_FOUND",
    "message": "Challenge not found",
    "details": { "challengeId": "7d6c…" }
  }
}
  • code is a stable upper-snake identifier. Branch on it, never on message.
  • message is human-readable and may change.
  • details is omitted when there is nothing to add.

Unknown /v1 paths return 404 with code NOT_FOUND. Malformed JSON, a body that fails validation, or an unparseable path parameter is 400 VALIDATION_ERROR with the parser’s explanation as the message. A few older handlers answer those cases with a plain-text 400 or 422 instead. 304 Not Modified has no body.

The OAuth token, device-code and revoke endpoints use the RFC 6749 error shape ({"error": "invalid_grant", "error_description": "…"}).

The complete list of application error codes is in Appendix: error codes .

Endpoint index

Every route the server mounts. “Auth” is the gate the route sits behind; individual handlers may add further checks such as ownership, account required, or scope.

MethodPathAuth
GET/v1/challengesPublic (optional bearer)
GET/v1/challenges/:idPublic (optional bearer)
POST/v1/challenges/:id/joinPublic (optional bearer)
GET/v1/challenges/:id/leaderboardPublic (optional bearer)
GET/v1/contestsPublic (optional bearer)
GET/v1/contests/:idPublic (optional bearer)
GET/v1/badges/:id/imagePublic (optional bearer)
GET/v1/achievementsPublic (optional bearer)
GET/v1/users/:callsign/achievementsPublic (optional bearer)
GET/v1/programsPublic (optional bearer)
GET/v1/programs/:slugPublic (optional bearer)
GET/v1/spotsPublic (optional bearer)
GET/v1/callsign-enrichment/datasetPublic (optional bearer)
GET/v1/callsign-enrichment/:callsignPublic (optional bearer)
GET/v1/satellites/amsat-statusPublic (optional bearer)
GET/v1/healthPublic (optional bearer)
GET/v1/users/searchPublic (optional bearer)
POST/v1/registerPublic (optional bearer)
GET/v1/pota/stats/activatorPublic (optional bearer)
GET/v1/pota/stats/hunterPublic (optional bearer)
GET/v1/pota/stats/state/:statePublic (optional bearer)
GET/v1/pota/stats/park/:referencePublic (optional bearer)
GET/v1/pota/stats/rankings/activatorsPublic (optional bearer)
GET/v1/pota/stats/statusPublic (optional bearer)
GET/v1/parks/boundariesPublic (optional bearer)
GET/v1/parks/boundaries/statusPublic (optional bearer)
GET/v1/parks/boundaries/:referencePublic (optional bearer)
GET/v1/admin/boundariesPublic (optional bearer)
GET/v1/trailsPublic (optional bearer)
GET/v1/trails/statusPublic (optional bearer)
GET/v1/trails/:referencePublic (optional bearer)
GET/v1/clubs/:id/logoPublic (optional bearer)
GET/v1/equipment/catalogPublic (optional bearer)
GET/v1/equipment/searchPublic (optional bearer)
POST/v1/equipment/submissionsPublic (optional bearer)
GET/v1/eventsPublic (optional bearer)
GET/v1/events/:idPublic (optional bearer)
GET/v1/macrosPublic (optional bearer)
GET/v1/macros/versionsPublic (optional bearer)
GET/v1/macros/tombstonesPublic (optional bearer)
GET/v1/macros/:slugPublic (optional bearer)
GET/v1/macros/:slug/versionsPublic (optional bearer)
GET/v1/rbn/spotsPublic (optional bearer)
GET/v1/rbn/statsPublic (optional bearer)
GET/v1/rbn/skimmersPublic (optional bearer)
GET/v1/websdr/directoryPublic (optional bearer)
GET/v1/websdr/statusPublic (optional bearer)
GET/v1/websdr/streamPublic (optional bearer)
POST/v1/twilio/smsPublic (optional bearer)
POST/v1/metricsPublic (optional bearer)
POST/v1/diagnosticsPublic (optional bearer)
POST/v1/telemetry/equipment-usagePublic (optional bearer)
POST/v1/challenges/:id/progressBearer (first-party)
GET/v1/challenges/:id/progressBearer (first-party)
DELETE/v1/challenges/:id/leaveBearer (first-party)
GET/v1/challenges/:id/participants/:callsignBearer (first-party)
GET/v1/participants/:callsign/challengesBearer (first-party)
GET/v1/friends/invite-linkBearer (first-party)
POST/v1/friends/requestsBearer (first-party)
POST/v1/friends/suggestionsBearer (first-party)
GET/v1/friendsBearer (first-party)
GET/v1/friends/requests/pendingBearer (first-party)
POST/v1/friends/requests/:id/acceptBearer (first-party)
POST/v1/friends/requests/:id/declineBearer (first-party)
DELETE/v1/friends/:idBearer (first-party)
POST/v1/activitiesBearer (first-party)
DELETE/v1/activities/:idBearer (first-party)
POST/v1/spotsBearer (first-party)
DELETE/v1/spots/:idBearer (first-party)
GET/v1/feedBearer (first-party)
GET/v1/clubsBearer (first-party)
GET/v1/clubs/syncBearer (first-party)
GET/v1/clubs/membershipBearer (first-party)
GET/v1/clubs/:idBearer (first-party)
GET/v1/clubs/:id/activityBearer (first-party)
GET/v1/clubs/:id/statusBearer (first-party)
PUT/v1/clubs/:id/notesBearer (first-party)
POST/v1/eventsBearer (first-party)
GET/v1/events/mineBearer (first-party)
PUT/v1/events/:idBearer (first-party)
DELETE/v1/events/:idBearer (first-party)
POST/v1/telemetry/upload-errorsBearer (first-party)
POST/v1/spot-markersBearer (first-party)
POST/v1/sota/zonesBearer (first-party)
DELETE/v1/accountBearer (first-party)
POST/v1/users/me/achievements/signalBearer (first-party)
PUT/v1/account/callsignBearer (first-party)
POST/v1/account/claim-previousBearer (first-party)
POST/v1/macros/:slug/run-eventBearer (first-party)
POST/v1/sync/pushBearer (first-party)
GET/v1/sync/changesBearer (first-party)
POST/v1/sync/snapshotBearer (first-party)
POST/v1/sync/snapshot/commitBearer (first-party)
GET/v1/sync/bootstrapBearer (first-party)
PUT/v1/sync/keyBearer (first-party)
GET/v1/sync/keyBearer (first-party)
POST/v1/oauth/authorize/decisionBearer (first-party)
POST/v1/oauth/device/decisionBearer (first-party)
GET/v1/oauth/userinfoBearer (first-party)
GET/v1/oauth/grantsBearer (first-party)
DELETE/v1/oauth/grants/:client_idBearer (first-party)
GET/v1/admin/oauth/clientsAdmin token
POST/v1/admin/oauth/clientsAdmin token
DELETE/v1/admin/oauth/clients/:client_idAdmin token
GET/v1/admin/sync/orphansAdmin token
POST/v1/admin/sync/orphans/:participant_id/adoptAdmin token
POST/v1/admin/challengesAdmin token
PUT/v1/admin/challenges/:idAdmin token
DELETE/v1/admin/challenges/:idAdmin token
POST/v1/admin/contestsAdmin token
DELETE/v1/admin/contests/:idAdmin token
POST/v1/admin/contests/validateAdmin token
POST/v1/admin/challenges/:id/badgesAdmin token
GET/v1/admin/challenges/:id/badgesAdmin token
DELETE/v1/admin/badges/:idAdmin token
POST/v1/admin/achievements/:id/awardAdmin token
DELETE/v1/admin/achievements/:id/award/:callsignAdmin token
POST/v1/admin/achievements/:id/award/batchAdmin token
POST/v1/admin/achievements/backfillAdmin token
POST/v1/admin/challenges/:id/invitesAdmin token
GET/v1/admin/challenges/:id/invitesAdmin token
DELETE/v1/admin/invites/:tokenAdmin token
POST/v1/admin/programsAdmin token
GET/v1/admin/programsAdmin token
PUT/v1/admin/programs/:slugAdmin token
GET/v1/admin/programs/:slugAdmin token
DELETE/v1/admin/programs/:slugAdmin token
POST/v1/admin/clubsAdmin token
GET/v1/admin/clubsAdmin token
PUT/v1/admin/clubs/:idAdmin token
DELETE/v1/admin/clubs/:idAdmin token
POST/v1/admin/clubs/:id/membersAdmin token
GET/v1/admin/clubs/:id/membersAdmin token
POST/v1/admin/clubs/:id/import-notesAdmin token
PUT/v1/admin/clubs/:id/logoAdmin token
DELETE/v1/admin/clubs/:id/logoAdmin token
DELETE/v1/admin/clubs/:id/members/:callsignAdmin token
PUT/v1/admin/clubs/:id/members/:callsignAdmin token
POST/v1/admin/equipmentAdmin token
PUT/v1/admin/equipment/:idAdmin token
DELETE/v1/admin/equipment/:idAdmin token
GET/v1/admin/equipment/submissionsAdmin token
PUT/v1/admin/equipment/submissions/:id/reviewAdmin token
GET/v1/admin/eventsAdmin token
GET/v1/admin/events/:idAdmin token
PUT/v1/admin/events/:idAdmin token
DELETE/v1/admin/events/:idAdmin token
PUT/v1/admin/events/:id/reviewAdmin token
GET/v1/admin/events/submitter/:callsignAdmin token
GET/v1/admin/telemetry/upload-errorsAdmin token
GET/v1/admin/metrickitAdmin token
POST/v1/admin/macrosAdmin token
GET/v1/admin/macrosAdmin token
PUT/v1/admin/macros/:slugAdmin token
PATCH/v1/admin/macros/:slugAdmin token
DELETE/v1/admin/macros/:slugAdmin token
DELETE/v1/admin/spots/:idAdmin token
GET/v1/admin/trails/statusAdmin token
GET/v1/admin/statsAdmin token
GET/v1/admin/stats/users-by-hourAdmin token
POST/v1/propagation/p2pNone
POST/v1/propagation/wheelNone
POST/v1/auth/verify/startPublic
GET/v1/auth/verify/completePublic
POST/v1/auth/verify/pollPublic
POST/v1/auth/tokenPublic
POST/v1/auth/token/refreshPublic
POST/v1/auth/logoutPublic
POST/v1/auth/logout/allJWT bearer (account)
GET/v1/auth/devicesJWT bearer (account)
DELETE/v1/auth/devices/:idJWT bearer (account)
POST/v1/auth/callsigns/verify/startJWT bearer (account)
GET/v1/oauth/authorizePublic / client credentials
POST/v1/oauth/tokenPublic / client credentials
GET/v1/oauth/devicePublic / client credentials
POST/v1/oauth/device/codePublic / client credentials
POST/v1/oauth/revokePublic / client credentials
GET/.well-known/jwks.jsonPublic
GET/.well-known/openid-configurationPublic
GET/.well-known/oauth-authorization-serverPublic
GET/invite/:tokenPublic (HTML)
GET/link/qso/:tokenPublic
GET/link/act/:tokenPublic
GET/link/call/:callsignPublic
GET/link/park/:referencePublic
GET/link/sum/:referencePublic
GET/link/static/link-card.jsPublic
GET/.well-known/apple-app-site-associationPublic
GET/metricsPublic

Routes marked Admin token are operated by Carrier Wave staff through the admin console. They are listed for completeness and summarised briefly in each section; their request and response shapes are not documented here.

Authentication and accounts

All paths are under /v1 unless noted. Carrier Wave uses two kinds of credential.

Device tokens. The original scheme. A device token is an opaque string with the fd_ prefix, issued by POST /v1/register on the first registration of a callsign (and by challenge join). It carries no account identity and cannot be recovered if lost.

Account tokens. A Carrier Wave account owns one or more verified callsigns and issues two tokens per device:

  • Access tokens are JWTs signed with EdDSA (Ed25519). They live for 15 minutes; the server allows 60 seconds of clock skew. The header kid names the signing key published at /.well-known/jwks.json. Claims:
    • iss: always https://activities.carrierwave.app
    • sub: the account id (UUID)
    • aud: cw-social (accepted by this API) or cw-sync (accepted only by the sync service). A token minted for one audience is rejected by the other.
    • scope: space-delimited scopes from the set sync:read sync:write social:read social:write account. If you request no scopes or only unknown ones, you receive the full set.
    • cs: the account’s primary callsign. Informational only; the server never uses it for authorization and re-resolves the acting callsign from the account.
    • iat, exp: unix seconds
    • jti: random UUID
    • cid: present only on tokens issued to third-party OAuth clients. Tokens issued by the endpoints in this section never carry it. Third-party tokens are refused (403 FORBIDDEN) by every endpoint in this section and treated as anonymous by the public ones.
  • Refresh tokens are opaque strings with the cwr_ prefix. Each has a sliding lifetime of 60 days, and the family it belongs to has an absolute lifetime of 180 days, after which you must verify by email again. Every refresh rotates the token. Re-presenting the previous token within a 60 second grace window returns the current live pair; outside that window the server treats it as reuse and revokes the whole family. One refresh family corresponds to one device.

Email verification flow. Sign-in and onboarding use a magic link plus PKCE.

  1. Generate a PKCE verifier and compute pkceChallenge = hex(sha256(verifier)), 64 hex characters (not base64url). Call POST /v1/auth/verify/start with the callsign and optionally a fallback email. If the amateur radio directory has an email for that exact callsign, the link goes there; otherwise it goes to the email you supplied. If the callsign already belongs to an account, the link goes only to that account’s on-file address and your email is ignored. The response is always 202 with a pollToken, whether or not any mail could be sent.
  2. The email contains a link to GET /v1/auth/verify/complete?code=<code>. Opening it marks the verification as clicked (single use) and shows a static page. The code alone is not a credential.
  3. Finish one of two ways. If your app received the code (for example through a universal link), call POST /v1/auth/token with code and pkceVerifier. Otherwise poll POST /v1/auth/verify/poll with pollToken and pkceVerifier; 202 means not clicked yet, any other status is the same result /auth/token would return. Both paths consume the verification.
  4. The outcome depends on what was started. Onboarding (unclaimed callsign): a new account, device and tokens, 201. Sign-in (callsign already owned): a new device on that account, 201. Attach (started from /auth/callsigns/verify/start by a signed-in account): the callsign is added to the account and no tokens are issued, 204.

Verifications expire 15 minutes after they are started.

Device management. Every token issuance creates a device (deviceName, platform) with its own refresh family. GET /auth/devices lists devices, DELETE /auth/devices/:id revokes one, POST /auth/logout revokes the family of a given refresh token, and POST /auth/logout/all revokes everything.

Revocation. Access tokens are verified without a database lookup, so revocation has two strengths. POST /auth/logout/all and PUT /account/callsign invalidate every existing access token for the account immediately, including the one used to make the call. POST /auth/logout and DELETE /auth/devices/:id revoke only the refresh family: an access token already issued for that device stays valid until it expires, at most 15 minutes later.

Which bearer an endpoint accepts.

  • Public: no credential needed. A bearer, if sent, is ignored.
  • Bearer (first-party): a device token or a first-party account access token. An account token acts as the account’s primary callsign.
  • Account token: an account access token only, with the account scope. A device token is rejected with 401 INVALID_TOKEN; a token without the scope is rejected with 403 FORBIDDEN.

ACCOUNT_REQUIRED. Some features outside this section (sync, OAuth user info and grants) need an account-backed session. When you call them with a device token you receive 403 with code ACCOUNT_REQUIRED and a message saying that a Carrier Wave account is required. Sign in with the email flow above to continue.

The /v1/auth/* and /.well-known/* endpoints exist only when account sign-in is enabled on the server; otherwise they return 404 and only device tokens are accepted.

Sign-in and tokens

Start callsign verification

POST /v1/auth/verify/start

Auth: Public Rate limit: 1 request per 20 s per IP, burst 3, in addition to the global limit.

Request

{
  "callsign": "W6JY",
  "email": "me@example.com",
  "pkceChallenge": "3f1a…64 hex chars…"
}
FieldTypeRequiredNotes
callsignstringyesTrimmed; 1 to 16 characters. Stored upper-cased.
emailstringnoFallback address, used only when the directory has no email for the callsign and the callsign is not already owned by an account. Must be non-empty, at most 254 characters, and contain @. Silently ignored if invalid.
pkceChallengestringyesExactly 64 hex characters: hex(sha256(verifier)).

Response 202 Accepted

{ "pollToken": "cwp_<64 hex>" }

Always 202 with a fresh poll token, even if no email was sent (unknown callsign with no deliverable address, account with no stored email, delivery failure). You cannot tell from the response whether mail went out.

Errors

CodeHTTPWhen
VALIDATION_ERROR400Empty or over-long callsign; pkceChallenge not 64 hex; malformed JSON
INTERNAL_ERROR500Server failure
(plain text)429Route or global rate limit

Notes

  • If the callsign already belongs to an account, the link goes to the directory email if one resolves, else the account’s stored verified email. Your email is ignored.
  • The emailed link is https://activities.carrierwave.app/v1/auth/verify/complete?code=<32 hex>.
GET /v1/auth/verify/complete?code=<code>

Auth: Public

ParamTypeRequiredDescription
code (query)stringyesThe 32-hex code from the emailed link.

Response 200 OK, Content-Type: text/html. A self-contained Carrier Wave page saying either “Callsign verified” (return to the app to finish signing in) or “Link expired” (invalid or expired). Status is 200 in both cases. Headers: referrer-policy: no-referrer, content-security-policy: default-src 'none'; style-src 'unsafe-inline'; img-src data:, cache-control: no-store.

Errors

CodeHTTPWhen
(plain text)400code query parameter missing

Notes

  • Single use: a second visit shows the “expired” page.
  • Opening the link does not issue tokens. The app must still present the PKCE verifier to /auth/token or /auth/verify/poll.

Poll for verification completion

POST /v1/auth/verify/poll

Auth: Public

Request

{
  "pollToken": "cwp_<64 hex>",
  "pkceVerifier": "the-verifier-whose-sha256-hex-was-sent",
  "deviceName": "Jay's iPhone",
  "platform": "ios",
  "scopes": "sync:read sync:write social:read social:write account"
}
FieldTypeRequiredNotes
pollTokenstringyesFrom /auth/verify/start.
pkceVerifierstringyesMust satisfy hex(sha256(verifier)) == pkceChallenge.
deviceNamestringnoShown in GET /auth/devices.
platformstringnoFree-form, shown in GET /auth/devices.
scopesstringnoSpace-separated. Unknown scopes are dropped; if nothing valid remains, all five scopes are granted.

Response, one of:

  • 202 Accepted, empty body: link not yet clicked, poll token unknown, or verification expired. The server does not distinguish these, so stop polling after 15 minutes.
  • 201 Created, onboarding or new-device sign-in:
    {
      "access": "<EdDSA JWT>",
      "refresh": "cwr_<64 hex>",
      "expiresIn": 900,
      "accountId": "4c4a1b3e-…"
    }
    
  • 204 No Content: the verification was an attach (started via /auth/callsigns/verify/start). The callsign is now on the account; no new tokens.

Errors

CodeHTTPWhen
FORBIDDEN403Link was clicked but pkceVerifier does not match the challenge. The verification is not consumed; retry with the right verifier.
INVALID_TOKEN401Sign-in path: the account the verification was bound to no longer exists.
CALLSIGN_TAKEN409Callsign became owned by another account between start and completion. details.callsign.
VALIDATION_ERROR400Malformed JSON or missing required fields

Notes

  • Tokens are identical to those from /auth/token. The access token audience is always cw-social.
  • On 201 a new device and refresh family are created; on onboarding the account is created too.

Exchange a clicked verification code for tokens

POST /v1/auth/token

Auth: Public

Request

{
  "code": "<32 hex from the emailed link>",
  "pkceVerifier": "…",
  "deviceName": "Jay's iPhone",
  "platform": "ios",
  "scopes": "sync:read sync:write"
}

code and pkceVerifier are required. The optional fields and their defaults are the same as /auth/verify/poll.

Response: 201 Created with access, refresh, expiresIn (900) and accountId, or 204 No Content for the attach case. Same semantics as /auth/verify/poll.

Errors

CodeHTTPWhen
INVALID_TOKEN401No clicked, unexpired verification with that code (never clicked, expired, already exchanged, or unknown)
FORBIDDEN403PKCE verifier mismatch; the verification is not consumed
CALLSIGN_TAKEN409As above
VALIDATION_ERROR400Malformed JSON

Notes

  • The link must have been opened first. A successful exchange consumes the code.
  • Not to be confused with POST /v1/oauth/token, the third-party OAuth token endpoint.

Refresh (rotate) tokens

POST /v1/auth/token/refresh

Auth: Public (the refresh token in the body is the credential)

Request

{ "refresh": "cwr_<64 hex>", "aud": "cw-social" }
FieldTypeRequiredNotes
refreshstringyesCurrent live refresh token, or the one just rotated within the last 60 s.
audstringno"cw-social" (default) or "cw-sync". Anything else is a 400.

Response 200 OK

{ "access": "<JWT>", "refresh": "cwr_<new 64 hex>", "expiresIn": 900 }

Errors

CodeHTTPWhen
VALIDATION_ERROR400Unknown aud; malformed JSON
FORBIDDEN403The refresh token belongs to a third-party OAuth grant (use /v1/oauth/token). Checked before rotation, so the token is not consumed.
INVALID_TOKEN401Unknown token; expired (60-day sliding); family older than 180 days (family revoked); reuse detected (replay outside the 60 s grace window, or a revoked token; the whole family is revoked); account missing.

Notes

  • Rotation is serialized per family. A concurrent or retried refresh with the previous token inside the grace window returns a fresh pair rather than revoking, so an app, widget and watch sharing one device do not lock each other out.
  • Scopes carry over from the refresh token. cs is re-read from the account’s primary callsign at mint time.

Log out this device

POST /v1/auth/logout

Auth: Public (refresh token in body)

Request

{ "refresh": "cwr_<64 hex>" }

Response: 204 No Content. Idempotent: an unknown token also returns 204.

Errors

CodeHTTPWhen
VALIDATION_ERROR400Malformed JSON
INTERNAL_ERROR500Server failure

Notes

  • Revokes every token in the refresh family. The device entry is not removed from GET /auth/devices, and an outstanding access token for the device remains valid until it expires (at most 15 minutes).
  • Works with any token in the family, including an already-rotated one.

Account management

These endpoints require an account access token with aud cw-social, a valid signature, unexpired, not revoked, and carrying the account scope. Checks run in that order: a missing, invalid, expired or revoked bearer is 401 INVALID_TOKEN; a third-party token or a missing account scope is 403 FORBIDDEN. A device token is rejected with 401 INVALID_TOKEN.

Log out everywhere

POST /v1/auth/logout/all

Auth: Account token

No params, no body.

Response: 204 No Content.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing, invalid or revoked bearer
FORBIDDEN403Missing account scope, or third-party token

Notes

  • Revokes every refresh token for the account and immediately invalidates every access token issued before the call, including the one used to make it. Device entries are not deleted, so a fresh session still sees them in GET /auth/devices.

List devices

GET /v1/auth/devices

Auth: Account token

Response 200 OK

{
  "data": [
    {
      "id": "0e7c…",
      "deviceName": "Jay's iPhone",
      "platform": "ios",
      "createdAt": "2026-10-01T18:04:11.120Z",
      "lastSeenAt": "2026-10-01T18:04:11.120Z"
    }
  ]
}

Ordered by lastSeenAt descending. deviceName and platform are null when not supplied at token issuance. At present lastSeenAt is set when the device is created and not updated afterwards, so it matches createdAt.

Errors: 401 INVALID_TOKEN, 403 FORBIDDEN as above.

Notes: Devices whose refresh family was revoked via /auth/logout or /auth/logout/all still appear. Only DELETE /auth/devices/:id removes an entry.

Revoke a device

DELETE /v1/auth/devices/:id

Auth: Account token

ParamTypeRequiredDescription
id (path)UUIDyesDevice id from GET /auth/devices.

Response: 204 No Content. Idempotent: 204 even if the device does not exist or belongs to another account.

Errors

CodeHTTPWhen
(plain text)400id is not a valid UUID
INVALID_TOKEN401Bearer problems
FORBIDDEN403Missing account scope, or third-party token

Notes: Revokes that device’s refresh tokens and removes the device entry. The device’s current access token lives on until expiry.

Start verification of an additional callsign

POST /v1/auth/callsigns/verify/start

Auth: Account token Rate limit: none route-specific (only the global limit), unlike the public /auth/verify/start.

Request: same body as /auth/verify/start: callsign (required), email (optional fallback), pkceChallenge (required, 64 hex).

Response: 202 Accepted { "pollToken": "cwp_…" }.

Errors: VALIDATION_ERROR 400, INVALID_TOKEN 401, FORBIDDEN 403 (scope), INTERNAL_ERROR 500.

Notes

  • The verification is bound to your account. Ownership by another account is not checked at this stage; the directory-else-supplied email is used. On completion via /auth/token or /auth/verify/poll: if your account already owns the callsign, the completion behaves like a sign-in (new device and tokens, 201); otherwise the callsign is attached and you receive 204, or 409 CALLSIGN_TAKEN if another account holds it.
  • Attaching a callsign does not change the account’s primary callsign. Use PUT /account/callsign for that.

Key discovery

JSON Web Key Set

GET /.well-known/jwks.json

Auth: Public. Served at the domain root, not under /v1.

Response 200 OK, headers content-type: application/json, cache-control: public, max-age=3600:

{
  "keys": [
    { "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "kid": "v1", "x": "<base64url raw 32-byte public key>" }
  ]
}

The set contains exactly one key, the current signing key. After a key rotation, tokens signed with the previous key no longer verify.

Errors: none specific (404 when account sign-in is disabled).

Notes: The same location also serves /.well-known/openid-configuration and /.well-known/oauth-authorization-server for the third-party OAuth provider, documented separately.

Both endpoints are public. A bearer, if present and valid, is accepted but has no effect on the response; an invalid bearer is ignored, not rejected.

Register a callsign (legacy device token)

POST /v1/register

Auth: Public (optional bearer; no effect) Rate limit: 1 request per 60 s per IP, burst 1.

Request

{ "callsign": "w6jy ", "deviceName": "Jay's iPhone" }
FieldTypeRequiredNotes
callsignstringyesTrimmed and upper-cased before validation and storage; 1 to 16 characters.
deviceNamestringnoAt most 128 characters.

Response 201 Created

{ "data": { "userId": "b1a2…", "deviceToken": "fd_<32 alnum>" } }

deviceToken is omitted entirely when the callsign is already registered. Re-registering an existing callsign never yields a credential. userId (the friend-search identity) is returned in both cases. Status is 201 even when nothing new was created.

Errors

CodeHTTPWhen
VALIDATION_ERROR400Empty callsign; callsign longer than 16 characters; deviceName longer than 128 characters; malformed JSON
(plain text)429Register or global rate limit
INTERNAL_ERROR500Server failure

Notes

  • Callsign ownership is not verified here; anyone can register any unclaimed callsign.
  • A lost device token cannot be reissued. Recover access through the email verification flow.

Search users by callsign

GET /v1/users/search?q=<fragment>

Auth: Public (optional bearer; no effect)

ParamTypeRequiredDescription
q (query)stringyesCase-insensitive substring match on callsign. Shorter than 2 bytes returns an empty result.

Response 200 OK

{ "data": [ { "userId": "b1a2…", "callsign": "W6JY", "displayName": null } ] }

Up to 20 rows, ordered by callsign. displayName is currently always null.

Errors

CodeHTTPWhen
(plain text)400q missing
INTERNAL_ERROR500Server failure

Notes: The length check on q counts bytes, not characters. % and _ in q act as SQL wildcards.

Account changes

These endpoints accept a device token or a first-party account access token. A missing or invalid bearer is 401 INVALID_TOKEN; a third-party token is 403 FORBIDDEN. An account token acts as the account’s primary callsign.

Delete account

DELETE /v1/account

Auth: Bearer (first-party)

No params, no body.

Response: 204 No Content.

Errors

CodeHTTPWhen
USER_NOT_FOUND404No profile exists for the acting callsign. Callsign-keyed data is still deleted before this is returned.
INVALID_TOKEN401Bearer problems
FORBIDDEN403Third-party token

Notes

  • Deletes the callsign’s profile and everything keyed to it: challenge participation, progress, badges, friendships, friend requests, friend invites and activities. Any device tokens for the callsign stop working.
  • For account-backed callers this does not delete the account, its verified callsigns, its devices or its tokens, and the session remains valid.

Change callsign

PUT /v1/account/callsign

Auth: Bearer (first-party)

Request

{ "newCallsign": "W6JY" }

newCallsign is required; trimmed and upper-cased. No explicit length cap (unlike /register).

Response 200 OK

{ "data": { "callsign": "W6JY" } }

Errors

CodeHTTPWhen
VALIDATION_ERROR400Empty newCallsign; equal to the current callsign; malformed JSON
FORBIDDEN403The current callsign is owned by an account and the caller is not that account. This includes device-token callers whose callsign has since been claimed by an account. Also third-party tokens.
CALLSIGN_TAKEN409Another profile already has newCallsign, or another account has verified it. details.callsign.
USER_NOT_FOUND404The caller’s profile could not be found
INVALID_TOKEN401Bearer problems

Notes

  • Renames the callsign everywhere it appears (profile, challenges, progress, badges, activities, club membership, spots) and records the change in the callsign history.
  • For account callers who own the current callsign, the account’s verified callsign and primary callsign are rewritten and every existing access token is invalidated immediately, including the one used for this call. Call /auth/token/refresh to obtain a token whose cs reflects the new callsign. Refresh tokens are not revoked.

Claim a previous callsign’s data

POST /v1/account/claim-previous

Auth: Bearer (first-party), effectively account tokens only. A device token authenticates but is refused with 403 FORBIDDEN (not ACCOUNT_REQUIRED).

Request

{ "previousCallsign": "KN6ABC" }

previousCallsign is required; trimmed and upper-cased.

Response 200 OK

{ "data": { "merged": true, "previousCallsign": "KN6ABC" } }

Errors

CodeHTTPWhen
VALIDATION_ERROR400Empty; equal to the current callsign; malformed JSON
FORBIDDEN403Caller has no account (device token); the caller’s account has not verified previousCallsign; or third-party token
USER_NOT_FOUND404No profile exists for previousCallsign, or none for the current callsign
INVALID_TOKEN401Bearer problems

Notes

  • Prerequisite: verify the old callsign on the same account first via POST /auth/callsigns/verify/start, the emailed link, then /auth/token or /auth/verify/poll (204 attach).
  • Merges the old callsign’s friendships (both directions, skipping duplicates), friend requests and activities into the current callsign, and moves its challenge participation, progress, badges, club membership and spots. The old profile is then deleted, which invalidates any device tokens for the old callsign.
  • The old callsign stays attached to the account.

OAuth 2.0 / OpenID Connect provider

Carrier Wave is an OAuth 2.0 authorization server and an OpenID Connect provider. Your application can sign a Carrier Wave user in and, with the user’s consent, read and write their encrypted log through the /v1/sync/* endpoints. The user signs in on Carrier Wave’s own pages, so your app never handles their credentials. This is separate from the magic-link sign-in that Carrier Wave’s own apps use.

All endpoints below are served from https://activities.carrierwave.app. The /.well-known/* documents live at the domain root; every other path is under /v1. On a server where OAuth is not enabled, the discovery documents and the public /v1/oauth/* endpoints return 404.

Registering a client

Client registration is curated. There is no self-serve portal and no dynamic client registration (RFC 7591). Email hello@carrierwave.app to register a client and include:

  • The client id you would like (a short identifier such as my-logger) and the name users should see on the consent screen. A description and a homepage URL are optional; the homepage is shown on the consent screen as “Developer site”.
  • Your redirect URIs, if you use the authorization code flow. They are matched exactly: no wildcards, no prefixes, no normalization. Loopback URIs such as http://127.0.0.1:<port>/callback are fine for CLIs.
  • The scopes you need. This becomes your client’s ceiling; see Scopes and consent below.
  • The grant types you need. Authorization code and refresh token are the default. Ask for device_code if you need the device flow.
  • Whether the client is confidential (a server that can keep a secret) or public (native app, SPA, CLI).

A confidential client receives a secret of the form cws_ followed by 40 alphanumeric characters. It is shown once and cannot be retrieved again. A public client has no secret, must not send one, and relies on PKCE instead.

Carrier Wave staff can suspend a client. A suspended client behaves exactly like an unknown one (invalid_client), so the two are indistinguishable to callers. There is no update endpoint; contact Carrier Wave to change redirect URIs or scopes.

Grant types

Grantgrant_type valueNotes
Authorization codeauthorization_codePKCE S256 is mandatory for every client, confidential or public. plain is rejected. Codes live 60 seconds, are single use, and are bound to the client that requested them.
Refresh tokenrefresh_tokenRotating refresh tokens bound to the issuing client. A refresh never widens scope and honors revoked consent immediately. No id_token is returned on refresh.
Device authorization (RFC 8628)urn:ietf:params:oauth:grant-type:device_code or the short form device_codeDevice codes live 15 minutes. Poll every 5 seconds; slow_down is enforced.

Not supported: implicit (response_type=token), client credentials, dynamic registration (RFC 7591), token introspection (RFC 7662). response_types_supported is ["code"] only. Each client may use only the grant types it was registered with; any other grant returns unsupported_grant_type.

These are the only scopes that exist. Any other scope string is silently dropped from a request, so an unknown scope can never become an authorization. The consent screen shows exactly this text next to each scope:

ScopeConsent-screen description (verbatim)
openidConfirm your Carrier Wave account
profileSee your callsign and public profile
emailSee the email address on your account
sync:readDownload your encrypted log. The app still needs your sync passphrase to read it. This server cannot decrypt it and neither can anyone without that passphrase.
sync:writeAdd and update QSOs in your log, and upload encrypted log data

Scope rules:

  • If a request omits scope, the server uses openid.
  • Requested scopes are filtered to the known set, then clamped to your client’s registered ceiling, preserving request order and dropping duplicates. You can always ask for less, never more.
  • openid in the granted set turns the authorization into an OpenID Connect login and an id_token is minted. email additionally puts the verified email into the ID token and UserInfo.
  • profile currently has no effect beyond appearing on the consent screen and in the token’s scope claim. callsign and preferred_username are always returned by UserInfo.
  • Whenever any sync:* scope is requested, the consent screen shows an explicit warning about the sync passphrase.

What a third-party token may call

Access tokens issued to your client carry a cid claim (your client id). Every request made with such a token is checked against the table below. The route must be listed and the token must carry the exact scope shown (no prefix or wildcard matching; the HTTP method is part of the check). Anything else returns 403 FORBIDDEN. Everything not listed, including clubs, friends, spots, events, contests, account management, the first-party /v1/auth/* routes and the OAuth flow routes themselves, is reserved for Carrier Wave’s own apps.

MethodRoute (under /v1)Required scopeWhat it moves
GET/sync/bootstrapsync:readLive snapshot pointer plus post-snapshot journal entries (ciphertext and presigned download URLs)
GET/sync/changes?since=&limit=sync:readIncremental journal entries with seq > since
GET/sync/keysync:readThe passphrase-wrapped key envelope (useless without the user’s passphrase)
POST/sync/pushsync:writeReserve sequence numbers and get a presigned PUT URL per ciphertext entry
POST/sync/snapshotsync:writeBegin a snapshot (phase 1)
POST/sync/snapshot/commitsync:writeConfirm a snapshot (phase 2)
PUT/sync/keysync:writeUpload a new passphrase-wrapped key envelope
GET/oauth/userinfoopenidOpenID Connect UserInfo (documented below)

The /v1/sync/* endpoints are documented separately. A refresh token issued by the OAuth provider cannot be used at the first-party POST /v1/auth/token/refresh; that returns 403 FORBIDDEN without rotating the token. Use POST /v1/oauth/token with grant_type=refresh_token.

Reading and writing the encrypted log

The log is an append-only journal of encrypted blobs. A token authorizes access to ciphertext, never decryption. The data key is derived on the user’s device from their sync passphrase with Argon2id and never reaches the server.

  1. GET /v1/sync/key (sync:read) returns the passphrase-wrapped data-key envelope. 404 means the user has not set up sync.
  2. Ask the user for their sync passphrase in your own UI. Derive a key-encryption key with Argon2id and use it to unwrap the 32-byte data key.
  3. GET /v1/sync/bootstrap for a first sync, or GET /v1/sync/changes?since=<n> for an incremental one. Each entry has a monotonic seq and a presigned download URL.
  4. For each entry: download, decrypt with ChaCha20-Poly1305 using the data key, gunzip, and parse the journal payload JSON.
  5. Fold payloads in seq order. Payloads are upserts and delete tombstones; the last write by seq wins. Persist the highest seq you have seen.
  6. To write: build the QSO record or tombstone, wrap it in a journal payload, gzip it, seal it with the same key, call POST /v1/sync/push (sync:write) to get a presigned upload URL per entry, then PUT the ciphertext there. Push is idempotent on client_entry_id.

Wire details to watch: frequency is in MHz; field names are camelCase, with rawADIF and isActivityLogQSO as irregular spellings; entries are never modified in place.

Error bodies

Two shapes appear in this family:

  • RFC 6749 section 5.2 bodies on the OAuth protocol endpoints and the decision endpoints: {"error": "<code>", "error_description": "<text>"}, sent with Cache-Control: no-store and Pragma: no-cache. Status by code: invalid_request 400, invalid_client 401, invalid_grant 400, unsupported_grant_type 400, invalid_scope 400, access_denied 403, authorization_pending 400, slow_down 400, expired_token 400. The 401 invalid_client response does not set a WWW-Authenticate header.
  • The server envelope on UserInfo, connected-app routes, and for any bearer rejection: {"error": {"code": "<CODE>", "message": "<text>"}}, with an optional details field. Codes you may see: INVALID_TOKEN 401, ACCOUNT_REQUIRED 403, FORBIDDEN 403, VALIDATION_ERROR 400, RATE_LIMITED 429, INTERNAL_ERROR 500.

A request with the wrong content type, an unparseable body, or a missing required field such as grant_type is rejected before the OAuth handler runs and returns a plain-text error rather than an RFC 6749 body.

Every route is rate limited at roughly 10 requests per second with a burst of 20; beyond that you receive 429. Request bodies are capped at 1 MiB.

Walkthrough: authorization code flow (apps with a redirect)

  1. Generate a random code_verifier (43 to 128 characters) and a random state. Compute code_challenge = base64url_nopad(sha256(code_verifier)). Optionally generate a nonce.

  2. Send the user’s browser to the consent page:

    GET /v1/oauth/authorize?client_id=my-logger
      &redirect_uri=https%3A%2F%2Fmylogger.example%2Foauth%2Fcallback
      &response_type=code
      &scope=openid%20sync%3Aread%20sync%3Awrite
      &state=5f1c…
      &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
      &code_challenge_method=S256
      &nonce=n-0S6_WzA2Mj
    

    The page is self-contained HTML. The user signs in on that page with a Carrier Wave magic link, so your app never sees their credentials, then clicks Allow or Deny. The page records the decision and sends the browser to your redirect URI.

  3. The browser lands on your redirect_uri:

    • success: ?code=<48-character code>&state=5f1c…
    • refusal: ?error=access_denied&state=5f1c…
    • malformed request: ?error=unsupported_response_type|invalid_request|invalid_scope&state=…

    Verify that state matches what you sent. An unknown client_id or an unregistered redirect_uri never redirects; the user sees a 400 HTML error page instead.

  4. Within 60 seconds, exchange the code (form-encoded):

    POST /v1/oauth/token
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=authorization_code&client_id=my-logger&code=<code>
    &redirect_uri=https%3A%2F%2Fmylogger.example%2Foauth%2Fcallback
    &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
    

    A confidential client adds Authorization: Basic base64(client_id:client_secret) (or client_secret= in the form). A public client must not send a secret.

  5. Receive access_token, refresh_token, scope, expires_in: 900 and, with openid, id_token. Check that id_token.nonce equals the nonce you sent.

  6. Call /v1/sync/* and /v1/oauth/userinfo with Authorization: Bearer <access_token>.

  7. Before expires_in elapses, POST /v1/oauth/token with grant_type=refresh_token&client_id=…&refresh_token=…. Store the new refresh_token from every response.

Walkthrough: device flow (CLIs and headless apps)

  1. Start the authorization (form-encoded; client authentication as above):

    POST /v1/oauth/device/code
    Content-Type: application/x-www-form-urlencoded
    
    client_id=my-logger&scope=openid%20sync%3Aread
    

    The response carries device_code, user_code (for example KRWG-ZPTL), verification_uri (https://activities.carrierwave.app/v1/oauth/device), verification_uri_complete (…/device?user_code=KRWG-ZPTL), expires_in: 900 and interval: 5.

  2. Show the user user_code and verification_uri, or render verification_uri_complete as a QR code. On that page the user types the code, signs in with a magic link, and clicks Allow or Deny.

  3. Poll no faster than every interval seconds:

    POST /v1/oauth/token
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code
    &client_id=my-logger&device_code=<device_code>
    
    • 400 authorization_pending: keep waiting.
    • 400 slow_down: you polled too fast. The poll window resets even on a rejected poll, so back off by at least interval.
    • 403 access_denied: the user declined. Stop.
    • 400 expired_token: the device code is unknown, expired, or already consumed. Start over.
    • 200 with tokens: approved. The device code is consumed atomically.
  4. Use and refresh tokens exactly as in the authorization code flow. The device flow never carries a nonce, so the id_token (with openid) has none.

Discovery

Generic OpenID Connect and OAuth client libraries can configure themselves from the discovery document. A library that honors it will use PKCE S256, authenticate a confidential client with HTTP Basic (preferred) or form fields, and decline to attempt the implicit grant or plain PKCE.

Server metadata

GET /.well-known/openid-configuration
GET /.well-known/oauth-authorization-server

Auth: Public

Served at the domain root, not under /v1. Both paths (the OpenID Connect location and the RFC 8414 alias) return the same document.

No parameters.

Request

GET /.well-known/openid-configuration

Response 200 OK, application/json.

{
  "issuer": "https://activities.carrierwave.app",
  "authorization_endpoint": "https://activities.carrierwave.app/v1/oauth/authorize",
  "token_endpoint": "https://activities.carrierwave.app/v1/oauth/token",
  "userinfo_endpoint": "https://activities.carrierwave.app/v1/oauth/userinfo",
  "revocation_endpoint": "https://activities.carrierwave.app/v1/oauth/revoke",
  "device_authorization_endpoint": "https://activities.carrierwave.app/v1/oauth/device/code",
  "jwks_uri": "https://activities.carrierwave.app/.well-known/jwks.json",
  "scopes_supported": ["openid", "profile", "email", "sync:read", "sync:write"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["EdDSA"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
  "code_challenge_methods_supported": ["S256"],
  "claims_supported": ["iss", "sub", "aud", "exp", "iat", "auth_time", "nonce", "at_hash",
                       "callsign", "preferred_username", "email", "email_verified"]
}

Errors

CodeHTTPWhen
none404OAuth is not enabled on this server (the body is not JSON)

Notes

  • No cache headers are set on this document. The JWKS document is cacheable for an hour.
  • claims_supported describes the ID token and UserInfo. Access tokens carry a different claim set (scope, cs, jti, cid); see Tokens.

Signing keys

GET /.well-known/jwks.json

Auth: Public

Served at the domain root with Cache-Control: public, max-age=3600. Use it to verify access tokens and ID tokens.

No parameters.

Response 200 OK, application/json.

{"keys":[{"kty":"OKP","crv":"Ed25519","use":"sig","alg":"EdDSA","kid":"v1","x":"…"}]}

Authorization code flow

GET /v1/oauth/authorize

Auth: Public (browser page; the user signs in on the page)

Renders the HTML consent page. Every query parameter is optional at the HTTP level so that a malformed link renders a readable error page rather than a bare 400; the page then enforces the required set itself.

ParamTypeRequiredDescription
client_idquery stringyesRegistered client id.
redirect_uriquery stringyesMust exactly equal one of the client’s registered redirect URIs.
response_typequery stringno (defaults to code)Only code is accepted; anything else is reported via redirect at decision time.
scopequery stringno (defaults to openid)Space-delimited. Unknown scopes are dropped; the rest are clamped to the client’s ceiling.
statequery stringno (strongly recommended)Opaque CSRF binding; echoed verbatim on the redirect.
code_challengequery stringyesbase64url_nopad(sha256(code_verifier)).
code_challenge_methodquery stringno (defaults to S256)Must be S256; plain is rejected at decision time.
noncequery stringnoEchoed into the id_token.

Request

GET /v1/oauth/authorize?client_id=my-logger&redirect_uri=https%3A%2F%2Fmylogger.example%2Foauth%2Fcallback&response_type=code&scope=openid%20sync%3Aread&state=abc123&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256

Response 200 OK, text/html; charset=utf-8, Cache-Control: no-store, with <meta name="robots" content="noindex, nofollow">. The page shows the client name, the clamped scope list with the consent copy above, the client’s homepage URL if any (“Developer site: …”), a passphrase warning whenever any sync:* scope is present, a callsign sign-in box, then Allow and Deny buttons. After the user signs in and decides, the page posts the decision to POST /v1/oauth/authorize/decision and sends the browser to the returned redirectUrl.

Errors All are 400 Bad Request HTML pages, never a redirect:

Page headingHTTPWhen
Invalid authorization request400client_id, redirect_uri or code_challenge missing
Unrecognized application400Unknown or suspended client, or redirect_uri not registered (RFC 6749 section 4.1.2.1: the server must not redirect)
Nothing to authorize400No scopes remain after filtering and clamping
none404OAuth is not enabled on this server

Notes

  • The page does not reject a bad response_type or code_challenge_method. Those are caught by the decision endpoint and reported to your app as ?error=… on the redirect.
  • Sign-in on the page waits for the magic link for up to 15 minutes.
POST /v1/oauth/authorize/decision

Auth: Bearer (first-party)

Called by the consent page’s own JavaScript with the session the user just created on that page. Your application does not call this endpoint. It is documented so that the redirect you receive is fully explained. Every parameter is re-validated here; nothing from the rendered page is trusted.

Request Content-Type: application/json. The OAuth fields keep their snake_case wire names; only approve is camelCase.

ParamTypeRequiredDescription
client_idstringyesAs on the authorize request.
redirect_uristringyesMust be registered for the client.
response_typestringyesMust be code.
scopestringnoSpace-delimited; filtered and clamped again. Omitted means openid.
statestringnoEchoed on the redirect.
code_challengestringyesNon-empty.
code_challenge_methodstringeffectively yesMust be exactly S256; missing or any other value produces an invalid_request redirect.
noncestringnoStored with the code and echoed into the id_token.
approvebooleanyestrue grants, false declines.
{
  "client_id": "my-logger",
  "redirect_uri": "https://mylogger.example/oauth/callback",
  "response_type": "code",
  "scope": "openid sync:read",
  "state": "abc123",
  "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  "code_challenge_method": "S256",
  "nonce": null,
  "approve": true
}

Response 200 OK, application/json. The URL is always a registered redirect URI. Query values are percent-encoded with an unreserved-only allowlist; if the redirect URI already has a query string the parameters are appended with &.

Approved:

{ "redirectUrl": "https://mylogger.example/oauth/callback?code=Qm3k…48chars…&state=abc123" }

Declined (approve: false):

{ "redirectUrl": "https://mylogger.example/oauth/callback?error=access_denied&state=abc123" }

Malformed but redirectable request (still 200 here; the error goes to your app):

{ "redirectUrl": "https://mylogger.example/oauth/callback?error=invalid_request&state=abc123" }

Possible redirect error values: unsupported_response_type (response_type is not code), invalid_request (empty code_challenge, or code_challenge_method not S256), invalid_scope (nothing left after clamping), access_denied (the user declined).

On approval the server records the user’s consent for your client with the clamped scopes (re-approving revives a previously disconnected app) and issues a single-use authorization code that lives 60 seconds and is bound to the client, the account, the redirect_uri, the scopes, the code_challenge and the nonce. Nothing is persisted on decline.

Errors RFC 6749 body, plus the server envelope for bearer rejections:

CodeHTTPWhen
INVALID_TOKEN (envelope)401Missing or invalid bearer
FORBIDDEN (envelope)403Bearer is a third-party token
access_denied403The session is not backed by an account (“an account-backed session is required”)
invalid_client401Unknown or suspended client
invalid_request400redirect_uri not registered; “client lookup failed”; “could not record consent”; “could not issue authorization code”

Notes

  • The scopes placed in the grant and the code are the server’s clamped set, not the raw request.

Device flow

Device verification page

GET /v1/oauth/device

Auth: Public (browser page; the user signs in on the page)

This is the verification_uri returned by POST /v1/oauth/device/code.

ParamTypeRequiredDescription
user_codequery stringnoPre-fills the code box. Normalized (uppercased, dash inserted, non-alphanumerics stripped) before rendering.

Request

GET /v1/oauth/device?user_code=KRWG-ZPTL

Response 200 OK, text/html; charset=utf-8, Cache-Control: no-store, noindex. Shows a code input (placeholder XXXX-XXXX), the callsign sign-in box, Allow and Deny buttons, and the notice “Only approve a code you started yourself, on a device you are holding.” On decision the page posts to POST /v1/oauth/device/decision and then shows “ is now connected” or “Request denied. Nothing was shared.”

Errors

CodeHTTPWhen
none404OAuth is not enabled on this server

Notes

  • The page always renders 200, whether or not user_code is valid. Validity is checked only at decision time.

Start a device authorization

POST /v1/oauth/device/code

Auth: Client credentials (same rules as the token endpoint: a confidential client must present its secret, a public client must not)

Request Content-Type: application/x-www-form-urlencoded

ParamTypeRequiredDescription
client_idstringyes unless sent via HTTP BasicClient id.
client_secretstringconfidential clients onlySecret, if not sent via HTTP Basic.
scopestringno (defaults to openid)Space-delimited; filtered and clamped to the client’s ceiling.
POST /v1/oauth/device/code
Content-Type: application/x-www-form-urlencoded

client_id=my-cli&scope=openid%20sync%3Aread

Response 200 OK, application/json, Cache-Control: no-store.

{
  "device_code": "Xy7…48 alphanumerics…",
  "user_code": "KRWG-ZPTL",
  "verification_uri": "https://activities.carrierwave.app/v1/oauth/device",
  "verification_uri_complete": "https://activities.carrierwave.app/v1/oauth/device?user_code=KRWG-ZPTL",
  "expires_in": 900,
  "interval": 5
}

Errors RFC 6749 body:

CodeHTTPWhen
invalid_client401Missing, unknown or suspended client, or wrong secret handling (as on the token endpoint)
unsupported_grant_type400The client is not registered for the device flow
invalid_scope400No valid scopes after filtering and clamping (“no valid scopes requested”)
invalid_request400The server could not start the authorization (“could not start device authorization”)

Notes

  • user_code is XXXX-XXXX drawn from the 25-character ambiguity-free alphabet ACDEFGHJKMNPQRTUVWXY34679. Typed input is normalized (case, dash, spaces), so the user can enter krwgzptl.
  • interval is enforced. A poll inside the window returns slow_down and resets the window.
  • Scopes are clamped again at decision time, so a ceiling tightened between issue and approval is honored.

Device decision

POST /v1/oauth/device/decision

Auth: Bearer (first-party)

Called by the device verification page with the session the user created there. Your application does not call this endpoint.

Request Content-Type: application/json, camelCase.

ParamTypeRequiredDescription
userCodestringyesThe code the user typed; normalized server-side (krwgzptl becomes KRWG-ZPTL).
approvebooleanyestrue to approve, false to deny.
{ "userCode": "KRWG-ZPTL", "approve": true }

Response 200 OK, application/json.

{ "approved": true, "clientName": "My CLI" }

On approval the server records the user’s consent for the client with the re-clamped scopes and marks the device code approved; the polling client’s next token request succeeds. A denial is terminal: the polling client receives access_denied and the code cannot be re-approved.

Errors RFC 6749 body, plus the server envelope for bearer rejections:

CodeHTTPWhen
INVALID_TOKEN (envelope)401Missing or invalid bearer
FORBIDDEN (envelope)403Bearer is a third-party token
access_denied403The session is not backed by an account
invalid_grant400Unknown or expired user code, or the code was already approved or denied (“this code was already used”)
invalid_client401The client behind the code is now unknown or suspended
invalid_scope400approve: true but no grantable scopes remain after re-clamping
invalid_request400The server could not record the decision (“could not record consent”)

Tokens

ArtifactLifetimeFormat
Authorization code60 seconds, single use48-character alphanumeric opaque string
Device code15 minutes (900 seconds)48-character opaque string
User codesame 15 minutesXXXX-XXXX from the alphabet ACDEFGHJKMNPQRTUVWXY34679
Device poll interval5 secondsPolling faster returns slow_down
Access token15 minutes (expires_in: 900)EdDSA (Ed25519) JWT, aud: "cw-social"
ID token60 minutesEdDSA JWT, aud: <client_id>
Refresh token60-day sliding window, 180-day absolute maximum per familycwr_ followed by 64 hex characters. Rotates on every use. Re-presenting the just-rotated token within 60 seconds returns its successor; reuse after that window revokes the whole family.
Client secretuntil the client is suspendedcws_ followed by 40 alphanumeric characters, shown once

Access-token claims: iss (https://activities.carrierwave.app), sub (the account UUID), aud (cw-social), scope (space-delimited), cs (primary callsign, informational only), iat, exp, jti, cid (your client id). The JWT header carries alg: EdDSA and kid.

ID-token claims: iss, sub, aud (your client_id), iat, exp, auth_time, nonce (only when the authorization request carried one; the device flow never sets it), at_hash (base64url of the left 16 bytes of SHA-256 of the access token), callsign, preferred_username (mirrors callsign), and email with email_verified: true (only with the email scope and a verified address on file). auth_time is the moment of consent.

Verify both token types against GET /.well-known/jwks.json.

Revocation: disconnecting an app (DELETE /v1/oauth/grants/:client_id), client-side revocation (POST /v1/oauth/revoke) and client suspension all stop new tokens immediately, but access tokens already issued stay valid for up to 15 minutes because verification is stateless. Plan for that window.

Token endpoint

POST /v1/oauth/token

Auth: Client credentials. A confidential client must present its secret, either as Authorization: Basic base64(client_id:client_secret) or as client_secret in the form; HTTP Basic wins if both are present. A public client must not send a secret.

Request Content-Type: application/x-www-form-urlencoded

ParamTypeRequiredDescription
grant_typestringyesauthorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code, or device_code.
client_idstringyes unless sent via HTTP BasicClient id.
client_secretstringconfidential clients onlySecret, if not sent via HTTP Basic.
codestringauthorization_codeThe code from the redirect.
redirect_uristringauthorization_codeMust equal the redirect_uri the code was issued for.
code_verifierstringauthorization_code43 to 128 characters; base64url_nopad(sha256(code_verifier)) must equal the stored challenge.
refresh_tokenstringrefresh_tokenA cwr_… token issued to this client.
device_codestringdevice grantFrom POST /v1/oauth/device/code.

Authorization-code example:

POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&client_id=my-logger&code=Qm3k…&redirect_uri=https%3A%2F%2Fmylogger.example%2Foauth%2Fcallback&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Refresh example (confidential client, HTTP Basic):

POST /v1/oauth/token
Authorization: Basic bXktc2VydmVyOmN3c19…
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=cwr_9f3a…64hex…

Device poll example:

POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&client_id=my-cli&device_code=Xy7…48chars…

Response 200 OK, application/json, Cache-Control: no-store, Pragma: no-cache.

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6InYxIn0…",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "cwr_9f3a…",
  "scope": "openid sync:read",
  "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6InYxIn0…"
}

refresh_token is always present. id_token is omitted (not null) when openid is not in the granted scopes, and is always omitted on refresh_token grants.

Per-grant behavior:

  • authorization_code: consumes the code atomically (unknown, expired and already-used codes are indistinguishable), checks that the code was issued to this client, checks redirect_uri equality, verifies PKCE, then issues tokens.
  • refresh_token: checks that the presented token belongs to this client before rotating it, rotates it (reuse outside the 60-second grace window revokes the family), requires the user’s consent to still exist, and issues tokens whose scopes are the intersection of the token’s scopes, the current consent and the client ceiling.
  • device grant: returns the RFC 8628 section 3.5 errors until the user decides; on approval the device code is consumed and tokens are issued with the scopes recorded at decision time.

Errors RFC 6749 body, with Cache-Control: no-store:

CodeHTTPWhen
invalid_client401No client_id anywhere; unknown or suspended client; a public client sent a secret; a confidential client omitted its secret or it does not match
unsupported_grant_type400Unknown grant_type, or the client is not registered for that grant
invalid_request400Missing code, code_verifier, refresh_token or device_code for the chosen grant
invalid_grant400Code invalid, expired or used; code issued to another client; redirect_uri mismatch; PKCE failed; refresh token not issued to this client; refresh reuse detected, revoked or unknown; consent revoked (“this authorization has been revoked”); account no longer exists
invalid_scope400On refresh, no granted scopes remain for this client
authorization_pending400Device grant: the user has not decided yet
slow_down400Device grant: polled faster than interval
access_denied403Device grant: the user declined
expired_token400Device grant: unknown, expired, or already-consumed device code
RATE_LIMITED (envelope)429Per-IP rate limit

Notes

  • invalid_grant is one code, but error_description differs per cause (listed above), so you can tell a PKCE failure from a redirect mismatch by its text.
  • Refresh tokens from Carrier Wave’s own apps cannot be used here (invalid_grant), and refresh tokens issued here cannot be used at /v1/auth/token/refresh (403 FORBIDDEN).
  • The user’s consent is re-checked on every refresh, so a disconnect takes effect at the next refresh, not at the 60-day expiry.

Revoke a refresh token

POST /v1/oauth/revoke

Auth: Client credentials (same rules as the token endpoint)

RFC 7009 revocation for refresh tokens.

Request Content-Type: application/x-www-form-urlencoded

ParamTypeRequiredDescription
tokenstringyesThe refresh token (cwr_…) to revoke. Access tokens cannot be revoked here.
client_idstringyes unless sent via HTTP BasicClient id.
client_secretstringconfidential clients onlySecret, if not sent via HTTP Basic.

token_type_hint is not read.

POST /v1/oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=cwr_9f3a…&client_id=my-logger

Response 200 OK with an empty body, whether or not the token existed or belonged to the caller (RFC 7009 section 2.2).

Errors RFC 6749 body:

CodeHTTPWhen
invalid_client401Client authentication failed (same cases as the token endpoint)

Notes

  • Revocation only happens if the token was issued to the calling client; otherwise the request is silently ignored.
  • Revokes the whole rotation family, not just the presented token.
  • Does not revoke the user’s consent. The user stays connected to your app; you simply have no live refresh token until the user authorizes again. Access tokens already issued remain valid for up to 15 minutes.

Identity and connected apps

UserInfo

GET /v1/oauth/userinfo

Auth: Bearer (third-party, scope openid). Carrier Wave’s own apps may also call it. This is the only OAuth route reachable with a third-party token.

No parameters.

Request

GET /v1/oauth/userinfo
Authorization: Bearer eyJ0eXAiOiJKV1Qi…

Response 200 OK, application/json. email and email_verified appear only when the token carries the email scope and a verified address exists; otherwise both keys are omitted.

{
  "sub": "4c7a4c0e-7e0b-4f3a-9d8f-2a6e1b7a9c21",
  "callsign": "W6JY",
  "preferred_username": "W6JY",
  "email": "w6jy@example.com",
  "email_verified": true
}

Errors server envelope {"error":{"code","message"}}:

CodeHTTPWhen
INVALID_TOKEN401Missing, malformed, expired or revoked bearer, or the account behind the token no longer exists
FORBIDDEN403Third-party token without the openid scope
ACCOUNT_REQUIRED403The session is not backed by an account

Notes

  • sub is the account UUID and matches sub in the access token and the ID token.
  • callsign is resolved from the account at request time, not from the token’s cs claim.
  • email prefers the address the primary callsign was verified under.
  • profile is not checked; callsign and preferred_username are always returned.

List connected applications

GET /v1/oauth/grants

Auth: Bearer (first-party)

Lists the apps the signed-in user has connected. Used by Carrier Wave’s own apps; third-party tokens are denied.

No parameters.

Request

GET /v1/oauth/grants
Authorization: Bearer <first-party access token>

Response 200 OK, { "data": [...] }, newest first, disconnected apps excluded.

{
  "data": [
    {
      "clientId": "my-logger",
      "clientName": "My Logger",
      "scopes": "openid sync:read sync:write",
      "grantedAt": "2026-10-09T18:22:41.120394Z"
    }
  ]
}

Errors server envelope:

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer
FORBIDDEN403Third-party bearer
ACCOUNT_REQUIRED403The session is not backed by an account
INTERNAL_ERROR500Server failure

Notes

  • scopes is the space-delimited string as last consented; re-consent overwrites it.
  • grantedAt is RFC 3339 in UTC.

Disconnect an application

DELETE /v1/oauth/grants/:client_id

Auth: Bearer (first-party)

ParamTypeRequiredDescription
client_idpath stringyesThe client to disconnect.

Request

DELETE /v1/oauth/grants/my-logger
Authorization: Bearer <first-party access token>

Response 204 No Content, empty body.

The user’s consent is revoked and the client’s refresh tokens for this account are removed. The client’s next refresh fails with invalid_grant; access tokens it already holds remain valid for up to 15 minutes.

Errors server envelope:

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer
FORBIDDEN403Third-party bearer, or no active connection exists for this account and client (unknown client ids and already-disconnected apps both return 403, not 404)
ACCOUNT_REQUIRED403The session is not backed by an account
INTERNAL_ERROR500Server failure

Notes

  • A second DELETE for the same client returns 403.
  • Authorizing the app again later restores the connection.

Operator endpoints

Clients are registered, listed and suspended by Carrier Wave staff; these endpoints are not available to third-party developers.

MethodPathPurpose
POST/v1/admin/oauth/clientsRegister a client
GET/v1/admin/oauth/clientsList registered clients
DELETE/v1/admin/oauth/clients/:client_idSuspend a client

Challenges, progress and leaderboards

A challenge is an operator-authored goal that participants join and report progress against: work all 50 US states, reach 1000 park contacts, take part in a time-boxed event. The server stores each challenge with a free-form configuration object. Your client evaluates QSOs locally against that configuration and sends the server a summary. The server turns the summary into a score, a tier, a percentage and a leaderboard rank.

These endpoints are first-party only. Third-party OAuth tokens receive 403 FORBIDDEN on every route in this section. The public reads (list and get challenge, leaderboard, badge image) need no token at all.

Conventions

  • All paths are under /v1. Responses are JSON unless noted. Success bodies are wrapped as {"data": ...}; errors as {"error": {"code", "message", "details"?}}.
  • An invalid UUID in a path answers 400 VALIDATION_ERROR. A missing or malformed JSON body (including a missing Content-Type: application/json) answers 400 VALIDATION_ERROR. A query string that fails to parse (for example active=maybe or limit=abc) answers a plain-text 400 without the JSON envelope. An unknown /v1/... path answers 404 NOT_FOUND.
  • Request bodies are capped at 1 MiB on every route.
  • Requests are rate limited per IP at 10 requests per second with a burst of 20. Excess requests receive 429. No rate-limit headers are returned.
  • Cross-origin browser requests are accepted from https://carrierwave.app and https://activities.carrierwave.app.
  • Callsigns are stored and compared upper-case.

Auth levels used below

  • Public (optional bearer). A valid first-party bearer, if present, identifies the caller. A missing, invalid, expired or third-party token downgrades the request to anonymous. These routes never answer 401 or 403 for a bad optional token.
  • Bearer (first-party). A device token (fd_..., issued by the join endpoint) or a first-party OAuth access token. Missing or invalid: 401 INVALID_TOKEN. Third-party OAuth token: 403 FORBIDDEN.
  • Operator. An operator token. Any failure answers 401 INVALID_TOKEN.

Types and categories

type and category are labels used for filtering and display.

typeMeaning
collectionWork every item in a defined set (WAS, DXCC)
cumulativeReach a numeric target (for example 1000 park contacts)
timeBoundedFixed start and end window (13 Colonies)
categoryMeaning
awardOfficial awards (DXCC, WAS, WAC)
eventSpecial events (13 Colonies, Field Day)
clubClub-specific challenges
personalIndividual goals
otherEverything else

Progress maths use configuration.goals.type and configuration.scoring.method, not the type label. A challenge has ended when isActive is false. The server does not evaluate timeConstraints dates.

How progress works

  1. The client evaluates QSOs locally against configuration.qualificationCriteria, timeConstraints and historicalQsosAllowed, then sends a summary (completedGoals, currentValue, qualifyingQsoCount, lastQsoDate) to POST /v1/challenges/:id/progress. The server stores the summary as sent. It does not see QSOs and does not check completedGoals against goals.items.
  2. The server derives three values from the summary and the configuration:
    • score, from scoring.method. count (the default) is the number of completedGoals. percentage is floor(completed / number of goals.items * 100), or 0 when there are no items. points is currentValue. Any other value, including weighted, behaves as count.
    • currentTier. The server walks configuration.tiers in array order and keeps the last tier whose integer threshold is less than or equal to the score. tiers[].order is not used, so list tiers in ascending threshold order. If tiers is absent, or any tier lacks a numeric threshold, currentTier is null.
    • percentage, from goals.type. collection: completed / number of goals.items * 100. cumulative: currentValue / goals.targetValue * 100 (targetValue defaults to 100). Anything else: 0.0. The value is not clamped to 100.
  3. Each report replaces the previous one for that callsign and challenge and stamps the update time. The update time is the leaderboard tiebreaker: the earlier update wins.
  4. rank orders all reported progress for the challenge by score descending, then earliest update. Ties share a rank and the following rank is skipped. scoring.tiebreaker is stored but not applied.
  5. newBadges in the progress response is currently always empty. Badges are not awarded by the server.

Tiers and badges

Tiers are milestone thresholds inside configuration.tiers. Badges are images uploaded by an operator and optionally tagged with a tierId; the tierId is not validated against the configuration. Badge images are served publicly by id (GET /v1/badges/:id/image). There is no public badge listing, and the challenge object does not include a badges array; tiers in the configuration may carry a badgeId.

Invite-only challenges

A challenge requires an invite when inviteConfig.requiresToken is true. Nothing else in inviteConfig is read: maxParticipants and expiresAt are passed through and not enforced. Invite tokens have the form inv_ followed by 24 characters from [0-9a-z]. Operators mint them with optional maxUses and expiresAt. A join validates the token first for a precise error, then redeems it atomically, so concurrent joins cannot exceed maxUses. A join that fails after redemption releases the use.

Device token on join

POST /v1/challenges/:id/join is how a device without an account obtains its fd_ device token.

  • If the callsign is new to the server, a participant record is created with a fresh token and the token is returned once in deviceToken.
  • If the callsign already exists, the caller must already be authenticated as that exact callsign, otherwise the join answers 403 FORBIDDEN. The token is never rotated or re-disclosed; deviceToken is omitted from the response. Recovering a lost token is handled by the magic-link sign-in flow, not this endpoint.
  • A caller using a first-party OAuth token always takes the second branch: the participant record already exists for an OAuth caller, so an OAuth caller never receives a deviceToken.

Challenge configuration schema

Wire shape of configuration (camelCase). “Server reads” marks the keys the server inspects; everything else is stored and echoed verbatim for the client to interpret. “Required” marks keys the official clients expect to be present; the server does not validate the configuration.

FieldTypeRequiredServer readsDescription
goalsobjectyesyesGoal definition
goals.type"collection" | "cumulative"yesyes (default collection)Drives percentage
goals.itemsGoal[]collectionyes, length onlyItems to collect. Goal = {id: string, name: string, category?: string, metadata?: {[k]: string}}
goals.targetValueintegercumulativeyes (default 100)Target for cumulative goals
goals.unitstringcumulativenoDisplay unit, for example "contacts"
goals.calculationRulestringnonoClient-side aggregation rule, for example "count"
tiersTier[]noyesMilestones. Tier = {id: string, name: string, threshold: integer, order: integer, badgeId?: string}. Only id and threshold are read; list tiers in ascending threshold order
qualificationCriteriaobjectyesnoClient-side QSO filter
qualificationCriteria.bandsstring[] | nullnonoFor example ["40m","20m"]; null means any
qualificationCriteria.modesstring[] | nullnonoFor example ["CW"]; null means any
qualificationCriteria.requiredFieldsarraynonoFields a QSO must carry; passed through to the client
qualificationCriteria.dateRange{start?: ISO8601, end?: ISO8601} | nullnonoQSO date window
qualificationCriteria.matchRulesMatchRule[]yesno{qsoField: string, goalField: string, transformation?: "none"|"uppercase"|"lowercase", validationRegex?: string}
scoringobjectyesyesScoring config
scoring.method"count" | "percentage" | "points" | "weighted"yesyes (default count; weighted behaves as count)See “How progress works”
scoring.weights{rule: string, weight: number}[]nonoClient-side; not applied by the server
scoring.tiebreaker"earliestCompletion" | "mostRecent" | "alphabetical"nonoNot applied; ranking always uses score descending, then earliest update
scoring.displayFormatstringyesnoFor example "{value}/50 states"
timeConstraintsobjectnono{type: "calendar"|"relative", startDate?: ISO8601, endDate?: ISO8601, duration?: string, timezone: string}; client-side only
historicalQsosAllowedbooleanyesyes (default true)Echoed as historicalAllowed in the join response

inviteConfig (separate top-level field, nullable):

FieldTypeServer readsDescription
requiresTokenbooleanyesWhen true, joining requires a valid inviteToken
maxParticipants, expiresAtinteger, ISO8601noPassed through; not enforced

hamalertConfig (separate top-level field, nullable): {enabled: boolean, alertType: string, spotSources: string[], autoManage: boolean}. Passed through; the server does not read it.

Browsing challenges

List challenges

GET /v1/challenges

Auth: Public (optional bearer; a bearer changes nothing in the response)

ParamTypeRequiredDescription
categorystring (query)noExact match on category (award, event, club, personal, other). Not validated; an unknown value returns an empty list
typestring (query)noExact match on type (collection, cumulative, timeBounded)
activebool (query)noFilter on isActive. Omit for both
limitint (query)noDefault 50, maximum 100 (larger values are reduced to 100)
offsetint (query)noDefault 0

Request: no body.

Response: 200 OK

{
  "data": {
    "challenges": [
      {
        "id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
        "name": "ARRL WAS 250",
        "description": "Work all 50 US states during 2026 ...",
        "category": "award",
        "type": "collection",
        "participantCount": 1234,
        "isActive": true
      }
    ],
    "total": 45,
    "limit": 50,
    "offset": 0
  }
}

participantCount counts participants with status active. Results are ordered newest first. total is the number of challenges matching the filters, ignoring paging. limit echoes the applied value.

Errors

CodeHTTPWhen
(plain text)400Query string fails to parse (no JSON envelope)
INTERNAL_ERROR500Server failure

Notes: List items do not include configuration, version, author or timestamps. Fetch the challenge by id for those.

Get challenge

GET /v1/challenges/:id

Auth: Public (optional bearer; no effect on the response)

ParamTypeRequiredDescription
idUUID (path)yesChallenge id

Response: 200 OK

Response headers:

HeaderValue
X-Challenge-VersionInteger version
ETag"<version>:<updatedAt unix seconds>", quotes included. If-None-Match is not evaluated, so conditional requests always answer 200
{
  "data": {
    "id": "b2c3d4e5-f6a7-5b6c-9d0e-1f2a3b4c5d6e",
    "version": 1,
    "name": "DXCC",
    "description": "Work 100 or more DXCC entities ...",
    "author": "ARRL",
    "category": "award",
    "type": "collection",
    "configuration": {
      "goals": {
        "type": "collection",
        "items": [
          { "id": "1", "name": "Canada", "category": "entity" }
        ]
      },
      "tiers": [
        { "id": "tier-50",  "name": "50 Entities", "threshold": 50,  "order": 1 },
        { "id": "tier-100", "name": "DXCC",        "threshold": 100, "order": 2 }
      ],
      "qualificationCriteria": {
        "bands": null,
        "modes": null,
        "requiredFields": [],
        "dateRange": null,
        "matchRules": [
          { "qsoField": "dxccEntity", "goalField": "id", "transformation": "none" }
        ]
      },
      "historicalQsosAllowed": true,
      "scoring": {
        "method": "count",
        "tiebreaker": "earliestCompletion",
        "displayFormat": "{value} entities"
      }
    },
    "inviteConfig": null,
    "hamalertConfig": null,
    "isActive": true,
    "createdAt": "2025-01-01T00:00:00Z",
    "updatedAt": "2025-01-01T00:00:00Z"
  }
}

Fields: id, version, name, description, author (nullable), category, type, configuration, inviteConfig (nullable), hamalertConfig (nullable), isActive, createdAt, updatedAt. There is no badges array.

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
CHALLENGE_NOT_FOUND404No such challenge; details.challengeId

Notes: inviteConfig is returned verbatim, so a client can check requiresToken before attempting to join. Inactive challenges are still readable. version increments on every operator update, and ETag and X-Challenge-Version change with it.

Participation and progress

Join a challenge

POST /v1/challenges/:id/join

Auth: Public (optional bearer). A bearer is required when the callsign is already known to the server: the request must then be authenticated as that same callsign or it answers 403 FORBIDDEN. For a brand-new callsign no bearer is needed and this call issues the device token.

ParamTypeRequiredDescription
idUUID (path)yesChallenge id

Request (camelCase):

{
  "callsign": "W1ABC",
  "deviceName": "iPhone",
  "inviteToken": "inv_k3j5h2g8f7d6s5a4p0o9i8u7"
}
FieldTypeRequiredDescription
callsignstringyesUpper-cased before storage
deviceNamestringnoStored only when this call creates the participant
inviteTokenstringonly when inviteConfig.requiresToken is trueTrimmed; an empty string is treated as absent. Not validated on challenges that do not require a token

Processing order:

  1. Challenge lookup; unknown id answers 404.
  2. isActive is false: 400 CHALLENGE_ENDED. Only the flag is checked; timeConstraints dates are not evaluated.
  3. If requiresToken: the token must be non-empty, exist, and belong to this challenge, otherwise 403 INVITE_REQUIRED. Expired: 403 INVITE_EXPIRED. useCount >= maxUses: 403 INVITE_EXHAUSTED.
  4. The participant record is found or created.
  5. Existing participant and caller not authenticated as that callsign: 403 FORBIDDEN.
  6. Atomic invite redemption. If the token expired or ran out between steps 3 and 6, or the join lost a race: 403 INVITE_EXHAUSTED.
  7. Participation is recorded. If a participation already exists for this challenge and callsign: 409 ALREADY_JOINED, and the invite use is released.

Response: 201 Created

{
  "data": {
    "participationId": "0f9c1c2e-6c1a-4d8e-9a7b-3c2d1e0f9a8b",
    "deviceToken": "fd_AbCdEfGhIjKlMnOpQrStUvWxYz012345",
    "joinedAt": "2025-01-15T12:00:00Z",
    "status": "active",
    "historicalAllowed": true
  }
}

deviceToken is present only when this call created the participant; for an existing callsign it is omitted entirely. status is always "active" on a fresh join. historicalAllowed echoes configuration.historicalQsosAllowed (default true).

Errors

CodeHTTPWhen
VALIDATION_ERROR400Bad UUID, or body missing, not JSON, or missing callsign
CHALLENGE_ENDED400isActive is false
CHALLENGE_NOT_FOUND404Unknown challenge
INVITE_REQUIRED403Gated challenge and token missing, blank, unknown, or issued for a different challenge
INVITE_EXPIRED403Token expiresAt is in the past
INVITE_EXHAUSTED403useCount >= maxUses at validation time, or the atomic redemption did not succeed
FORBIDDEN403Callsign already registered and the caller is not authenticated as that callsign (third-party OAuth tokens count as anonymous here)
ALREADY_JOINED409A participation already exists for this challenge and callsign, including one with status left. Leaving a challenge is permanent; re-joining answers 409

Notes

  • The device token is shown once; the app must store it.
  • Gated joins increment the invite’s useCount.
  • Joining does not create a progress record. The participant is absent from the leaderboard until the first progress report.

Report progress

POST /v1/challenges/:id/progress

Auth: Bearer (first-party)

ParamTypeRequiredDescription
idUUID (path)yesChallenge id

Request:

{
  "completedGoals": ["CA", "NY", "TX"],
  "currentValue": 47,
  "qualifyingQsoCount": 52,
  "lastQsoDate": "2025-01-15T18:30:00Z"
}
FieldTypeRequiredDescription
completedGoalsstring[]yesGoal ids the client considers complete. Stored as sent; not checked against goals.items and not de-duplicated
currentValueint32yesCurrent numeric value for cumulative goals, or the value to score under points
qualifyingQsoCountint32yesAccepted but not stored
lastQsoDateISO8601 datetimenoStored; not returned by any endpoint in this section

Response: 200 OK (not 201)

{
  "data": {
    "accepted": true,
    "serverProgress": {
      "completedGoals": ["CA", "NY", "TX"],
      "currentValue": 47,
      "percentage": 6.0,
      "score": 3,
      "rank": 23,
      "currentTier": null
    },
    "newBadges": []
  }
}
  • accepted is always true.
  • serverProgress.completedGoals and currentValue echo the request.
  • percentage (floating point, not capped at 100), score and currentTier are computed as described in “How progress works”. rank is the rank after this report is applied.
  • newBadges is an array of badge ids and is currently always empty.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer
FORBIDDEN403Third-party OAuth token
VALIDATION_ERROR400Bad UUID; body missing or invalid; missing completedGoals, currentValue or qualifyingQsoCount
CHALLENGE_NOT_FOUND404Unknown challenge
NOT_PARTICIPATING403The caller has never joined this challenge. A participation of any status satisfies the check, so a callsign that has left can still report and reappears on the leaderboard

Notes

  • Each report fully replaces the previous one and refreshes the update time, which moves the reporter later in tie ordering.
  • Progress is accepted for inactive challenges; isActive is not checked here.

Get progress

GET /v1/challenges/:id/progress

Auth: Bearer (first-party)

ParamTypeRequiredDescription
idUUID (path)yesChallenge id

Response: 200 OK

{
  "data": {
    "completedGoals": ["CA", "NY", "TX"],
    "currentValue": 47,
    "percentage": 6.0,
    "score": 3,
    "rank": 23,
    "currentTier": null
  }
}

score and currentTier are the stored values from the last report. percentage is recomputed from the stored report and the current configuration. rank is live.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer
FORBIDDEN403Third-party OAuth token
VALIDATION_ERROR400Bad UUID
CHALLENGE_NOT_FOUND404Unknown challenge
NOT_PARTICIPATING403The caller has no stored progress for this challenge. A participant who has joined but never reported gets this code too

Leave a challenge

DELETE /v1/challenges/:id/leave

Auth: Bearer (first-party)

ParamTypeRequiredDescription
idUUID (path)yesChallenge id

Request: no body.

Response: 204 No Content, empty body.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer
FORBIDDEN403Third-party OAuth token
VALIDATION_ERROR400Bad UUID
NOT_PARTICIPATING403No participation with status active for the caller on this challenge. Also returned for an unknown challenge id; this endpoint never answers 404

Notes

  • Leaving marks the participation left and deletes the caller’s progress, in one transaction. A second leave call answers 403 and changes nothing.
  • After leaving, the callsign no longer appears in GET /v1/participants/:callsign/challenges (active only) or on the leaderboard (progress deleted).
  • Leaving a challenge is permanent. A later POST /v1/challenges/:id/join for the same callsign answers 409 ALREADY_JOINED.
  • Invite uses are not refunded.

Get participation status

GET /v1/challenges/:id/participants/:callsign

Auth: Bearer (first-party); the authenticated callsign must equal :callsign (case-insensitive)

ParamTypeRequiredDescription
idUUID (path)yesChallenge id
callsignstring (path)yesMust match the caller’s own callsign

Response: 200 OK

{
  "data": {
    "participationId": "0f9c1c2e-6c1a-4d8e-9a7b-3c2d1e0f9a8b",
    "challengeId": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
    "joinedAt": "2025-01-15T12:00:00Z",
    "status": "active"
  }
}

status is one of active, left, completed. The server currently writes only active and left.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer
FORBIDDEN403Third-party token, or :callsign is not the caller’s callsign (checked before any lookup)
VALIDATION_ERROR400Bad UUID
NOT_PARTICIPATING403No participation at all. A left participation is returned normally with status: "left". No 404 for an unknown challenge

List a callsign’s challenges

GET /v1/participants/:callsign/challenges

Auth: Bearer (first-party); :callsign must be the caller’s own

ParamTypeRequiredDescription
callsignstring (path)yesMust match the caller’s callsign (case-insensitive)

Response: 200 OK. Note that data is a bare array.

{
  "data": [
    {
      "participationId": "0f9c1c2e-6c1a-4d8e-9a7b-3c2d1e0f9a8b",
      "challengeId": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
      "challengeName": "ARRL WAS 250",
      "joinedAt": "2025-01-15T12:00:00Z",
      "status": "active"
    }
  ]
}

Only participations with status active are returned, most recent join first. Inactive challenges are still listed.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer
FORBIDDEN403Third-party token, or callsign mismatch

Leaderboards

Get leaderboard

GET /v1/challenges/:id/leaderboard

Auth: Public (optional bearer; the bearer is ignored. userPosition comes from the around parameter, never from the caller’s identity)

ParamTypeRequiredDescription
idUUID (path)yesChallenge id
limitint (query)noDefault 100, maximum 100. Ignored when around is set
offsetint (query)noDefault 0. Ignored when around is set
aroundstring (query)noCallsign to centre on. Switches to the “around me” window

Response: 200 OK

{
  "data": {
    "leaderboard": [
      {
        "rank": 1,
        "callsign": "K1ABC",
        "score": 50,
        "currentTier": "tier-50",
        "completedAt": "2025-01-10T00:00:00Z"
      },
      {
        "rank": 2,
        "callsign": "W1ABC",
        "score": 47,
        "currentTier": "tier-40",
        "completedAt": "2025-01-12T08:15:00Z"
      }
    ],
    "total": 1234,
    "userPosition": null,
    "lastUpdated": "2025-01-15T19:00:00Z"
  }
}

Entry fields: rank (integer), callsign, score, currentTier (nullable), completedAt (nullable). userPosition is either null or an object of the same shape.

Ranking

  • Only participants who have reported progress at least once appear. total is the number of such participants, not the participant count.
  • rank orders by score descending, then earliest update. Ties share a rank and the following rank is skipped (standard rank, not dense rank). scoring.tiebreaker is not applied.
  • completedAt is not a completion timestamp. It is the time of the participant’s latest report when score > 0, otherwise null. It moves on every report.
  • lastUpdated is the server time when the response was built, not a data freshness marker.

around behaviour

  • around returns a fixed window of five ranks either side of the named callsign (rank between target minus 5 and target plus 5, ordered by rank) and ignores limit and offset. With ties the window can hold more or fewer than 11 rows.
  • If the callsign has no reported progress, leaderboard is [] and userPosition is null; total is still the full count.
  • The callsign is upper-cased for the lookup, but userPosition is the entry whose callsign equals the around value exactly as supplied. Pass the callsign in upper case: around=w1abc returns the window but userPosition: null.

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
(plain text)400Unparsable limit or offset
CHALLENGE_NOT_FOUND404Unknown challenge

Badges

Get badge image

GET /v1/badges/:id/image

Auth: Public (optional bearer; ignored)

ParamTypeRequiredDescription
idUUID (path)yesBadge id

Response: 200 OK, body is the raw image bytes.

HeaderValue
Content-TypeAs uploaded: image/png, image/jpeg or image/svg+xml
Cache-Controlpublic, max-age=86400

No ETag or Last-Modified is set.

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
BADGE_NOT_FOUND404Unknown badge; details.badgeId
INTERNAL_ERROR500Server failure

Notes: Badges are deleted with their challenge, after which cached copies may outlive the badge for up to a day. Badge ids and the relative imageUrl (/v1/badges/:id/image) are issued to operators at upload time; prefix imageUrl with the API origin.

Operator endpoints

All operator endpoints require an operator token in Authorization: Bearer ...; any failure answers 401 INVALID_TOKEN. Request and response bodies are not documented here.

MethodPathPurpose
POST/v1/admin/challengesCreate a challenge. Answers 201 with the full challenge object at version 1. isActive defaults to true. configuration is stored without schema validation
PUT/v1/admin/challenges/:idReplace a challenge (full replacement, not a patch). Increments version and refreshes updatedAt, so the public ETag and X-Challenge-Version change. Stored scores and tiers are not recalculated until each participant reports again
DELETE/v1/admin/challenges/:idDelete a challenge and its participations, progress, badges and invites. Answers 204
POST/v1/admin/challenges/:id/badgesUpload a badge as multipart/form-data with parts name (required), tierId (optional, not validated against the configuration) and image (required; part Content-Type must be image/png, image/jpeg or image/svg+xml; at most 1,048,576 bytes). The whole request body is capped at 1 MiB, so the effective image maximum is a little under 1 MiB. Answers 201 with id, name, tierId, a relative imageUrl, contentType and createdAt
GET/v1/admin/challenges/:id/badgesList a challenge’s badges, oldest first, without image bytes. This is the only badge listing
DELETE/v1/admin/badges/:idDelete a badge. Answers 204. Public caches may keep serving the image for up to a day
POST/v1/admin/challenges/:id/invitesMint an invite token with optional maxUses (null means unlimited) and expiresAt (null means never). A JSON body is required; send {} for an unlimited, non-expiring token. Answers 201 with token, a relative url, maxUses, useCount, expiresAt and createdAt. Minting an invite does not gate the challenge; set inviteConfig.requiresToken for that
GET/v1/admin/challenges/:id/invitesList a challenge’s invites, newest first. Expired and exhausted tokens are included
DELETE/v1/admin/invites/:tokenRevoke an invite. Answers 204; unknown token answers 404 INVITE_NOT_FOUND. Participants who already joined with the token are unaffected

Programs

A program is an entry in the on-the-air activity registry: Parks on the Air (POTA), Summits on the Air (SOTA), WWFF, IOTA, LOTA, Agents on Air, and the catch-all casual. Clients load the registry at startup and use it to drive reference entry, reference validation, feature switches, and ADIF export. The registry is read-only for third parties.

Every success body in this section is wrapped as {"data": ...}. Every error body is {"error": {"code": "...", "message": "...", "details": {...}}}, where details is optional. Field names are camelCase. Malformed JSON bodies and bad path parameters return 400 VALIDATION_ERROR. Request bodies are capped at 1 MiB.

Program record

GroupFieldsMeaning
Identityslug, name, shortName, icon, iconUrl, website, serverBaseUrlHow the program is named and linked. slug is the stable key (for example pota). icon is an SF Symbol name. serverBaseUrl, when present, is the program’s own API base for spot and lookup features.
Reference formatreferenceLabel, referenceFormat, referenceExample, multiRefAllowed, activationThreshold, supportsRoveHow references such as K-0001 or W7W/LC-001 are labelled and validated. referenceFormat is a regular expression or null. activationThreshold is the number of QSOs needed for a valid activation (10 for POTA). multiRefAllowed and supportsRove say whether an operator can log several references at once or rove between them.
Capabilitiescapabilities (string array)Feature flags the client switches on. Known values: referenceField, adifUpload, browseSpots, selfSpot, hunter, locationLookup, progressTracking, dataEntry, dataVerification. The vocabulary is open; ignore values you do not recognise.
ADIF mappingadifFields object: mySig, mySigInfo, sigField, sigInfoFieldWhich ADIF tags carry the operator’s and the worked station’s reference. For POTA: MY_SIG=POTA, MY_SIG_INFO, SIG, SIG_INFO.
Data entrydataEntry object: label, placeholder, formatAn extra free-text field shown when the dataEntry capability is set (for example the Agents on Air “Passkey”).
StatusisActiveAlways true on public endpoints, which return only active programs.

Field presence rules:

  • website, referenceFormat, referenceExample, and activationThreshold are always present and may be null.
  • iconUrl, serverBaseUrl, adifFields, and dataEntry are omitted when unset.
  • isActive is always present.

Versioning and caching

The list endpoint returns a numeric version: the Unix epoch seconds of the most recent update among active programs, or 0 when there are none. Any change to an active program moves it. No ETag or Cache-Control headers are emitted and conditional requests are not supported. Cache the list, store version, and re-fetch when it differs.

List programs

GET /v1/programs

Auth: Public (optional bearer; the response is identical with or without a token).

No parameters.

Response: 200 OK

{
  "data": {
    "programs": [
      {
        "slug": "pota",
        "name": "Parks on the Air",
        "shortName": "POTA",
        "icon": "tree",
        "website": "https://pota.app",
        "referenceLabel": "Park Reference",
        "referenceFormat": "^[A-Z]+-[0-9]{4,5}$",
        "referenceExample": "K-0001",
        "multiRefAllowed": true,
        "activationThreshold": 10,
        "supportsRove": true,
        "capabilities": ["referenceField", "adifUpload", "browseSpots", "selfSpot", "hunter", "locationLookup", "progressTracking"],
        "adifFields": {
          "mySig": "POTA",
          "mySigInfo": "MY_POTA_REF",
          "sigField": "SIG",
          "sigInfoField": "SIG_INFO"
        },
        "isActive": true
      },
      {
        "slug": "aoa",
        "name": "Agents on Air",
        "shortName": "AoA",
        "icon": "antenna.radiowaves.left.and.right",
        "website": null,
        "referenceLabel": "Mission Reference",
        "referenceFormat": "^M-[a-z0-9]{4}$",
        "referenceExample": "M-a01f",
        "multiRefAllowed": false,
        "activationThreshold": null,
        "supportsRove": false,
        "capabilities": ["referenceField", "hunter", "dataEntry", "dataVerification"],
        "dataEntry": {
          "label": "Passkey",
          "placeholder": "Enter code from other station",
          "format": null
        },
        "isActive": true
      }
    ],
    "version": 1737900000
  }
}

Errors:

CodeHTTPWhen
INTERNAL_ERROR500Server-side failure

Notes:

  • Only active programs are returned, in display order.
  • No ETag is emitted; poll version instead.

Get a program

GET /v1/programs/:slug

Auth: Public (optional bearer; the response is identical with or without a token).

ParamTypeRequiredDescription
slugstring (path)yesProgram slug, for example pota. Case-sensitive exact match.

Response: 200 OK with {"data": <Program>}, the same shape as a list item above.

Errors:

CodeHTTPWhen
PROGRAM_NOT_FOUND404No program with that slug, or the program is inactive. details: {"slug": "<slug>"}
INTERNAL_ERROR500Server-side failure

Notes: Inactive programs are not distinguishable from missing ones on this endpoint.

Operator endpoints

These require an operator token and are not available to third-party integrations.

MethodPathPurpose
GET/v1/admin/programsList all programs, including inactive ones
GET/v1/admin/programs/:slugGet any program, including inactive ones
POST/v1/admin/programsCreate a program
PUT/v1/admin/programs/:slugPartially update a program (including isActive)
DELETE/v1/admin/programs/:slugDelete a program

Achievements

Achievements are shown to users as “Badges”. There are two kinds of record:

  • Catalog entries describe each badge: id (kebab-case string), name, description, category (creator, community, activity, ecosystem, streak, quirky), tier (bronze, silver, gold, or null), iconName (SF Symbol), accentHex, awardMethod, secret, sortOrder. The catalog is maintained by Carrier Wave; there is no API to add entries.
  • Awards record that a callsign holds a badge: id (UUID), callsign, achievementId, awardedAt, awardedBy, evidence (arbitrary JSON or null). A callsign holds each badge at most once.

Award methods

awardMethod on a catalog entry says who may grant the badge.

awardMethodGranted byExamples
manualCarrier Wave operatorscreator, beta-tester, founding-supporter, contributor, bug-hunter
signalA first-party client reporting a signal for the signed-in usersweep-user, watch-operator, home-screen, pebble-pioneer, swl, rbn-spotter
autoThe server, from the user’s logged activityeverything else

Signals. A signal is a one-line claim from a signed-in first-party client, such as “this user paired with Sweep”. The server accepts a signal only for catalog entries whose awardMethod is signal, so a client cannot claim manual or auto badges. The award is written with awardedBy set to signal. After a signal the server also checks for the cross-platform badge: it is granted when the callsign holds sweep-user and at least one of watch-operator, home-screen, or pebble-pioneer, both awarded within the last 7 days. That award has awardedBy set to system and evidence of {"windowDays": 7}.

Auto badges. Some auto badges are evaluated as soon as an activity is recorded: first-qso, first-cw, first-ssb, first-ft8, and mode-polyglot (five or more distinct modes), plus a small number of secret badges. Others are cumulative and are evaluated by a background evaluator that runs periodically over recently active callsigns: qso-100, qso-1000, qso-10000 (total QSOs), dxcc-10, dxcc-100 (distinct DXCC entities), all-hf (distinct bands), streak-7, streak-30, streak-365 (longest daily streak), and anniversary (account at least one year old). Auto awards are idempotent; re-evaluation never duplicates an award.

Refreshing on the client

  • Fetch the catalog once and cache it. Entries change rarely.
  • Fetch a callsign’s awards when showing a profile, and again after the user logs activity or after sending a signal. Auto awards can appear minutes after the activity that earned them.
  • Send each signal once. Re-sending is harmless but unnecessary.
  • No ETag or Cache-Control headers are emitted on these endpoints.

List the achievement catalog

GET /v1/achievements

Auth: Public (optional bearer; the response is identical with or without a token).

No parameters.

Response: 200 OK

{
  "data": {
    "achievements": [
      {
        "id": "first-qso",
        "name": "First QSO",
        "description": "Logged your first contact",
        "category": "activity",
        "tier": null,
        "iconName": "antenna.radiowaves.left.and.right",
        "accentHex": "#4CAF50",
        "awardMethod": "auto",
        "secret": false,
        "sortOrder": 100
      },
      {
        "id": "qso-100",
        "name": "Centurion",
        "description": "Logged 100 contacts",
        "category": "activity",
        "tier": "bronze",
        "iconName": "antenna.radiowaves.left.and.right",
        "accentHex": "#CD7F32",
        "awardMethod": "auto",
        "secret": false,
        "sortOrder": 110
      }
    ]
  }
}

Errors:

CodeHTTPWhen
INTERNAL_ERROR500Server-side failure

Notes:

  • Secret achievements (secret: true) are excluded. Every entry returned has secret: false.
  • Ordered by sortOrder ascending, then id ascending.

List a callsign’s achievements

GET /v1/users/:callsign/achievements

Auth: Public (optional bearer; the response is identical with or without a token). Anyone can read anyone’s awards.

ParamTypeRequiredDescription
callsignstring (path)yesUpper-cased by the server before lookup.

Response: 200 OK

{
  "data": {
    "callsign": "W6JY",
    "awards": [
      {
        "id": "3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b",
        "callsign": "W6JY",
        "achievementId": "creator",
        "awardedAt": "2026-03-01T18:22:10.512345Z",
        "awardedBy": "system",
        "evidence": null
      }
    ]
  }
}

Errors:

CodeHTTPWhen
INTERNAL_ERROR500Server-side failure

Notes:

  • An unknown callsign returns 200 with an empty awards array. There is no 404.
  • Awards of secret achievements are included even though the catalog omits them. A client that cannot match an achievementId to a catalog entry should skip it or render it generically.
  • Ordered by awardedAt descending.
  • evidence is whatever the awarding path recorded, for example {"from": "sessionCompleted"}, {"total": 1234}, or {"windowDays": 7}, or null.

Report an achievement signal

POST /v1/users/me/achievements/signal

Auth: Bearer (first-party). The award is always written for the token’s own callsign; the body cannot name another user. Third-party OAuth tokens are refused.

No path or query parameters.

Request:

FieldTypeRequiredDescription
idstringyesCatalog id. Its awardMethod must be signal.
evidenceany JSONnoStored verbatim on the award.
{ "id": "watch-operator" }

Response: 201 Created with the award.

{
  "data": {
    "id": "9d1b6e42-0c3a-4f5e-8b7d-2a1c3e5f7a9b",
    "callsign": "N0CALL",
    "achievementId": "watch-operator",
    "awardedAt": "2026-10-10T17:03:41.120934Z",
    "awardedBy": "signal",
    "evidence": null
  }
}

Errors:

CodeHTTPWhen
INVALID_TOKEN401Missing, invalid, or expired bearer token
FORBIDDEN403Third-party OAuth token
VALIDATION_ERROR400Unknown id ("Unknown achievement: <id>"), an id whose awardMethod is not signal ("Achievement '<id>' is not signal-awardable"), or malformed JSON
INTERNAL_ERROR500Server-side failure

Notes:

  • Idempotent. Re-sending the same signal returns 201 again with the original awardedAt. awardedBy is set to signal on every call. evidence is replaced only when the new request supplies a non-null value.
  • Any cross-platform award triggered by the signal is not included in this response. Fetch the callsign’s awards to see it.
  • Accepted ids: sweep-user, watch-operator, home-screen, pebble-pioneer, swl, rbn-spotter.

Operator endpoints

These require an operator token and are not available to third-party integrations.

MethodPathPurpose
POST/v1/admin/achievements/:id/awardAward a badge to one callsign
DELETE/v1/admin/achievements/:id/award/:callsignRevoke a badge from one callsign
POST/v1/admin/achievements/:id/award/batchAward a badge to many callsigns
POST/v1/admin/achievements/backfillRe-run the cumulative auto evaluators for every callsign

Contest definitions

A contest definition is a declarative JSON document describing one amateur-radio contest: schedule, exchange, dupe rules, scoring phases, operating constraints, Cabrillo header, and entry categories. The current format version is 0.3.0. Definitions are published by Carrier Wave and are read-only for third parties.

File vs. contest. A file has a version and a contests array. Each contest in the file is stored and served individually, keyed by its id. The file-level version is served as formatVersion on each contest.

Normalization. The served definition is a normalized form of what was uploaded: absent optional fields are dropped, experimental: false and an empty overlay_categories are omitted, required is always present on exchange fields, fields with defaults (such as zero_point_qsos_count_for_mults and a rule’s conditions) are always present, and unknown keys are discarded. The exception is data_dependencies, where unknown dependency types are kept.

Case conventions. The envelope and metadata fields (id, name, shortName, sponsorName, sponsorUrl, formatVersion, isActive, createdAt, updatedAt) are camelCase. The definition document itself is snake_case, because it is the stand-alone contest file format.

Versioning

version is a semver string, currently "0.3.0". Loaders should reject a file whose major version is higher than they support. Each contest carries updatedAt, which changes whenever its definition is republished. No ETag or Cache-Control headers are emitted; compare updatedAt per contest instead.

Validation

Definitions are validated in two passes before they are published.

  1. Structural. Required keys, types, and enum vocabularies (modes, bands, schedule.type, phase type, op, scope, and so on). A structural failure is a 400 VALIDATION_ERROR whose message is the parser’s text. It does not carry a problems list.
  2. Semantic. Every rule runs and the full list of problems is returned. Errors block publication; warnings do not.

A validation problem has this shape:

{
  "severity": "error",
  "contestId": "cwops-cwt",
  "path": "scoring.phases[2].operation.inputs[1].phase",
  "message": "..."
}

severity is error or warning. contestId is null only for file-level problems. path is a JSON path within the contest object.

Warnings are emitted for: autofill on a received exchange field; a per_qso phase whose last rule still has conditions; an unknown continent literal; and a scoring field with no data_dependencies entry that provides it. Everything else is an error, including duplicate contest, category, or phase ids, unknown modes, bands, or days, malformed HH:MM times, empty sessions, categories, or phases, forward phase references, unknown condition fields or dupe keys, and serial-number misuse.

A published definition that reaches you through the public endpoints has passed both passes.

Refreshing on the client

Call the list endpoint to get summaries, then fetch each contest’s full definition by id. Treat definition as an opaque document and keep it with its updatedAt. The first-party apps refresh at most once every 24 hours and on demand; a contest that disappears from the list has been withdrawn.

File-level fields

FieldTypeRequiredNotes
$schemastringnoJSON Schema URL. Preserved.
$idstringnoSchema id. Preserved.
titlestringnoHuman title of the file.
versionstringyesFormat semver, currently "0.3.0". Served as formatVersion.
contestsContest[]yesOne or more contests.

Contest fields

FieldTypeRequiredNotes
idstringyesStable kebab-case id. Unique within the file.
namestringyesFull name.
short_namestringnoFor example CWT.
sponsor{name?, url?}noServed as sponsorName and sponsorUrl in the metadata.
modesstring[]yesFrom CW, SSB, DIGITAL, RTTY, BOTH.
bandsstring[]yes2200m through 1mm.
data_dependenciesobjectnoKeys country_file, prefix_map, section_list, zone_map, plus unknown keys, which are kept. Each is {id, url?, file?, description?, update_frequency?, provides[], options?}.
scheduletagged objectyestype is recurring_weekly (sessions[{day, start_utc, duration_minutes}], session_scoring of independent or cumulative), annual (rule, start_utc, start_day, duration_hours), or fixed_dates (occurrences[{start, duration_hours}]).
exchange{sent[], received[]}yesEach field: name, type (one of text, signal_report, serial_number, cq_zone, itu_zone, grid_square, state_province, arrl_section, section_list, power, name, age, class), label, required (default true), autofill?, auto_increment?, list?, default?, pattern?, min?, max?.
dupe_rulesobjectyesscope of session or contest; key[] from callsign, band, mode, mode_group; zero_point_qsos_count_for_mults (default false).
scoring{phases[]}yesPhase type is per_qso (rules[{description?, conditions[], value}], first match wins), multiplier_count (source, scope of per_band or per_contest, include_zero_point_qsos), or aggregate (operation tree of `{op: ref
operating_constraintsobjectnooff_time {minimum_off_minutes?, maximum_on_hours_by_category{}}, band_changes {applies_to[], min_minutes_on_band?, max_changes_per_clock_hour?}.
cabrilloobjectnocontest_name, version (currently "3.0"), qso_format? (opaque).
categoriesCategory[]yes`{id, label, band?, power_classes[], assisted?, op_count? (single
overlay_categoriesOverlay[]no{id, label, max_on_hours?, max_license_years?}. Ids must not collide with categories.
experimentalboolnoDefault false. Omitted from the served document when false.

List contest definitions

GET /v1/contests

Auth: Public (optional bearer; the response is identical with or without a token).

ParamTypeRequiredDescription
includeInactivebool (query)noDefault false. When true, inactive contests are included. A non-boolean value returns a plain-text 400, not the JSON error envelope.

Response: 200 OK. Summaries only; no definition body.

{
  "data": {
    "contests": [
      {
        "id": "cwops-cwt",
        "name": "CWops Mini-CWT Test",
        "shortName": "CWT",
        "sponsorName": "CW Operators' Club",
        "formatVersion": "0.3.0",
        "isActive": true,
        "updatedAt": "2026-04-23T01:08:55.371358Z"
      },
      {
        "id": "k1usn-sst",
        "name": "K1USN Slow Speed Test",
        "shortName": "SST",
        "sponsorName": "K1USN Radio Club",
        "formatVersion": "0.3.0",
        "isActive": true,
        "updatedAt": "2026-04-23T01:08:55.371358Z"
      }
    ]
  }
}

Errors:

CodeHTTPWhen
INTERNAL_ERROR500Server-side failure

Notes:

  • Ordered by name ascending.
  • sponsorUrl is not included in list items; fetch the contest to get it.
  • No ETag is emitted; compare updatedAt per contest.

Get a contest definition

GET /v1/contests/:id

Auth: Public (optional bearer; the response is identical with or without a token).

ParamTypeRequiredDescription
idstring (path)yesThe contest id, for example cwops-cwt. Exact match.

Response: 200 OK. Metadata plus the full contest object under definition. The envelope keys are camelCase; the definition document is snake_case.

{
  "data": {
    "id": "a1club-awt",
    "name": "A1CLUB Weekly Test",
    "shortName": "AWT",
    "sponsorName": "A1CLUB",
    "sponsorUrl": "https://a1club.org/contest/awt/",
    "formatVersion": "0.3.0",
    "definition": {
      "id": "a1club-awt",
      "name": "A1CLUB Weekly Test",
      "short_name": "AWT",
      "sponsor": { "name": "A1CLUB", "url": "https://a1club.org/contest/awt/" },
      "modes": ["CW"],
      "bands": ["160m", "80m", "40m", "20m", "15m", "10m"],
      "schedule": {
        "type": "recurring_weekly",
        "sessions": [{ "day": "wednesday", "start_utc": "11:45", "duration_minutes": 75 }],
        "session_scoring": "independent"
      },
      "exchange": {
        "sent": [
          { "name": "rst", "type": "signal_report", "label": "RST", "required": true },
          { "name": "name", "type": "name", "label": "Name", "required": true }
        ],
        "received": [
          { "name": "rst", "type": "signal_report", "label": "RST", "required": true },
          { "name": "name", "type": "name", "label": "Name", "required": true }
        ]
      },
      "dupe_rules": { "scope": "session", "key": ["callsign", "band"], "zero_point_qsos_count_for_mults": false },
      "scoring": {
        "phases": [
          { "id": "qso_points", "type": "per_qso", "rules": [{ "conditions": [], "value": 1 }] },
          { "id": "mult_callsigns", "type": "multiplier_count", "description": "Unique callsigns worked across all bands", "source": "their_callsign", "scope": "per_contest", "include_zero_point_qsos": false },
          { "id": "total", "type": "aggregate", "operation": { "op": "multiply", "inputs": [ { "op": "ref", "phase": "qso_points" }, { "op": "ref", "phase": "mult_callsigns" } ] } }
        ]
      },
      "cabrillo": { "contest_name": "A1AWT", "version": "3.0" },
      "categories": [ { "id": "single_op", "label": "Single Operator", "power_classes": ["HP", "LP", "QRP"] } ],
      "experimental": true
    },
    "isActive": true,
    "createdAt": "2026-04-23T01:08:55.371358Z",
    "updatedAt": "2026-04-23T01:08:55.371358Z"
  }
}

Errors:

CodeHTTPWhen
CONTEST_DEFINITION_NOT_FOUND404No contest with that id. details: {"contestId": "<id>"}
INTERNAL_ERROR500Server-side failure

Notes: Unlike the list, this endpoint does not filter on isActive. An inactive contest is still fetchable by id; check isActive in the response.

Operator endpoints

These require an operator token and are not available to third-party integrations.

MethodPathPurpose
POST/v1/admin/contestsPublish or republish every contest in a definition file
DELETE/v1/admin/contests/:idWithdraw a contest definition
POST/v1/admin/contests/validateValidate a file or a single contest without publishing it

The social endpoints cover friends, the activity feed, clubs, and the public pages behind links shared from the app. JSON endpoints live under /v1 on https://activities.carrierwave.app. The HTML landing pages and the universal-link association file are served from the domain root of the same host.

These endpoints are first-party only. They accept the bearer token issued to the Carrier Wave apps (a device token or a first-party OAuth access token). A missing, malformed, unknown, expired or revoked token returns 401 INVALID_TOKEN. Third-party OAuth tokens are refused with 403 FORBIDDEN regardless of their scopes. The invite and share-link landing pages, the club logo and the universal-link association file are public and need no token.

Conventions shared by every JSON endpoint in this section:

  • Successful responses are wrapped as { "data": ... }. Field names are camelCase.
  • Errors are a top-level object { "code": "<CODE>", "message": "<text>", "details": { ... } }. details is omitted when there is nothing to add. 304 Not Modified has an empty body.
  • A path id that is not a UUID, malformed JSON, a wrong content type, or a missing required field returns 400 VALIDATION_ERROR.
  • Request bodies are limited to 1 MiB.
  • Requests are rate limited. Over the limit the server returns 429 RATE_LIMITED.
  • Timestamps are RFC 3339 strings in UTC.
  • There is no separate registration step. The first friends or feed call for a callsign creates its user record.

Friends

Friendships are symmetric and are keyed by user id, not by callsign. The lifecycle is:

  1. Invite link. GET /v1/friends/invite-link returns a single-use token (inv_ followed by 24 alphanumerics) and a URL of the form https://activities.carrierwave.app/invite/<token>. Tokens expire after 7 days. Each call returns a fresh token; earlier tokens stay valid until used or expired.
  2. Request. POST /v1/friends/requests with either inviteToken or toUserId creates a request in status pending. A request between a pair that was previously declined or unfriended is revived rather than rejected.
  3. Accept or decline. Only the addressee can act on a request. Accepting creates the friendship in both directions and is idempotent. Declining is not: a second decline returns 404.
  4. Remove. DELETE /v1/friends/:friendshipId removes the friendship in both directions and clears the request history between the two users, so either can send a new request later.

Request status is one of pending, accepted or declined. POST /v1/friends/suggestions answers “which of these callsigns are Carrier Wave users I am not already friends or pending with” for up to 100 callsigns per call.

The public page GET /invite/:token shows the inviter’s callsign while the invite is unused and unexpired, and offers a carrierwave://invite/<token> deep link. Viewing the page does not consume the invite.

GET /v1/friends/invite-link

Auth: Bearer (first-party)

No parameters. No request body.

Response: 200 OK

{
  "data": {
    "token": "inv_k3J9xQpL2mN8vB4cD7fG1hRs",
    "url": "https://activities.carrierwave.app/invite/inv_k3J9xQpL2mN8vB4cD7fG1hRs",
    "expiresAt": "2026-10-17T18:04:11.123456Z"
  }
}

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Third-party OAuth token
INTERNAL_ERROR500Server failure

Notes

  • Every call creates a new token. Outstanding tokens are not reused.
  • expiresAt is 7 days after creation.
  • The token is single-use. It is consumed by the first POST /v1/friends/requests that presents it.

Create friend request

POST /v1/friends/requests

Auth: Bearer (first-party)

Request. Exactly one of the two fields must be present.

FieldTypeRequiredDescription
inviteTokenstringone ofToken from an invite link (inv_...)
toUserIduuidone ofTarget user id, for example from /v1/friends/suggestions
{ "inviteToken": "inv_k3J9xQpL2mN8vB4cD7fG1hRs" }

or

{ "toUserId": "7d2c3d0a-6f6d-4b8e-9a7e-0b1f3d1a2c44" }

Response: 201 Created

{
  "data": {
    "id": "f0a2f8b4-1c1e-4f4e-9b0e-2b0c7f3c6d11",
    "fromUserId": "0a1b2c3d-...",
    "fromCallsign": "W6JY",
    "toUserId": "7d2c3d0a-...",
    "toCallsign": "K6ARK",
    "status": "pending",
    "requestedAt": "2026-10-10T18:04:11.123456Z"
  }
}

Errors

CodeHTTPWhen
VALIDATION_ERROR400Both or neither of toUserId and inviteToken given; malformed JSON
INVALID_TOKEN401Missing or invalid bearer token
USER_NOT_FOUND404toUserId does not exist (details.userId)
FRIEND_INVITE_NOT_FOUND404Token unknown, expired or already used (details.token)
ALREADY_FRIENDS409The two users are already friends
FRIEND_REQUEST_EXISTS409A pending request already exists in either direction
CANNOT_FRIEND_SELF422Target resolves to the caller

Notes

  • An invite token is consumed as soon as it is presented, even if the request is then rejected (for example with ALREADY_FRIENDS or CANNOT_FRIEND_SELF). The inviter must generate a new link in that case.
  • An expired or used token returns FRIEND_INVITE_NOT_FOUND.
  • If a request between the same pair was declined earlier, or left over from a removed friendship, it is revived as pending and its original id is returned.
  • Lifecycle: pending, then accepted (via accept) or declined (via decline). Removing a friend deletes the request history between the pair.

Friend suggestions

POST /v1/friends/suggestions

Auth: Bearer (first-party)

Request

FieldTypeRequiredDescription
callsignsstring[]yesCandidate callsigns, any case. Only the first 100 are considered.
{ "callsigns": ["k6ark", "W1AW", "N0CALL"] }

Response: 200 OK

{
  "data": [
    { "userId": "7d2c3d0a-...", "callsign": "K6ARK" },
    { "userId": "9e8f7a6b-...", "callsign": "W1AW" }
  ]
}

Errors

CodeHTTPWhen
VALIDATION_ERROR400Missing callsigns; malformed JSON
INVALID_TOKEN401Missing or invalid bearer token

Notes

  • An empty callsigns array returns [].
  • Matching is case-insensitive. The result excludes the caller, existing friends, and users with a pending request in either direction. Callsigns that are not Carrier Wave users are simply absent.
  • The returned callsign uses the casing stored for that user.

List friends

GET /v1/friends

Auth: Bearer (first-party)

No parameters.

Response: 200 OK

{
  "data": [
    {
      "friendshipId": "c1d2e3f4-...",
      "callsign": "K6ARK",
      "userId": "7d2c3d0a-...",
      "acceptedAt": "2026-09-30T02:15:00.000000Z"
    }
  ]
}

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer token

Notes

  • Ordered by friend callsign ascending.
  • friendshipId is the id to pass to DELETE /v1/friends/:id. It identifies the caller’s side of the friendship; the friend sees a different id for the same relationship.

List pending friend requests

GET /v1/friends/requests/pending

Auth: Bearer (first-party)

No parameters.

Response: 200 OK

{
  "data": {
    "incoming": [
      {
        "id": "f0a2f8b4-...",
        "fromUserId": "...", "fromCallsign": "N6XYZ",
        "toUserId": "...",   "toCallsign": "W6JY",
        "status": "pending",
        "requestedAt": "2026-10-10T17:00:00.000000Z"
      }
    ],
    "outgoing": []
  }
}

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer token

Notes

  • Only pending requests are returned. incoming holds requests addressed to the caller, outgoing the ones the caller sent. Both are newest first.

Accept friend request

POST /v1/friends/requests/:id/accept

Auth: Bearer (first-party)

ParamTypeRequiredDescription
iduuid (path)yesFriend request id

Request: no body.

Response: 200 OK with the request, now accepted.

{
  "data": {
    "id": "f0a2f8b4-...",
    "fromUserId": "...", "fromCallsign": "N6XYZ",
    "toUserId": "...",   "toCallsign": "W6JY",
    "status": "accepted",
    "requestedAt": "2026-10-10T17:00:00.000000Z"
  }
}

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Caller is not the addressee (toUserId)
FRIEND_REQUEST_NOT_FOUND404No such request, or the request was declined

Notes

  • Idempotent: accepting an already accepted request returns the existing record with 200, so a duplicate tap or a retry from a second device succeeds.
  • The sender cannot accept their own outgoing request.

Decline friend request

POST /v1/friends/requests/:id/decline

Auth: Bearer (first-party)

ParamTypeRequiredDescription
iduuid (path)yesFriend request id

Request: no body.

Response: 204 No Content

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Caller is not the addressee
FRIEND_REQUEST_NOT_FOUND404No such request, or it is no longer pending

Notes

  • Not idempotent. A second decline returns 404.
  • A later request between the same pair revives the declined request as pending.

Remove friend

DELETE /v1/friends/:id

Auth: Bearer (first-party)

ParamTypeRequiredDescription
iduuid (path)yesfriendshipId from GET /v1/friends

Response: 204 No Content

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
INVALID_TOKEN401Missing or invalid bearer token
FRIENDSHIP_NOT_FOUND404No friendship with this id belongs to the caller (details.friendshipId)

Notes

  • Removes the friendship for both users and clears the request history between them, so either can send a fresh request later.
  • Only the caller’s own friendshipId works. The id the other user sees for the same friendship returns 404.

Activities and feed

An activity is a client-reported event with a type string, a timestamp (when it happened) and a free-form JSON details object. The server does not validate type or details. The Carrier Wave apps currently send these kinds:

newDXCCEntity, newBand, newMode, dxContact, potaActivation, sotaActivation, dailyStreak, potaDailyStreak, personalBest, workedFriend, sessionCompleted

Some kinds also feed achievement evaluation, which runs when the activity is posted: newBand, sessionCompleted and newDXCCEntity read details.band, newMode reads details.mode, and workedFriend reads details.workedCallsign. Achievement evaluation never fails the write.

Who sees an activity:

  • GET /v1/feed returns activities of the caller’s friends, newest first. The caller’s own activities are not included.
  • GET /v1/clubs/:id/activity returns activities of a club’s members, including the caller’s own, and is visible to any member of that club.

Both feeds are cursor-paginated on the server-side creation time, not on the client-supplied timestamp. A page returns hasMore and nextCursor; pass nextCursor back verbatim to get the next page. Items older than the cursor are returned (strictly before).

Activities persist until the owner deletes them.

Report activity

POST /v1/activities

Auth: Bearer (first-party)

Request

FieldTypeRequiredDescription
typestringyesActivity kind (free-form; see the kinds above)
timestampRFC 3339 datetimeyesWhen the activity happened
detailsJSON objectyesArbitrary payload, stored verbatim
{
  "type": "sessionCompleted",
  "timestamp": "2026-10-10T17:42:00Z",
  "details": { "qsoCount": 23, "band": "20m", "mode": "CW", "park": "US-1234" }
}

Response: 201 Created

{
  "data": {
    "id": "3f1c...",
    "callsign": "W6JY",
    "activityType": "sessionCompleted",
    "timestamp": "2026-10-10T17:42:00Z",
    "details": { "qsoCount": 23, "band": "20m", "mode": "CW", "park": "US-1234" }
  }
}

Note the field names differ: the request uses type, the response uses activityType.

Errors

CodeHTTPWhen
VALIDATION_ERROR400Missing type, timestamp or details; bad timestamp; malformed JSON
INVALID_TOKEN401Missing or invalid bearer token
INTERNAL_ERROR500Server failure

Notes

  • callsign is the caller’s callsign at the time of the report.
  • The activity appears in friends’ GET /v1/feed and in GET /v1/clubs/:id/activity for every club the callsign belongs to.
  • There is no deduplication. Posting the same activity twice creates two activities.

Delete activity

DELETE /v1/activities/:id

Auth: Bearer (first-party)

ParamTypeRequiredDescription
iduuid (path)yesActivity id

Response: 204 No Content

Errors

CodeHTTPWhen
(400)400id is not a UUID
INVALID_TOKEN401Missing or invalid bearer token
ACTIVITY_NOT_FOUND404No activity with this id belongs to the caller (details.activityId)

Notes

  • Only the owner can delete an activity. Another user’s activity returns 404, not 403.

Get activity feed

GET /v1/feed

Auth: Bearer (first-party)

ParamTypeRequiredDescription
limitinteger (query)noPage size. Default 50, clamped to 1..100
beforeRFC 3339 string (query)noCursor. Returns items created strictly before this instant. Unparseable values are ignored and the first page is returned
filterstring (query)noAccepted but currently has no effect

Response: 200 OK

{
  "data": {
    "items": [
      {
        "id": "3f1c...",
        "callsign": "K6ARK",
        "userId": "7d2c3d0a-...",
        "displayName": null,
        "activityType": "potaActivation",
        "timestamp": "2026-10-10T16:00:00Z",
        "details": { "park": "US-0001" }
      }
    ],
    "pagination": {
      "hasMore": true,
      "nextCursor": "2026-10-10T16:00:03.512345+00:00"
    }
  }
}

displayName is currently always null.

Errors

CodeHTTPWhen
VALIDATION_ERROR400limit is not an integer
INVALID_TOKEN401Missing or invalid bearer token

Notes

  • Scope: activities of the caller’s friends. The caller’s own activities are excluded.
  • Ordering and the cursor use the server-side creation time, not the client-supplied timestamp. nextCursor is the creation time of the last item (RFC 3339 with a +00:00 offset) and is null when hasMore is false. Pass it back verbatim as before.
  • Items do not include their creation time. The cursor is the only place it appears.

Clubs

Clubs are curated by Carrier Wave operators. A club has a name, an optional callsign and description, an optional notes link (notesUrl and notesTitle), and an optional logo. There is no self-join endpoint. Members are added by an operator or by roster sync.

Membership and roles. The roster is a list of callsigns (stored uppercase), each with a role of admin or member. Members are listed admins first, then by callsign, and carry lastSeenAt and isCarrierWaveUser. Club admin members can edit the club’s notes link with PUT /v1/clubs/:id/notes. All members can read the club’s details, activity and member status.

Notes-URL roster sync. A club whose notesUrl points at a callsign-notes text file (one callsign per line, first whitespace-separated token, # comments and blank lines ignored; the Ham2K PoLo format) has its roster reconciled against that file about every six hours. Callsigns new to the file are added as member. Members with the member role who are no longer in the file are removed. Members with the admin role are never touched by sync. If the file cannot be fetched, returns a non-2xx status, or yields zero callsigns, the roster is left unchanged. Operators can also run the same reconcile on demand.

ETags. GET /v1/clubs/sync and GET /v1/clubs/membership return an ETag that changes whenever any of the caller’s clubs or their rosters change (add, remove, role change, roster sync, notes link, logo). Send it back as If-None-Match to get 304 Not Modified with an empty body when nothing changed.

Prior callsigns. GET /v1/clubs, GET /v1/clubs/sync and GET /v1/clubs/membership accept ?callsigns=A,B to include memberships held under the caller’s earlier callsigns. Each extra callsign is honoured only if the caller is known to own it (current callsign, callsign history, or callsigns linked to the account). Anything else is silently dropped. The per-club routes (/v1/clubs/:id/...) check only the caller’s current callsign.

Member status. GET /v1/clubs/:id/status classifies each member as onAir (an active spot for the callsign within the last 30 minutes), recentlyActive (seen in Carrier Wave within the last 15 minutes) or inactive.

Logos. GET /v1/clubs/:id/logo is public and cacheable. hasLogo on club responses says whether a logo exists.

Common query parameter for GET /v1/clubs, GET /v1/clubs/sync and GET /v1/clubs/membership:

ParamTypeRequiredDescription
callsignsstring (query)noComma-separated prior callsigns to include alongside the current one, for example ?callsigns=W6JSV,W6JNZ. Trimmed, uppercased, deduplicated. Callsigns the caller does not own are dropped silently.

List my clubs

GET /v1/clubs

Auth: Bearer (first-party)

Params: callsigns (see above).

Response: 200 OK

{
  "data": [
    {
      "id": "1b7e...",
      "name": "Brass Knuckle Gang",
      "callsign": "W6BKG",
      "description": "...",
      "notesUrl": "https://www.bkg.club/members.txt",
      "notesTitle": "BKG Callsign Notes",
      "hasLogo": true,
      "memberCount": 512
    }
  ]
}

notesUrl and notesTitle are omitted when unset. callsign and description are null when unset.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer token

Notes

  • Returns every club where any of the resolved callsigns is a member, ordered by name.

Sync clubs with full rosters

GET /v1/clubs/sync

Auth: Bearer (first-party)

Params: callsigns (see above). Optional request header If-None-Match.

Response: 200 OK with header ETag: "clubs-<epochSeconds>".

{
  "data": [
    {
      "id": "1b7e...",
      "name": "Brass Knuckle Gang",
      "callsign": "W6BKG",
      "description": null,
      "notesUrl": "https://www.bkg.club/members.txt",
      "notesTitle": "BKG Callsign Notes",
      "hasLogo": true,
      "memberCount": 512,
      "members": [
        {
          "callsign": "W6JY",
          "role": "admin",
          "joinedAt": "2026-09-01T00:00:00Z",
          "lastSeenAt": "2026-10-10T17:55:00Z",
          "isCarrierWaveUser": true
        },
        {
          "callsign": "9A2NB",
          "role": "member",
          "joinedAt": "2026-09-26T15:40:00Z",
          "isCarrierWaveUser": false
        }
      ]
    }
  ]
}

lastSeenAt is omitted when unknown. A lastGrid field exists but is currently always null and therefore omitted.

304 Not Modified with an empty body when If-None-Match equals the current ETag byte for byte, including the quotes. The 304 response carries no ETag header.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer token

Notes

  • The ETag is derived from the most recent change across the caller’s clubs and rosters. It is "clubs-0" when the caller has no clubs.
  • Members are ordered by club, admins first, then by callsign.

Club membership map

GET /v1/clubs/membership

Auth: Bearer (first-party)

Params: callsigns (see above). Optional request header If-None-Match.

Response: 200 OK with header ETag: "membership-<epochSeconds>".

{
  "data": [
    { "name": "Brass Knuckle Gang", "callsigns": ["9A2NB", "AA6XV", "W6JY"] }
  ]
}

304 Not Modified with an empty body on an ETag match.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer token

Notes

  • Same fingerprint as GET /v1/clubs/sync with a membership- prefix.
  • Entries are grouped by club name. The order of clubs in the array is unspecified; callsigns within a club are sorted. Two clubs with the same name are merged into one entry.
  • Intended as a lightweight callsign-to-club lookup.

Get club details

GET /v1/clubs/:id

Auth: Bearer (first-party)

ParamTypeRequiredDescription
iduuid (path)yesClub id

Response: 200 OK

{
  "data": {
    "id": "1b7e...",
    "name": "Brass Knuckle Gang",
    "callsign": "W6BKG",
    "description": null,
    "notesUrl": "https://www.bkg.club/members.txt",
    "notesTitle": "BKG Callsign Notes",
    "hasLogo": true,
    "members": [ { "callsign": "W6JY", "role": "admin", "joinedAt": "...", "lastSeenAt": "...", "isCarrierWaveUser": true } ]
  }
}

There is no memberCount on this response. Count the members array.

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Caller’s callsign is not on the roster. An unknown club id also returns 403
CLUB_NOT_FOUND404Club no longer exists

Notes

  • The membership check uses only the caller’s current callsign. ?callsigns= is not accepted here, so a membership held under a prior callsign returns 403 on the per-club routes.

Get club activity

GET /v1/clubs/:id/activity

Auth: Bearer (first-party)

ParamTypeRequiredDescription
iduuid (path)yesClub id
limitinteger (query)noPage size. Default 20, clamped to 1..100
cursorRFC 3339 string (query)noReturns items created strictly before this instant. Unparseable values are ignored

Response: 200 OK

{
  "data": {
    "items": [
      {
        "id": "3f1c...",
        "callsign": "9A2NB",
        "activityType": "newDXCCEntity",
        "timestamp": "2026-10-10T15:00:00Z",
        "details": { "entity": "Croatia", "band": "20m" },
        "createdAt": "2026-10-10T15:00:04.100000Z"
      }
    ],
    "pagination": { "hasMore": false, "nextCursor": null }
  }
}

Unlike GET /v1/feed, items include createdAt and omit userId and displayName.

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Caller is not a member, or the club is unknown

Notes

  • Scope: all activities whose callsign is on this club’s roster, including the caller’s own and members who are not friends.
  • Cursor semantics match GET /v1/feed: newest first by creation time, nextCursor is the createdAt of the last item. The query parameter is named cursor here and before on /v1/feed. The default limit is 20 here and 50 on /v1/feed.

Get club member status

GET /v1/clubs/:id/status

Auth: Bearer (first-party)

ParamTypeRequiredDescription
iduuid (path)yesClub id

Response: 200 OK

{
  "data": [
    {
      "callsign": "W6JY",
      "status": "onAir",
      "spotInfo": {
        "frequency": 14062.5,
        "mode": "CW",
        "source": "pota",
        "spottedAt": "2026-10-10T17:50:00Z"
      },
      "lastSeenAt": "2026-10-10T17:55:00Z"
    },
    { "callsign": "AA6XV", "status": "recentlyActive", "lastSeenAt": "2026-10-10T17:48:00Z" },
    { "callsign": "9A2NB", "status": "inactive" }
  ]
}

status is one of onAir, recentlyActive or inactive. spotInfo and lastSeenAt are omitted when not applicable.

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Caller is not a member, or the club is unknown

Notes

  • onAir: the most recent unexpired spot for the exact callsign, spotted within the last 30 minutes. spotInfo.frequency is in kHz. source is the lowercased spot network name, for example pota.
  • recentlyActive: no qualifying spot, and the member was seen in Carrier Wave within the last 15 minutes.
  • Otherwise inactive.
PUT /v1/clubs/:id/notes

Auth: Bearer (first-party). The caller must be an admin member of the club.

ParamTypeRequiredDescription
iduuid (path)yesClub id

Request. This is a replace, not a patch. A missing or null field clears that value.

FieldTypeRequiredDescription
notesUrlstring or nullnoMust start with https:// when present
notesTitlestring or nullnoDisplay title
{ "notesUrl": "https://www.bkg.club/members.txt", "notesTitle": "BKG Callsign Notes" }

Response: 200 OK with the club in the same shape as a GET /v1/clubs item.

Errors

CodeHTTPWhen
VALIDATION_ERROR400notesUrl present but not https://; malformed JSON; id is not a UUID
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Caller is not a member, or is a member without the admin role
CLUB_NOT_FOUND404Club no longer exists

Notes

  • Setting a notesUrl enrols the club in roster sync (see above). Clearing it stops future reconciliation but does not remove members.
  • Changes the club’s ETag.
GET /v1/clubs/:id/logo

Auth: Public

ParamTypeRequiredDescription
iduuid (path)yesClub id

Response: 200 OK with the raw image bytes. Content-Type is the uploaded type (image/png, image/jpeg, image/svg+xml, image/gif or image/webp). Cache-Control: public, max-age=86400.

Errors

CodeHTTPWhen
VALIDATION_ERROR400id is not a UUID
CLUB_NOT_FOUND404Club unknown, or it has no logo (details.clubId)

Notes

  • No membership check. Anyone with the club id can fetch the logo.
  • Treat 404 as “no logo”. hasLogo: false on club responses is the cheaper signal.

Operator endpoints

Clubs are created and maintained by Carrier Wave operators through /v1/admin/clubs*. These routes require an operator token (Authorization: Bearer <operator token>); any other credential returns 401 INVALID_TOKEN. They are listed here for completeness and are not available to app users.

MethodPathPurpose
POST/v1/admin/clubsCreate a club (name, optional callsign and description)
GET/v1/admin/clubsList all clubs with memberCount, rosterSyncedAt and rosterSyncStatus (ok or error: ...)
PUT/v1/admin/clubs/:idUpdate a club’s name, callsign, description, notesUrl and notesTitle
DELETE/v1/admin/clubs/:idDelete a club and its roster. Activities are untouched
GET/v1/admin/clubs/:id/membersList a club’s members
POST/v1/admin/clubs/:id/membersAdd members (callsign, optional role, default member). Re-adding an existing member updates their role
DELETE/v1/admin/clubs/:id/members/:callsignRemove a member
PUT/v1/admin/clubs/:id/members/:callsignChange a member’s role (admin or member)
POST/v1/admin/clubs/:id/import-notesReconcile the roster from the club’s notesUrl now. Returns imported, skipped, removed, callsigns and removedCallsigns
PUT/v1/admin/clubs/:id/logoUpload or replace the logo. Multipart with one image part (image/png, image/jpeg, image/svg+xml, image/gif or image/webp); logo uploads are limited to 1 MiB
DELETE/v1/admin/clubs/:id/logoRemove the logo

Members added as member to a club with a notesUrl are removed by the next roster sync unless they are also in the notes file. Add them as admin to make them stick. Every roster or logo change moves the club’s ETag.

Share and invite pages

The pages below are served from the domain root of https://activities.carrierwave.app, not under /v1. They need no token.

Friend invite landing page

GET /invite/:token

Auth: Public

ParamTypeRequiredDescription
tokenstring (path)yesInvite token from GET /v1/friends/invite-link

Response: always 200 OK, Content-Type: text/html; charset=utf-8, no cache headers. The page renders a single card:

  • If the token exists, is unused and unexpired: <title> and og:title are <CALLSIGN> wants to be friends on Carrier Wave, og:description is Open this link in Carrier Wave to add <CALLSIGN> as a friend., and the heading is <CALLSIGN> wants to be friends!.
  • Otherwise (unknown, used or expired): the generic Friend invite on Carrier Wave, You've been invited! and Open this link in Carrier Wave to accept this friend invite..
  • An “Open in Carrier Wave” button linking to carrierwave://invite/<token> when the token matches [A-Za-z0-9_]+, otherwise to a bare carrierwave://invite.

The callsign and token are HTML-escaped. Viewing the page does not consume the invite; only POST /v1/friends/requests does.

Errors: none. Lookup failures fall back to the generic page.

Notes

  • This page has no Open Graph image.
  • /invite/* is not a universal link. The app is reached through the carrierwave:// scheme only.
GET /link/qso/:token
GET /link/act/:token
GET /link/call/:callsign
GET /link/park/:reference
GET /link/sum/:reference

Auth: Public

ParamTypeRequiredDescription
token, callsign or referencestring (path)yesIdentifier chosen by the sharing app. For call and sum, a / in the real identifier is written as - (for example KD9LSV-P, W6-NC-123); the page displays it uppercased with - turned back into /. For qso and act it is an opaque share token (for example jZRcZAZMQI).

The server performs no lookup of the identifier. Every page is a static shell for the given kind.

Response: 200 OK, Content-Type: text/html; charset=utf-8, Cache-Control: public, max-age=3600, s-maxage=86400. The HTML contains:

  • <title> and og:title: Carrier Wave QSO (qso), Carrier Wave Activation (act), or <IDENTIFIER> on Carrier Wave (call, park, sum; identifier uppercased with - turned into /).
  • og:description and <meta name="description">: a fixed sentence per kind, for example A QSO shared from Carrier Wave. Open in the app for full details.
  • og:type=website, og:site_name=Carrier Wave, og:url=https://carrierwave.app/link/<kind>/<identifier>, og:image=https://carrierwave.app/icon.png (512 by 512), twitter:card=summary_large_image.
  • A fallback card with the kind (QSO, Activation, Callsign, Park or Summit) and the normalised identifier, an “Open in Carrier Wave” link to the canonical path (which relies on universal links) and a “Get Carrier Wave” link to https://carrierwave.app/.
  • <script src="/link/static/link-card.js" defer>.

The #d= fragment convention. The sharing app appends #d=<base64url(JSON)> to the URL. Browsers never send the fragment to the server, so the QSO or activation data is never received, logged or stored server-side. The page script reads location.hash, strips the optional d= prefix, base64url-decodes, parses the JSON and renders the card in the browser. The payload carries a version in v (currently 1; a greater value renders an “Update Carrier Wave” notice) and a kind in k:

kFields rendered
qsoc their call, me my call, rs/rr RST sent/received, mg/tg my/their grid, b band, md mode, f frequency MHz, p park, ts unix seconds, nt notes
callc call, g grid, co DXCC, q QTH, wc worked count, lb/lm last band/mode, nm name, ph footnote
parkr reference, nm name, la/lo latitude/longitude, st state, co country, ua/uh user activations/hunts, an active-now boolean
sumr reference, nm name, la/lo, am altitude m, pt points, rn region, as association, ac activations, ua/uc my activations/chases
actp park reference, pn park name, me operator, qc QSO count, bs bands[], ms modes[], p2p, dx, st start unix seconds, nt notes

The script also calls the public callsign enrichment endpoint (GET /v1/callsign-enrichment/<callsign>) to draw club-membership chips for the callsigns in the payload. With no fragment, or an invalid one, the static fallback card stays in place.

Errors: none. Any path segment renders.

Notes

  • Identifiers are HTML-escaped before rendering. The canonical URL keeps the identifier as given.
  • Because /link/* is a universal link, iOS and macOS devices with the app installed open these URLs in the app, fragment included.
GET /link/static/link-card.js

Auth: Public

Response: 200 OK, Content-Type: application/javascript; charset=utf-8, Cache-Control: public, max-age=86400, s-maxage=604800. The script that renders the share-link card from the #d= fragment.

Apple App Site Association

GET /.well-known/apple-app-site-association

Auth: Public

Response: 200 OK, Content-Type: application/json, Cache-Control: public, max-age=3600.

{
  "applinks": {
    "apps": [],
    "details": [
      { "appID": "7UE4RDLUSX.com.jsvana.FullDuplex", "paths": ["/link/*"] },
      { "appID": "7UE4RDLUSX.com.jsvana.CWSweep",    "paths": ["/link/*"] }
    ]
  }
}

Notes

  • Only /link/* is claimed, for both the iOS and macOS apps. /invite/* is not.
  • Served unsigned over HTTPS.

Spots and reference data

This family covers the live “who is on the air” feed and the reference datasets that sit behind it: Reverse Beacon Network spots, KiwiSDR receiver proxies, POTA activation statistics, park and administrative boundaries, historic trails, satellite status, callsign enrichment and HF propagation predictions. Most of it is public. The few write endpoints need a first-party bearer token.

All paths are mounted under /v1. Successful JSON responses from the spots, POTA statistics and status endpoints are wrapped as {"data": ...}. GeoJSON endpoints and the RBN, WebSDR, satellite, callsign-enrichment and propagation endpoints return bare bodies. Each endpoint below states which. Errors use the envelope {"error": {"code", "message", "details"?}} unless noted.

Request validation behaves the same way across the family:

  • Malformed query parameters (a non-numeric limit, an unknown source, a missing required host) answer with a plain-text 400, not the JSON envelope.
  • Malformed path parameters answer with 400 VALIDATION_ERROR in the JSON envelope.
  • Malformed JSON bodies on POST /v1/spots and POST /v1/sota/zones answer with 400 VALIDATION_ERROR. The propagation endpoints answer malformed bodies with a plain-text 400, 415 or 422.
  • A global rate limit applies to every endpoint: a burst of 20 requests, refilling at one request per 100 ms. Excess requests receive 429. Request bodies are capped at 1 MiB.

Field names are camelCase except where a section says otherwise (the RBN, callsign-enrichment and propagation endpoints use snake_case).

DatasetEndpointUpstream sourceTypical refresh
Spots (POTA, SOTA, self-spots)GET /v1/spotsPOTA activator spot feed, SOTAwatch spot feed, first-party clientsPOTA polled every 60 s, SOTA every 90 s; expired spots purged every 2 minutes
RBN spotsGET /v1/rbn/*telnet.reversebeacon.net:7000Live; rolling 1-hour in-memory window
KiwiSDR directoryGET /v1/websdr/directoryhttps://rx.skywavelinux.com/kiwisdr_com.jsCached in memory for 1 hour
POTA park catalog and activation statsGET /v1/pota/stats/*https://pota.app/all_parks_ext.csv, https://api.pota.appCatalog resynced once per 24 h; per-park stats refreshed on a rolling 24 h cycle
Park boundaries (US, UK, IT, PL, selected US state parks)GET /v1/parks/boundaries*PAD-US, Natural England, WDPA feature services; state agencies for FL, OR, CA, TX; Polish GDOŚ WFS24 h cycle; a park’s polygon is re-fetched after 90 days
US administrative boundaries (states, counties)GET /v1/admin/boundariesUS Census cartographic boundary files (1:500k, 2023 vintage)Loaded manually; refreshed rarely
National historic and scenic trailsGET /v1/trails*USGS National Map transportation layer; NTIR per-trail feature servicesWeekly cycle over a 19-trail catalog; a trail is re-fetched after 180 days
SOTA activation zonesPOST /v1/sota/zonesComputed from Terrarium elevation tilesOn demand; cached permanently per summit code
AMSAT satellite statusGET /v1/satellites/amsat-statushttps://www.amsat.org/status/Every 30 minutes
Callsign enrichment (CWops)GET /v1/callsign-enrichment/*CWops membership rosterEvery 24 h
HF propagation predictionsPOST /v1/propagation/*DVOACAP-based prediction service run alongside the APIComputed on demand; results cached for 24 h

Error codes used by this family

CodeHTTPRaised by
VALIDATION_ERROR400Bad JSON body or path parameter; WebSDR host checks; bbox, refs and kind validation; POTA mode; propagation inputs rejected by the prediction service
INVALID_TOKEN401Missing or invalid bearer on authenticated routes
PROGRAM_NOT_FOUND404POST /v1/spots with an unknown program
SPOT_NOT_FOUND404DELETE /v1/spots/:id
PARK_NOT_FOUND404GET /v1/pota/stats/park/:reference, GET /v1/parks/boundaries/:reference
TRAIL_NOT_FOUND404GET /v1/trails/:reference
NOT_FOUND404Propagation endpoints when predictions are disabled
SELF_SPOT_EXISTS409POST /v1/spots
CAPABILITY_NOT_SUPPORTED422POST /v1/spots for a program without self-spotting
RATE_LIMITED429Global rate limit
INTERNAL_ERROR500Database errors, upstream fetch failures (WebSDR, propagation)

Spots

The spot feed is a short-lived merge of several sources. Each spot carries a source of pota, rbn, sota, self or other.

  • POTA spots come from the POTA activator feed, polled every 60 s. Each spot expires when the upstream feed says it does, clamped to 24 hours, or after 30 minutes when upstream gives no expiry. They carry programSlug: "pota", and a locationDesc such as US-WY is split into countryCode and stateAbbr.
  • SOTA spots come from the SOTAwatch feed, polled every 90 s. They expire 30 minutes after spottedAt. reference is ASSOCIATION/SUMMIT (for example W7W/KI-001). Frequencies are converted to kHz.
  • Self-spots are posted by first-party clients with POST /v1/spots. They expire after 30 minutes, and a participant can hold at most one unexpired self-spot per program.
  • SMS spots are sent as a text message to the Carrier Wave number using a marker from POST /v1/spot-markers. The inbound webhook (POST /v1/twilio/sms) is called by the SMS provider, not by clients. POTA references are forwarded to the POTA spot API and re-enter the feed through the POTA poller. SOTA references by SMS are currently disabled and receive a “temporarily disabled” reply.

Expired spots are purged every 2 minutes and are never served in between. latitude and longitude on a spot are the coordinates of the referenced park or summit (the POTA park catalog centroid or the SOTA activation zone centroid), not of the spotter. They are present only when the park catalog has been synced (POTA) or an activation zone has been computed for that summit (SOTA).

List spots

GET /v1/spots

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDefaultDescription
programstringnoExact match on programSlug (for example pota, sota). Case-sensitive.
callsignstringnoExact match on callsign. Not upper-cased by the server.
sourceenumnoOne of pota, rbn, sota, self, other. Any other value answers with a plain-text 400.
modestringnoExact match on mode (for example CW, SSB).
statestringnoExact match on stateAbbr (for example WY).
maxAgeMinutesintno30Only spots with spottedAt within the last N minutes. Clamped to 1 to 1440.
limitintno100Page size. Clamped to 1 to 250.
cursorstringnoRFC 3339 timestamp from a previous pagination.nextCursor. Returns spots with spottedAt strictly older. An unparsable cursor is ignored.

Response 200 OK, ordered by spottedAt descending. Only unexpired spots are returned.

{
  "data": {
    "spots": [
      {
        "id": "7a0a9d0e-6e7d-4c2a-9a62-0c7f8e1a2b3c",
        "callsign": "W1AW",
        "programSlug": "pota",
        "source": "pota",
        "frequencyKhz": 14062.0,
        "mode": "CW",
        "reference": "US-1234",
        "referenceName": "Example State Park",
        "spotter": "K1ABC",
        "spotterGrid": "FN42",
        "locationDesc": "US-WY",
        "countryCode": "US",
        "stateAbbr": "WY",
        "comments": "QRP",
        "snr": 18,
        "wpm": 25,
        "latitude": 42.86,
        "longitude": -106.31,
        "spottedAt": "2026-10-10T14:02:11Z",
        "expiresAt": "2026-10-10T14:32:11Z"
      }
    ],
    "pagination": {
      "hasMore": true,
      "nextCursor": "2026-10-10T14:02:11+00:00"
    }
  }
}

Optional fields (programSlug, reference, referenceName, spotter, spotterGrid, locationDesc, countryCode, stateAbbr, comments, snr, wpm, latitude, longitude) are omitted when null. frequencyKhz is kHz, snr is dB, wpm is words per minute. nextCursor is the last returned spottedAt, formatted with a +00:00 offset rather than Z. No caching headers are set.

Errors

CodeHTTPWhen
(plain text)400Malformed query value (source not in the enum, non-integer limit or maxAgeMinutes).
INTERNAL_ERROR500Database failure.

Create a self-spot

POST /v1/spots

Auth: Bearer (first-party). The callsign and participant come from the token.

Request

{
  "programSlug": "pota",
  "frequencyKhz": 14062.0,
  "mode": "CW",
  "reference": "US-1234",
  "comments": "QRP from the summit"
}
FieldTypeRequiredDescription
programSlugstringyesMust name an existing program that supports the selfSpot capability.
frequencyKhznumberyesFrequency in kHz. Not range-checked by the server.
modestringyesFree text, stored as sent.
referencestringnoPark or summit reference. Used to resolve coordinates in GET /v1/spots.
commentsstringnoFree text.

Response 201 Created with {"data": <Spot>} in the same shape as the list endpoint. source is "self", expiresAt is 30 minutes after creation, and spotter, snr, wpm, latitude and longitude are absent. Coordinates appear once the spot is read back through GET /v1/spots.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer.
VALIDATION_ERROR400Body is not valid JSON for the schema.
PROGRAM_NOT_FOUND404programSlug is unknown. details: {"slug"}.
CAPABILITY_NOT_SUPPORTED422The program does not support self-spotting. details: {"capability": "selfSpot", "programSlug"}.
SELF_SPOT_EXISTS409The caller already has an unexpired self-spot for this program. Delete it first or wait for it to expire.

Delete your own self-spot

DELETE /v1/spots/:id

Auth: Bearer (first-party).

ParamTypeRequiredDescription
idUUIDyesSpot id. The spot must have been submitted by the caller.

Response 204 No Content.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer.
VALIDATION_ERROR400id is not a UUID.
SPOT_NOT_FOUND404No spot with that id belongs to the caller. Also returned for other users’ spots and for aggregated spots. details: {"spotId"}.

Create a spot marker (SMS spotting)

POST /v1/spot-markers

Auth: Bearer (first-party). No request body.

Generates a 6-character marker from the alphabet ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (no I, O, 0 or 1) bound to the caller’s callsign. Each call replaces any existing marker for that callsign, so the previous marker stops working.

Response 201 Created

{
  "data": {
    "marker": "K7Q2ZX",
    "callsign": "W1AW",
    "createdAt": "2026-10-10T14:00:00Z"
  }
}

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer.
INTERNAL_ERROR500Database failure.

Notes: The marker is the first token of the text message: MARKER REFERENCE FREQUENCY MODE [COMMENTS], for example K7Q2ZX US-1234 14.062 CW calling cq.

Reverse Beacon Network

The server keeps a live telnet session to telnet.reversebeacon.net:7000 and holds the last hour of spots in memory. Only CQ and DX spot types are kept; beacon and NCDXF spots are dropped. Spots whose frequency falls outside the HF and 6 m amateur bands are dropped as well, so band is always one of 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m or 6m. Nothing is persisted: the window is at most the last hour and is empty immediately after a server restart.

All three endpoints return bare JSON with snake_case field names and set no caching headers. When the RBN feed is disabled on the server they return empty results rather than an error.

RBN spots

GET /v1/rbn/spots

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDefaultDescription
callstringnoSpotted callsign, case-insensitive exact match.
spotterstringnoSkimmer callsign (for example KM3T-#), case-insensitive exact match.
modestringnoComma-separated list (CW,RTTY), case-insensitive. A spot matches if its mode is in the list.
bandstringnoBand label, case-insensitive (20m).
min_freqnumbernoLower bound in kHz, inclusive.
max_freqnumbernoUpper bound in kHz, inclusive.
sinceRFC 3339 datetimenonow minus 1 hourOnly spots with timestamp >= since. Cannot reach further back than the 1-hour window.
limitintno100Capped at 500.

Response 200 OK, newest first.

{
  "total": 1342,
  "spots": [
    {
      "id": 48213,
      "callsign": "W1AW",
      "frequency": 14039.8,
      "mode": "CW",
      "snr": 18,
      "wpm": 25,
      "spotter": "KM3T-#",
      "band": "20m",
      "timestamp": "2026-10-10T14:02:11Z"
    }
  ]
}

total is the number of matching spots before limit is applied. id is a counter that resets when the server restarts. frequency is kHz. snr is dB and is 0 when the upstream line did not carry one. wpm is omitted when absent.

Errors

CodeHTTPWhen
(plain text)400Non-numeric min_freq, max_freq or limit, or unparsable since.

RBN stats

GET /v1/rbn/stats

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDefaultDescription
minutesintno60Window ending now. Clamped to 1 to 60.

Response 200 OK

{
  "minutes": 60,
  "total_spots": 5120,
  "spots_per_minute": 85.33,
  "bands": { "20m": 2100, "40m": 1600, "15m": 900 },
  "modes": { "CW": 4800, "RTTY": 220, "FT8": 100 }
}

bands and modes map a label to a count. Key order is unspecified.

RBN skimmers

GET /v1/rbn/skimmers

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDefaultDescription
minutesintno60Window ending now. Clamped to 1 to 60.
limitintno100Maximum skimmers returned. Capped at 500.

Response 200 OK, sorted by spot_count descending.

{
  "minutes": 60,
  "count": 212,
  "skimmers": [
    {
      "callsign": "KM3T-#",
      "spot_count": 310,
      "last_spot": "2026-10-10T14:02:11Z",
      "bands": ["20m", "40m", "15m"]
    }
  ]
}

count is the number of distinct active skimmers before limit. bands lists bands in first-seen order.

WebSDR proxies

These three endpoints exist so that a browser page served over HTTPS can load the KiwiSDR public directory, probe a receiver’s status, and open a receiver’s audio socket without running into cross-origin or mixed-content restrictions. Only hosts that resolve to public unicast addresses can be proxied; private, loopback, link-local, carrier-grade NAT and similar ranges are refused. Redirects from the target receiver are not followed by the status probe.

KiwiSDR directory

GET /v1/websdr/directory

Auth: Public (optional bearer; no behaviour change). No parameters.

Response 200 OK with Content-Type: application/javascript; charset=utf-8. The body is the raw JavaScript of https://rx.skywavelinux.com/kiwisdr_com.js (about 1 MB). The server caches it in memory for 1 hour and collapses concurrent cache misses into a single upstream fetch. No ETag or Cache-Control headers are set. Clients parse the JavaScript themselves.

Errors

CodeHTTPWhen
INTERNAL_ERROR500The upstream fetch failed or returned a non-2xx status ("websdr directory fetch failed: ..."). A stale cached copy is not served on failure.

Receiver status probe

GET /v1/websdr/status

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDescription
hoststringyesHostname or IPv4 literal (IPv6 literals are rejected). 1 to 253 characters of [A-Za-z0-9.-], not starting with - or ..
portintegeryesMust be 80, 443, or 1024 or above.

Response 200 OK with Content-Type: text/plain; charset=utf-8. The body is the receiver’s http://host:port/status response verbatim (KiwiSDR key=value lines). The upstream request times out after 5 s. No caching headers are set.

Errors

CodeHTTPWhen
(plain text)400host or port missing, or port not a 16-bit integer.
VALIDATION_ERROR400message is one of invalid host, invalid port, host must be a public address, host does not resolve, host resolves to a non-public address.
INTERNAL_ERROR500Receiver unreachable, timed out, or returned a non-2xx status.

Audio stream bridge (WebSocket)

GET /v1/websdr/stream

Auth: Public (optional bearer; no behaviour change). Browsers cannot send an Authorization header on a WebSocket handshake.

Handshake: a standard HTTP/1.1 WebSocket upgrade (Upgrade: websocket, Sec-WebSocket-Key, and so on) with query parameters:

ParamTypeRequiredDescription
hoststringyesSame rules as /v1/websdr/status.
portintegeryesSame rules as /v1/websdr/status.

Host validation runs before the upgrade, so a rejected host answers with a normal 400 VALIDATION_ERROR JSON response, not a WebSocket close. A request without upgrade headers answers with a plain-text 400.

After the upgrade:

  1. The server warms up the receiver by fetching /, /kiwisdr.min.js, /config/config.js and /status from its own address, with a 4 s deadline. This raises the Kiwi’s per-client page counter so the audio socket is not kicked with MSG too_busy after 10 s. Warm-up failures are ignored.
  2. It connects upstream to ws://host:port/ws/kiwi/<unix_ts>/SND (current firmware). If that handshake is rejected it falls back to ws://host:port/<unix_ts>/SND (legacy firmware). Each attempt has a 10 s connect timeout and follows up to 3 HTTP redirects (the kiwisdr.com proxy answers with 307s), re-validating every redirect target against the public-address rule and preserving path case.
  3. If the upstream connection fails the server sends one Text frame to the browser and then closes:
    • MSG cw_proxy_error=connect_timeout
    • MSG cw_proxy_error=connect_failed (handshake rejected, too many redirects, or an unparsable redirect)
    • MSG cw_proxy_error=redirect_blocked (a redirect pointed at a non-public target)
  4. On success the socket is a transparent byte pipe. Text, Binary, Ping and Pong frames are forwarded unchanged in both directions, and a Close from either side closes the other. Nothing is interpreted: the browser speaks the KiwiSDR SND protocol end to end, and the server never sends SET auth, SET mod, keepalives or anything else on its own.

The error Text frame above is the only message the server originates.

Errors

CodeHTTPWhen
(plain text)400Missing or invalid host or port, or not a WebSocket request.
VALIDATION_ERROR400Host validation (see the status probe).

Notes: The 1 MiB request body limit does not apply to WebSocket frames.

POTA statistics

Activation and hunter statistics for Parks on the Air, built from the POTA park catalog (https://pota.app/all_parks_ext.csv) and the public POTA API. The catalog is resynced once every 24 hours and per-park statistics are refreshed on a rolling 24-hour cycle, so the dataset is usually a little behind POTA itself.

All responses are {"data": ...} wrapped with camelCase fields. Every response includes a freshness object describing how far the current collection cycle has progressed:

"freshness": {
  "oldestFetch": "2026-10-09T02:00:00Z",
  "newestFetch": "2026-10-10T13:55:00Z",
  "parksPending": 120,
  "totalParks": 72000,
  "warning": "Data collection in progress (99% complete, 71880 of 72000 parks fetched). Stats may be incomplete."
}

warning is omitted when collection is complete. It reads "Park catalog has not been synced yet. Stats are unavailable." when totalParks is 0. oldestFetch and newestFetch are null until any park has been fetched. When a query has a state, freshness is scoped to that state.

Callsigns, states and park references are upper-cased by the server before lookup. Unknown callsigns and states return zeros with HTTP 200. Only unknown park references answer 404.

Activator stats

GET /v1/pota/stats/activator

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDescription
callsignstringyesActivator callsign.
statestringnoRestrict ranking and totals to parks in this state (abbreviation such as WY).
modestringnocw, data or phone (case-insensitive). Ranks by that mode’s QSO count only.

Response 200 OK

{
  "data": {
    "callsign": "W1AW",
    "activationCount": 42,
    "totalQsos": 1830,
    "qsosByMode": { "cw": 1200, "data": 300, "phone": 330 },
    "rank": 17,
    "totalRanked": 25000,
    "state": "WY",
    "modeFilter": "cw",
    "freshness": { ... }
  }
}

state and modeFilter are echoed only when supplied. When mode is given, activationCount is 0 and only the selected mode in qsosByMode is populated. rank and totalRanked are 0 for an unranked callsign.

Errors

CodeHTTPWhen
VALIDATION_ERROR400mode is not cw, data or phone ("Invalid mode 'x'. Must be cw, data, or phone.").
(plain text)400callsign missing.

Hunter stats

GET /v1/pota/stats/hunter

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDescription
callsignstringyesHunter callsign.
statestringnoRestrict to parks in this state.

Response 200 OK

{
  "data": {
    "callsign": "W1AW",
    "totalQsos": 980,
    "rank": 240,
    "totalRanked": 110000,
    "state": "WY",
    "freshness": { ... }
  }
}

Errors

CodeHTTPWhen
(plain text)400callsign missing.

State stats

GET /v1/pota/stats/state/:state

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDescription
statestringyesState abbreviation. Upper-cased.

Response 200 OK. Top lists hold at most 10 entries.

{
  "data": {
    "state": "WY",
    "totalActivations": 5400,
    "uniqueActivators": 610,
    "totalQsos": 260000,
    "topActivators": [ { "callsign": "W7XYZ", "count": 310 } ],
    "topHunters": [ { "callsign": "K0ABC", "count": 1200 } ],
    "freshness": { ... }
  }
}

count is activations for topActivators and QSOs for topHunters. An unknown state returns zeros and empty lists.

Park stats

GET /v1/pota/stats/park/:reference

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDescription
referencestringyesPOTA reference (for example US-1234, K-1234). Upper-cased.

Response 200 OK

{
  "data": {
    "reference": "US-1234",
    "name": "Example State Park",
    "locationDesc": "US-WY",
    "state": "WY",
    "latitude": 42.86,
    "longitude": -106.31,
    "grid": "DN62",
    "active": true,
    "totalAttempts": 120,
    "totalActivations": 98,
    "totalQsos": 4300,
    "topActivators": [ { "callsign": "W7XYZ", "count": 12 } ],
    "topHunters": [ { "callsign": "K0ABC", "count": 40 } ],
    "freshness": { ... }
  }
}

locationDesc, state, latitude, longitude and grid are omitted when null.

Errors

CodeHTTPWhen
PARK_NOT_FOUND404Reference is not in the synced catalog. details: {"reference"}.

Activator rankings

GET /v1/pota/stats/rankings/activators

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDefaultDescription
statestringnoRestrict to parks in this state.
limitintno25Clamped to 1 to 100.
offsetintno0Negative values become 0.

Response 200 OK

{
  "data": {
    "rankings": [
      {
        "callsign": "W7XYZ",
        "activationCount": 310,
        "totalQsos": 14000,
        "qsosByMode": { "cw": 9000, "data": 2000, "phone": 3000 },
        "rank": 1
      }
    ],
    "totalRanked": 25000,
    "state": "WY",
    "freshness": { ... }
  }
}

POTA stats sync status

GET /v1/pota/stats/status

Auth: Public (optional bearer; no behaviour change). No parameters.

Response 200 OK

{
  "data": {
    "totalParks": 72000,
    "parksFetched": 71880,
    "parksPending": 120,
    "completionPercentage": 99,
    "oldestFetch": "2026-10-09T02:00:00Z",
    "newestFetch": "2026-10-10T13:55:00Z",
    "warning": "Data collection in progress (99% complete, 71880 of 72000 parks fetched). Stats may be incomplete."
  }
}

oldestFetch, newestFetch and warning are omitted when null. completionPercentage is an integer (rounded down).

Park and administrative boundaries

Park polygons for POTA references, US state and county polygons, and SOTA activation-zone polygons. Boundary responses are bare GeoJSON with no data wrapper, camelCase property names and no caching headers. Responses can be large; use simplify for viewport queries.

Park boundary feature shape

{
  "type": "Feature",
  "geometry": { "type": "MultiPolygon", "coordinates": [[[[-106.4, 42.8], [-106.3, 42.8], [-106.3, 42.9], [-106.4, 42.8]]]] },
  "properties": {
    "reference": "US-1234",
    "name": "Example State Park",
    "designation": "State Park",
    "manager": "Wyoming State Parks",
    "acreage": 1234.5,
    "matchQuality": "exact",
    "source": "padus"
  }
}

designation, manager and acreage are omitted when null. matchQuality is exact, spatial or manual; parks for which no polygon could be found are never returned. source tags the upstream dataset the polygon came from. Polygons come from PAD-US, Natural England and WDPA feature services, from state agencies for Florida, Oregon, California and Texas, and from the Polish GDOŚ WFS (national parks, landscape parks, reserves, protected landscape areas and Natura 2000 sites). Stored geometry is pre-simplified at a 0.001 degree tolerance. The collection runs on a 24-hour cycle and re-fetches a park’s polygon after 90 days.

List park boundaries

GET /v1/parks/boundaries

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDefaultDescription
refsstringone of refs, bboxComma-separated POTA references, upper-cased, blanks dropped. Maximum 20. Takes priority over bbox; limit and simplify are ignored in refs mode.
bboxstringone of refs, bboxwest,south,east,north in decimal degrees (WGS 84 / EPSG:4326). Non-numeric parts are dropped; exactly 4 numbers must remain. Matches parks whose stored geometry’s bounding box intersects the box.
limitintno50bbox mode only. Clamped to 1 to 100. Results are ordered by reference.
simplifynumbernobbox mode only. Douglas-Peucker tolerance in degrees, applied to the full-resolution geometry. Without it the stored pre-simplified geometry is returned.

Response 200 OK

{
  "type": "FeatureCollection",
  "features": [ { "type": "Feature", "geometry": { ... }, "properties": { ... } } ],
  "meta": { "matched": 2, "unmatchedRefs": ["US-9999"] }
}

meta.unmatchedRefs lists requested references with no polygon, and is populated only in refs mode; it is [] in bbox mode. meta.matched equals features.length.

Errors

CodeHTTPWhen
VALIDATION_ERROR400Neither parameter ("Either 'refs' or 'bbox' parameter is required"), more than 20 refs ("Maximum 20 refs per request"), or bbox not 4 numbers ("bbox must be 'west,south,east,north' (4 numbers)").
(plain text)400Non-numeric limit or simplify.

Get one park boundary

GET /v1/parks/boundaries/:reference

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDescription
referencestringyesPOTA reference. Upper-cased.

Response 200 OK, a single Feature (shape above) with full-resolution geometry.

Errors

CodeHTTPWhen
PARK_NOT_FOUND404No polygon is cached for the reference. details: {"reference"}.

Park boundaries status

GET /v1/parks/boundaries/status

Auth: Public (optional bearer; no behaviour change). No parameters.

Response 200 OK, {"data": ...} wrapped.

{
  "data": {
    "totalParks": 80500,
    "totalCached": 61000,
    "unfetched": 9000,
    "completionPercentage": 88,
    "byCountry": {
      "us": { "totalParks": 72000 },
      "uk": { "totalParks": 2600 },
      "it": { "totalParks": 2400 },
      "pl": { "totalParks": 3500 }
    },
    "bySource": { "padus": 55000, "natural_england": 1200 },
    "exactMatches": 50000,
    "spatialMatches": 10000,
    "manualMatches": 1000,
    "noMatches": 10500,
    "oldestFetch": "2026-07-01T00:00:00+00:00",
    "newestFetch": "2026-10-10T13:00:00+00:00"
  }
}

totalParks is the sum of the US, UK, IT and PL catalog counts. unfetched is totalParks - (totalCached + noMatches), and completionPercentage is (totalCached + noMatches) * 100 / totalParks. oldestFetch and newestFetch are RFC 3339 strings or null (not omitted). bySource keys are source tags; the values shown are illustrative.

List administrative boundaries

GET /v1/admin/boundaries

Auth: Public (optional bearer; no behaviour change). This path is public despite the /admin/ prefix; no operator token is needed.

ParamTypeRequiredDefaultDescription
bboxstringyeswest,south,east,north in decimal degrees (EPSG:4326). Matches boundaries whose stored geometry’s bounding box intersects the box.
kindstringnostate or county. Omitted or empty returns both.
limitintno500Clamped to 1 to 4000. Results are ordered by kind, then fips.
simplifynumbernoExtra Douglas-Peucker tolerance in degrees applied on top of the stored geometry, which is itself simplified at 0.002 degrees.

Response 200 OK, bare GeoJSON.

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": { "type": "MultiPolygon", "coordinates": [ ... ] },
      "properties": {
        "id": "county-06037",
        "kind": "county",
        "name": "Los Angeles",
        "stateName": "California",
        "fips": "06037"
      }
    }
  ],
  "meta": { "matched": 1 }
}

fips is 2 digits for states and 5 for counties. stateName is omitted when null, which is the case for state features.

Errors

CodeHTTPWhen
VALIDATION_ERROR400bbox missing ("'bbox' parameter is required"), not 4 numbers, or kind not state or county ("kind must be 'state' or 'county'").
(plain text)400Non-numeric limit or simplify.

Notes: The data is the US Census Bureau’s 1:500,000 cartographic boundary set (2023 vintage, public domain). It is loaded manually rather than by a background refresh, and changes rarely. No caching headers are set.

SOTA activation zones

POST /v1/sota/zones

Auth: Bearer (first-party). Submissions are attributed to the account and rate limited per account. The zones feed the coordinates on GET /v1/spots, which remains public.

Returns cached activation-zone polygons for the requested summits and queues computation for summits that have not been seen before. Zones are computed from Terrarium elevation tiles and cached permanently per summit code.

Request

{
  "summits": [
    { "code": "W7W/KI-001", "lat": 47.65, "lon": -121.85, "altitudeM": 1612.0 }
  ]
}
FieldTypeRequiredDescription
summits[].codestringyesSOTA reference PREFIX/REGION-NUMBER (alphanumeric prefix and region, numeric suffix), at most 16 characters. Used as the cache key.
summits[].latnumberyes-90 to 90.
summits[].lonnumberyes-180 to 180.
summits[].altitudeMnumbernoSummit altitude in metres. Stored with the zone.

Entries that fail validation are dropped without error, and the list is truncated to the first 400 valid summits. At most 8 not-yet-seen codes per request are queued for computation in the background.

Response 200 OK, bare GeoJSON.

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": { "type": "Polygon", "coordinates": [ [ [-121.86, 47.64], [-121.84, 47.64], [-121.84, 47.66], [-121.86, 47.64] ] ] },
      "properties": { "code": "W7W/KI-001", "altitudeM": 1612.0 }
    }
  ],
  "meta": { "matched": 1, "pending": ["W7W/KI-002"] }
}

features holds only zones whose computation has finished successfully. meta.pending lists codes queued by this request; re-request shortly to pick them up. Codes that computed to “no zone”, or that are still pending from an earlier request, appear in neither list.

Errors

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer.
VALIDATION_ERROR400Body is not valid JSON for the schema (for example missing summits).
INTERNAL_ERROR500Database failure while reading or queueing.

Notes: When zone computation is disabled on the server, new codes are not queued but cached zones are still returned. A transient computation failure is retried on a later request; a “no zone” result is cached permanently. Cached zones are not recomputed. 400 summits fit comfortably within the 1 MiB body limit.

Historic trails

National historic and scenic trails as GeoJSON MultiLineString features, with the same request contract as park boundaries. The catalog holds 19 trails sourced from the USGS National Map transportation layer and the NTIR per-trail feature services. Collection runs weekly and re-fetches a trail after 180 days. Stored geometry is simplified at a 0.005 degree tolerance. Responses are bare GeoJSON with no caching headers.

Trail feature shape

{
  "type": "Feature",
  "geometry": { "type": "MultiLineString", "coordinates": [ ... ] },
  "properties": {
    "reference": "NHT-OREG",
    "name": "Oregon National Historic Trail",
    "designation": "National Historic Trail",
    "managingAgency": "National Park Service",
    "lengthMiles": 2170.0,
    "state": "MO,KS,NE,WY,ID,OR",
    "matchQuality": "exact",
    "source": "usgs_national_map"
  }
}

designation, managingAgency, lengthMiles and state are omitted when null. References follow the NHT-XXXX scheme. source tags the upstream dataset.

List trails

GET /v1/trails

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDefaultDescription
refsstringone of refs, bboxComma-separated trail references, upper-cased. Maximum 20. Takes priority over bbox; limit and simplify are ignored.
bboxstringone of refs, bboxwest,south,east,north in decimal degrees. Bounding-box intersection against the stored geometry.
limitintno50bbox mode only. Clamped to 1 to 100. Ordered by reference.
simplifynumbernobbox mode only. Tolerance in degrees, applied to the full-resolution geometry.

Response 200 OK: { "type": "FeatureCollection", "features": [...], "meta": { "matched": n, "unmatchedRefs": [...] } }.

Errors

CodeHTTPWhen
VALIDATION_ERROR400Neither refs nor bbox; more than 20 refs; bbox not 4 numbers.

Get one trail

GET /v1/trails/:reference

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDescription
referencestringyesTrail reference. Upper-cased.

Response 200 OK, a single Feature with full-resolution geometry.

Errors

CodeHTTPWhen
TRAIL_NOT_FOUND404No geometry is cached for the reference. details: {"reference"}.

Trails status

GET /v1/trails/status

Auth: Public (optional bearer; no behaviour change). No parameters. The same data is also served at the operator-only GET /v1/admin/trails/status.

Response 200 OK, {"data": ...} wrapped.

{
  "data": {
    "totalCatalog": 19,
    "totalCached": 18,
    "unfetched": 1,
    "completionPercentage": 94,
    "exactMatches": 16,
    "spatialMatches": 1,
    "manualMatches": 1,
    "oldestFetch": "2026-09-01T00:00:00+00:00",
    "newestFetch": "2026-10-08T00:00:00+00:00"
  }
}

Unlike the park status there is no noMatches field; unfetched is totalCatalog - totalCached. Fetch timestamps are RFC 3339 strings or null.

Satellites

AMSAT status

GET /v1/satellites/amsat-status

Auth: Public (optional bearer; no behaviour change). No parameters.

A mirror of the AMSAT status page (https://www.amsat.org/status/), refreshed every 30 minutes. Up to 150 satellites are processed per cycle, one row per transmitter name. The *24h counters cover reports in the last 24 hours, drawn from a 168-hour report window.

Response 200 OK, bare object with camelCase fields.

{
  "satellites": [
    {
      "name": "SO-50_[FM]",
      "noradId": 27607,
      "lastHeardAt": "2026-10-10T13:30:00Z",
      "lastReportedAt": "2026-10-10T14:30:00Z",
      "lastReport": "Heard",
      "lastCallsign": "ON7VQ",
      "lastGrid": "JO20gv",
      "heardReports24h": 7,
      "totalReports24h": 9
    }
  ],
  "updatedAt": "2026-10-10T15:00:12.412Z"
}

noradId, lastHeardAt, lastReportedAt, lastReport, lastCallsign, lastGrid and the top-level updatedAt are omitted when null. updatedAt is the time of the most recent update across all satellites. No caching headers are set.

Errors

CodeHTTPWhen
INTERNAL_ERROR500Database failure.

Callsign enrichment

Club membership lookups keyed by callsign. The only source today is the CWops membership roster, refreshed every 24 hours. Field names in this group are snake_case, and the bulk dataset uses single-character keys to keep it small.

Get the enrichment dataset

GET /v1/callsign-enrichment/dataset

Auth: Public (optional bearer; no behaviour change). No parameters.

Caching: the response carries ETag: "cwe-<unix milliseconds of the most recent source fetch>" and Cache-Control: public, max-age=3600. Send the ETag back in If-None-Match to receive 304 Not Modified with an empty body and the same ETag header. The comparison is an exact string match: weak validators and comma-separated lists are not parsed. Until the first source sync has completed the ETag is derived from the current time and changes on every request.

Response 200 OK with Content-Type: application/json.

{
  "sources": [
    { "slug": "cwops", "name": "CWops", "emoji": "🎹", "last_fetched_at": "2026-10-10T12:00:00Z" }
  ],
  "callsigns": [
    { "c": "AA0FO", "m": [ { "s": "cwops", "n": "MARSHALL R", "#": "1532" } ] }
  ],
  "generated_at": "2026-10-10T12:00:00Z"
}
KeyMeaning
sources[].slug, name, emoji, last_fetched_atPer-source metadata. emoji and last_fetched_at are nullable and serialised as null.
callsigns[].cUpper-case callsign.
callsigns[].m[]Memberships: s source slug (always present), n name, # member number (digits), e per-row emoji override, t free-form note. n, #, e and t are omitted when null.
generated_atThe instant the ETag is derived from.

Errors

CodeHTTPWhen
INTERNAL_ERROR500Database or serialisation failure.

Look up one callsign

GET /v1/callsign-enrichment/:callsign

Auth: Public (optional bearer; no behaviour change).

ParamTypeRequiredDescription
callsignstringyesUpper-cased before lookup.

Response 200 OK. Nullable fields are serialised as null, not omitted.

{
  "callsign": "AA0FO",
  "memberships": [
    {
      "source_slug": "cwops",
      "source_name": "CWops",
      "emoji": "🎹",
      "name": "MARSHALL R",
      "member_number": "1532",
      "note": null
    }
  ]
}

emoji is the per-member override when present, otherwise the source’s emoji. An unknown callsign returns 200 with memberships: [], never 404. No caching headers are set.

Notes: dataset is reserved and is never treated as a callsign.

Propagation predictions

HF propagation predictions from a DVOACAP-based prediction service that runs alongside the API. These endpoints take no credentials: no bearer or operator token is read, and only the global rate limit applies. When predictions are disabled on the server the routes are not mounted and answer 404 with {"error": {"code": "NOT_FOUND", "message": "Endpoint not found"}}.

Both endpoints share the same proxy and cache behaviour:

  1. The request is parsed, defaults are filled in, and the result is serialised to canonical JSON with sorted keys.
  2. The canonical body is the cache key, so omitting tx_power_w and sending 100 hit the same cache entry.
  3. A cached result younger than 24 hours is returned as-is.
  4. Otherwise the request is forwarded to the prediction service with a 30 s timeout. A 422 from the prediction service becomes 400 VALIDATION_ERROR whose message is the service’s response text. Any other non-2xx status or transport error becomes 500 INTERNAL_ERROR.
  5. Successful results are cached for 24 hours.

Field names are snake_case. Malformed JSON bodies answer with a plain-text 400, 415 or 422.

Point-to-point prediction

POST /v1/propagation/p2p

Auth: None.

Request

{
  "tx_lat": 42.0, "tx_lon": -71.0,
  "rx_lat": 51.5, "rx_lon": -0.1,
  "month": 4,
  "utc_hour": 14.0,
  "ssn": 120,
  "tx_power_w": 100,
  "frequencies_mhz": [3.5, 7.0, 14.1, 21.0, 28.0]
}
FieldTypeRequiredDefaultDescription
tx_lat, tx_lon, rx_lat, rx_lonnumberyesDegrees.
monthintegeryes1 to 12.
utc_hournumberyesHour of day UTC. Fractional values are allowed.
ssnnumberyesSmoothed sunspot number.
tx_power_wnumberno100.0Transmit power in watts.
frequencies_mhznumber[]noFrequencies to evaluate in MHz. When omitted the prediction service uses its default band set.

Response 200 OK, bare object. Nullable fields are serialised as null.

{
  "muf_mhz": 22.0,
  "path": { "distance_km": 5272.1, "bearing_deg": 49.3 },
  "bands": [
    {
      "freq_mhz": 14.1,
      "hop_count": 2,
      "tx_elevation_deg": 12.4,
      "total_loss_db": 132.7,
      "snr_db": 6.1,
      "reliability": 0.71,
      "service_prob": 0.68,
      "multipath_prob": 0.22
    }
  ]
}

Errors

CodeHTTPWhen
(plain text)400 / 415 / 422Body missing, wrong content type, or not matching the schema.
VALIDATION_ERROR400The prediction service rejected the inputs. Its message is passed through as message.
INTERNAL_ERROR500Prediction service unreachable or timed out, a non-2xx status other than 422, or an undecodable response.
NOT_FOUND404Predictions are disabled on the server.

Propagation wheel

POST /v1/propagation/wheel

Auth: None.

Predicts outward from a centre point along evenly spaced azimuths.

Request

{
  "lat": 42.0, "lon": -71.0,
  "month": 4, "utc_hour": 14.0, "ssn": 120,
  "tx_power_w": 100,
  "radius_km": 5000,
  "azimuth_step_deg": 15,
  "frequencies_mhz": [7.0, 14.1, 21.0]
}
FieldTypeRequiredDefaultDescription
lat, lonnumberyesCentre, in degrees.
month, utc_hour, ssnas p2pyes
tx_power_wnumberno100.0Transmit power in watts.
radius_kmnumberno5000.0Distance of each spoke’s receive point, in km.
azimuth_step_degnumberno15.0Spacing between spokes in degrees. The default gives 24 spokes.
frequencies_mhznumber[]noFrequencies to evaluate in MHz.

Response 200 OK

{
  "centre_lat": 42.0,
  "centre_lon": -71.0,
  "radius_km": 5000.0,
  "spokes": [
    { "azimuth_deg": 0.0, "rx_lat": 86.9, "rx_lon": -71.0, "bands": [ /* same band objects as p2p */ ] }
  ]
}

Errors: identical to the point-to-point endpoint.

Operator endpoints

These require an operator token and are not available to third-party clients.

MethodPathPurpose
DELETE/v1/admin/spots/:idDelete any spot regardless of source or owner. Aggregated POTA and SOTA spots reappear on the next poll if upstream still lists them.
GET/v1/admin/trails/statusSame response as GET /v1/trails/status.

Events

Community-submitted on-air events: club meetings, hamfests, nets, field days and similar. Anyone can search approved events near a location. Signed-in users submit events, and an operator reviews each one before it appears publicly.

All event JSON uses camelCase, including query parameters.

Review lifecycle

status is one of pending, approved or rejected.

  • A new submission starts as pending.
  • Only an operator moves an event to approved or rejected. Review is allowed from any status, so a rejected event can later be approved and an approved event can be rejected. On review the server sets reviewedBy to "admin" and reviewedAt to the current time. A rejection stores rejectionReason; an approval resets it to null.
  • When the submitter edits an approved event and changes a key field (name, description, address, venueName, latitude or longitude), the event returns to pending and reviewedBy and reviewedAt are cleared. Sending a value identical to the stored one does not trigger re-review. Changing dates, timezone, cost, url, city, state, country or eventType keeps the current status.
  • A user edit never clears rejectionReason, and a rejected event stays rejected after an edit. There is no resubmit action. Delete and recreate the event, or ask an operator to re-review it.
  • Operator edits never change status.
  • Events do not expire automatically.

Shapes

List endpoints return a compact item:

id, name, eventType, startDate, endDate, timezone, venueName, city, state, country, latitude, longitude, cost, submittedBy, status, createdAt, distanceMeters

Single-event endpoints return the full event:

id, name, description, eventType, startDate, endDate, timezone, venueName, address, city, state, country, latitude, longitude, cost, url, submittedBy, status, reviewedBy, reviewedAt, rejectionReason, createdAt, updatedAt, plus days

days is omitted entirely when the event has no per-day schedule. When present it is ordered by date ascending.

eventType is one of club_meeting, swap_meet, field_day, special_event, hamfest, net, other.

List events near a location

GET /v1/events

Auth: Public (optional bearer)

ParamTypeRequiredDescription
latfloatyesLatitude, -90 to 90
lonfloatyesLongitude, -180 to 180
radiusKmfloatyesSearch radius in kilometres, 1 to 500
eventTypestringnoExact match on one of the event types above. Unknown values match nothing.
fromDateRFC 3339 datetimenoOnly events with startDate >= fromDate
toDateRFC 3339 datetimenoOnly events with startDate <= toDate
includePastboolnoDefault false: only events whose startDate is now or later
limitintnoDefault 50, maximum 100
offsetintnoDefault 0

Only approved events are returned, ordered by startDate ascending. A bearer token does not change the result.

Response 200:

{
  "data": {
    "events": [
      {
        "id": "4b6a1c0e-…",
        "name": "Monthly Club Meeting",
        "eventType": "club_meeting",
        "startDate": "2026-11-01T19:00:00Z",
        "endDate": "2026-11-01T21:00:00Z",
        "timezone": "America/New_York",
        "venueName": "VFW Hall",
        "city": "Springfield",
        "state": "MA",
        "country": "US",
        "latitude": 42.1015,
        "longitude": -72.5898,
        "cost": "Free",
        "submittedBy": "W1ABC",
        "status": "approved",
        "createdAt": "2026-10-01T12:00:00Z",
        "distanceMeters": 1234.56
      }
    ],
    "total": 5,
    "limit": 50,
    "offset": 0
  }
}

total is the number of events matching the same filters, independent of limit and offset.

Errors

CodeHTTPWhen
VALIDATION_ERROR400lat, lon or radiusKm out of range
(plain text)400lat, lon or radiusKm missing or unparsable

Get one approved event

GET /v1/events/:id

Auth: Public

ParamTypeRequiredDescription
idUUID (path)yesEvent id. A non-UUID returns 400 VALIDATION_ERROR.

Response 200:

{
  "data": {
    "id": "4b6a1c0e-…",
    "name": "Monthly Club Meeting",
    "description": "Monthly meeting of the Springfield ARC",
    "eventType": "club_meeting",
    "startDate": "2026-11-01T19:00:00Z",
    "endDate": "2026-11-01T21:00:00Z",
    "timezone": "America/New_York",
    "venueName": "VFW Hall",
    "address": "123 Main St",
    "city": "Springfield",
    "state": "MA",
    "country": "US",
    "latitude": 42.1015,
    "longitude": -72.5898,
    "cost": "Free",
    "url": "https://example.com/meeting",
    "submittedBy": "W1ABC",
    "status": "approved",
    "reviewedBy": "admin",
    "reviewedAt": "2026-10-02T09:00:00Z",
    "rejectionReason": null,
    "createdAt": "2026-10-01T12:00:00Z",
    "updatedAt": "2026-10-02T09:00:00Z",
    "days": [
      { "id": "…", "date": "2026-11-01", "startTime": "2026-11-01T19:00:00Z", "endTime": "2026-11-01T21:00:00Z" }
    ]
  }
}

Errors

CodeHTTPWhen
EVENT_NOT_FOUND404No such id, or the event is not approved. details.eventId holds the id.

Submit an event

POST /v1/events

Auth: Bearer (first-party)

Request:

{
  "name": "Monthly Club Meeting",
  "description": "Monthly meeting of the Springfield ARC",
  "eventType": "club_meeting",
  "startDate": "2026-11-01T19:00:00Z",
  "endDate": "2026-11-01T21:00:00Z",
  "timezone": "America/New_York",
  "venueName": "VFW Hall",
  "address": "123 Main St",
  "city": "Springfield",
  "state": "MA",
  "country": "US",
  "latitude": 42.1015,
  "longitude": -72.5898,
  "cost": "Free",
  "url": "https://example.com/meeting",
  "days": [
    { "date": "2026-11-01", "startTime": "2026-11-01T19:00:00Z", "endTime": "2026-11-01T21:00:00Z" }
  ]
}

Required: name, eventType, startDate, timezone, address, city, country, latitude, longitude. Optional: description, endDate, venueName, state, cost, url, days. Each days[] item needs date (YYYY-MM-DD) and startTime; endTime is optional.

Validation: eventType must be one of the seven allowed values; coordinates must be in range; endDate, when given, must be strictly after startDate; the submitter must have fewer than 10 pending events. Length limits: name 200 characters, description 2000, venueName 200, cost 100. Exceeding a length limit returns 500 INTERNAL_ERROR rather than 400.

Response 201 Created with the full event under data. status is "pending", submittedBy is the callsign of the token’s user, and days is included when supplied and non-empty.

Errors

CodeHTTPWhen
VALIDATION_ERROR400Bad eventType, coordinates out of range, endDate not after startDate, malformed JSON
MAX_PENDING_EVENTS429The caller already has 10 or more pending events
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Third-party token
INTERNAL_ERROR500A text field exceeds its length limit

Edit own event

PUT /v1/events/:id

Auth: Bearer (first-party)

Request: the same fields as submit, every one optional. Omitted fields are left unchanged, so you cannot clear description, endDate, venueName, state, cost or url through this endpoint. If days is present, even as [], the existing schedule is deleted and replaced.

Validation: coordinates and eventType are checked when present. endDate is not checked against startDate on update.

Response 200 with the full event under data, including the current days.

Errors

CodeHTTPWhen
EVENT_NOT_FOUND404No such id
EVENT_NOT_OWNED403The caller is not the submitter
VALIDATION_ERROR400Bad coordinates, eventType or JSON

Notes. The review lifecycle above applies: changing a key field of an approved event sends it back to pending.

Delete own event

DELETE /v1/events/:id

Auth: Bearer (first-party)

Response 204 No Content. The event’s day schedule is deleted with it.

Errors

CodeHTTPWhen
EVENT_NOT_FOUND404No such id
EVENT_NOT_OWNED403The caller is not the submitter

List my events

GET /v1/events/mine

Auth: Bearer (first-party)

ParamTypeRequiredDescription
limitintnoDefault 50, maximum 100
offsetintnoDefault 0

Returns the caller’s events in every status, newest first.

Response 200:

{ "data": { "events": [ { "…": "list item fields", "distanceMeters": null } ] } }

There is no total, limit or offset echo on this endpoint. List items do not include rejectionReason, and GET /v1/events/:id returns 404 for events that are not approved.

Operator endpoints

These require the operator token and are not reachable with any OAuth token.

MethodPathPurpose
GET/v1/admin/eventsList events in any status (status, limit max 100, offset; omitted status means all), oldest first
GET/v1/admin/events/:idGet any event regardless of status, with rejectionReason and days
PUT/v1/admin/events/:idEdit any event; never changes status or review fields
PUT/v1/admin/events/:id/reviewApprove ({"action": "approve"}) or reject ({"action": "reject", "reason": "…"}) from any status
DELETE/v1/admin/events/:idPermanently delete an event
GET/v1/admin/events/submitter/:callsignSubmission counts for one callsign (totalSubmitted, totalApproved, totalRejected, totalPending)

Equipment catalog

A curated catalog of radios, antennas, keys, microphones and accessories that clients download and cache. Anyone can search it, fetch it in full or as a delta, and submit a new entry for operator review. Clients also report anonymous equipment-usage telemetry.

Equipment JSON uses snake_case (max_power_watts, image_url). category is one of radio, antenna, key, microphone, accessory. portability is one of pocket, backpack, portable, mobile, base.

Entry shape

{
  "id": "icom-ic-705",
  "name": "IC-705",
  "manufacturer": "Icom",
  "category": "radio",
  "bands": ["160m", "80m", "40m", "20m", "2m", "70cm"],
  "modes": ["CW", "SSB", "FM", "AM", "RTTY"],
  "max_power_watts": 10,
  "portability": "backpack",
  "weight_grams": 1100,
  "description": "…",
  "aliases": ["705"],
  "image_url": "https://…",
  "antenna_connector": "BNC",
  "power_connector": "DC barrel",
  "key_jack": "3.5mm",
  "mic_jack": "RJ45"
}

max_power_watts, weight_grams, description, image_url, antenna_connector, power_connector, key_jack and mic_jack are omitted when they have no value. id is a free-form string chosen by the operator when the entry is created.

Submission review

A submission is pending until an operator reviews it, after which it is approved or rejected. Review can be repeated from any status. A review may link the submission to an existing catalog entry by catalog_id, but it does not create a catalog entry. Operators add entries separately.

Get the catalog, in full or as a delta

GET /v1/equipment/catalog

Auth: Public

ParamTypeRequiredDescription
sinceRFC 3339 datetimenoReturn only entries updated after this time

Caching. The response carries ETag: "equipment-<count>-<max_updated_at_ms>" and Cache-Control: public, max-age=86400. If the request’s If-None-Match header equals the ETag exactly (plain string comparison, no weak-match handling), the server answers 304 with an empty body. The ETag is computed from the whole catalog even when since is supplied, so a delta request can also receive 304.

Response 200 (no data wrapper):

{
  "version": 412,
  "updated_at": "2026-09-30T18:04:11Z",
  "entries": [ { "…": "entry" } ]
}

version is the number of entries in the full catalog. updated_at is the most recent entry update, or the current time for an empty catalog. Entries are ordered by name ascending. Deletions are not signalled in a delta: a client that only ever fetches with since never learns about removed ids. Refetch the full catalog to pick up removals.

Errors: none specific. 500 INTERNAL_ERROR on a server failure.

GET /v1/equipment/search

Auth: Public

ParamTypeRequiredDescription
qstringyesNon-blank search text. Fuzzy-matched against the name, the manufacturer and name together, and the aliases. Matches with similarity at or below 0.15 are excluded.
categorystringnoExact match filter
limitintnoDefault 5, clamped to 1 to 20

Response 200:

{
  "results": [
    { "entry": { "…": "entry" }, "confidence": 0.63, "matched_field": "name" }
  ]
}

confidence is the best similarity score rounded to two decimal places. matched_field is "name" or "alias".

Errors

CodeHTTPWhen
VALIDATION_ERROR400q is blank
(plain text)400q is missing

Submit equipment for review

POST /v1/equipment/submissions

Auth: Public

Submissions are anonymous. Nothing ties a submission to a user account. The server records the client’s IP address and User-Agent header with the submission.

Request:

{
  "name": "QMX+",
  "manufacturer": "QRP Labs",
  "category": "radio",
  "bands": ["80m", "40m", "20m"],
  "modes": ["CW", "FT8"],
  "max_power_watts": 5,
  "portability": "pocket",
  "weight_grams": 300
}

Required: name, category. Defaults: manufacturer: "", bands: [], modes: [], portability: "portable". category and portability must be from the lists above.

Response 201:

{ "id": "<uuid>", "status": "pending" }

Errors

CodeHTTPWhen
VALIDATION_ERROR400Bad category or portability, malformed JSON

Usage telemetry

Report equipment usage

POST /v1/telemetry/equipment-usage

Auth: Public

Intended for Carrier Wave’s own apps. Reports are anonymous: no callsign or device identifier is sent or stored.

Request:

{
  "metadata": { "app_version": "1.9.0", "os_version": "19.0" },
  "usage": [
    {
      "catalog_id": "icom-ic-705",
      "category": "radio",
      "is_custom": false,
      "custom_name": null,
      "custom_manufacturer": null,
      "custom_bands": [],
      "custom_modes": [],
      "custom_max_power_watts": null,
      "custom_portability": null,
      "session_mode": "CW",
      "session_band": "20m",
      "session_program": "pota",
      "paired_with": ["elecraft-ax1"]
    }
  ]
}

metadata (object) and usage (array) are required. Inside metadata both fields are optional; os_version is accepted but not stored. Per entry, catalog_id and category are required; is_custom defaults to false and custom_bands, custom_modes and paired_with default to []. Field values are not validated. A batch of more than 50 entries is silently truncated to the first 50.

Response 201 with {"accepted": <n>}. An empty usage array returns 200 with {"accepted": 0}.

Operator endpoints

These require the operator token and are not reachable with any OAuth token.

MethodPathPurpose
POST/v1/admin/equipmentCreate a catalog entry (id, name, manufacturer, category required)
PUT/v1/admin/equipment/:idPartially update an entry; optional fields can be cleared by sending null
DELETE/v1/admin/equipment/:idDelete an entry
GET/v1/admin/equipment/submissionsList submissions (status, limit max 200), newest first
PUT/v1/admin/equipment/submissions/:id/reviewApprove or reject a submission, optionally linking a catalog_id

Radio macros

A registry of .cwmacro documents: small, declarative scripts that configure a radio (set frequency, set mode, send CW, and so on). Clients list and download approved macros, check for newer versions in bulk, and learn which macros to remove through tombstones.

Public reads only ever see approved macros. Macro JSON uses snake_case (applies_to, author_callsign, published_at), except inside the capabilities, args and steps objects, which are camelCase.

Unknown or unapproved slugs return 400 VALIDATION_ERROR, not 404.

Slugs, versions and tombstones

  • A slug is lowercase ASCII letters, digits and hyphens, at most 64 characters, with no leading, trailing or doubled hyphen.
  • Slugs are permanent. Every publish of a slug bumps its version and the full document for each version is kept.
  • A macro’s status is one of approved, pending, withdrawn, tombstoned or author_deleted. Only approved macros are visible to clients.
  • When an operator sets a macro to withdrawn, tombstoned or author_deleted, the slug keeps its registry entry and a tombstone is published with reason withdrawn, admin_removed or author_deleted respectively. Clients poll the tombstone list to remove macros they have cached.
  • A withdrawn slug can be republished as approved. The tombstone entry is not removed when that happens.
  • A permanent delete removes the macro, its version history, its run telemetry and its tombstone, so a deleted slug never appears in the tombstone list. Operators are advised to withdraw rather than delete.

Document format

A macro document has name, icon, description, tags, applies_to (radio model identifiers), capabilities, args and steps.

capabilities has the boolean fields transmits, changesFrequency, changesMode, changesPower and usesRawBytes.

Each args[] item has name and type, where type is one of frequency, integer, string, bool, mode. A frequency arg carries min and max in hertz, with min < max, within 100 kHz to 250 GHz.

Each steps[] item is tagged by kind:

kindFields
delaydurationMs
setFrequencyhz
setModemode
setPowerWattswatts
setPTTon
sendCWtext
vendorCommandcommandId, optional args
rawBytesprotocol, bytes
readFieldfield, into
waitUntilexpr, timeoutMs
assertexpr, message

format is the document format version. A client that understands format 2 can pass max_format=2 to the list endpoint to hide newer documents.

Tags are at most 8 per macro, normalised to lowercase, 2 to 24 characters of [a-z0-9-].

List approved macros

GET /v1/macros

Auth: Public

ParamTypeRequiredDescription
tagstringnoExact match on one tag
applies_tostringnoExact match on one radio model identifier
searchstringnoCase-insensitive substring match over name, slug and description
max_formatintnoOnly macros with format <= max_format
limitintnoDefault 50, clamped to 1 to 200
offsetintnoDefault 0

Ordered by published_at, newest first.

Response 200:

{
  "entries": [
    {
      "slug": "kx2-cw-setup",
      "name": "KX2 CW setup",
      "icon": "waveform",
      "description": "…",
      "tags": ["cw", "kx2", "elecraft"],
      "applies_to": ["elecraft-kx2"],
      "capabilities": { "transmits": false, "changesFrequency": true, "changesMode": true, "changesPower": false, "usesRawBytes": false },
      "format": 2,
      "version": 3,
      "author_callsign": "W6JY",
      "published_at": "2026-09-01T00:00:00Z"
    }
  ]
}

Bulk version check

GET /v1/macros/versions

Auth: Public

ParamTypeRequiredDescription
slugsstringyesComma-separated slugs. Whitespace is trimmed and empty items dropped. More than 200 slugs returns 400.

Response 200, an object keyed by slug. Only approved slugs appear; withdrawn and unknown slugs are simply absent:

{ "kx2-cw-setup": { "version": 3, "format": 2 }, "k3-tuner-cycle": { "version": 1, "format": 2 } }

Errors

CodeHTTPWhen
VALIDATION_ERROR400More than 200 slugs

Tombstones

GET /v1/macros/tombstones

Auth: Public

ParamTypeRequiredDescription
sinceRFC 3339 datetimenoOnly tombstones created after this time. Omitted means all.

Response 200, ordered by tombstoned_at ascending:

{ "entries": [ { "slug": "old-macro", "reason": "withdrawn", "tombstoned_at": "2026-08-10T12:00:00Z" } ] }

reason is withdrawn, admin_removed or author_deleted.

Get a macro document

GET /v1/macros/:slug

Auth: Public

Response 200:

{
  "slug": "kx2-cw-setup",
  "spec": {
    "name": "KX2 CW setup",
    "icon": "waveform",
    "description": "…",
    "tags": ["cw", "kx2"],
    "applies_to": ["elecraft-kx2"],
    "capabilities": { "transmits": false, "changesFrequency": true, "changesMode": true, "changesPower": false, "usesRawBytes": false },
    "args": [ { "name": "freq", "type": "frequency", "min": 14000000, "max": 14350000 } ],
    "steps": [ { "kind": "setFrequency", "hz": 14060000 }, { "kind": "setMode", "mode": "CW" } ]
  },
  "format": 2,
  "version": 3,
  "published_at": "2026-09-01T00:00:00Z",
  "updated_at": "2026-09-01T00:00:00Z",
  "author_callsign": "W6JY"
}

Errors

CodeHTTPWhen
VALIDATION_ERROR400Slug unknown or not approved. The message is macro not found: <slug>.

Version history for one slug

GET /v1/macros/:slug/versions

Auth: Public

Response 200, newest first:

{ "versions": [ { "version": 3, "published_at": "…" }, { "version": 2, "published_at": "…" } ] }

Errors

CodeHTTPWhen
VALIDATION_ERROR400Slug is not currently approved

Report a run event

POST /v1/macros/:slug/run-event

Auth: Bearer (first-party)

Intended for Carrier Wave’s own apps.

Request:

{ "success": false, "failed_step_index": 2, "error_code": "ptt-stuck-high", "occurred_at": "2026-10-10T14:00:00Z" }

success is required; the other three fields are optional. occurred_at is honoured only if it is within 48 hours of server time, otherwise the server substitutes the current time.

Response 204 No Content.

Rate limit. 60 run events per minute per user. Excess requests receive 429 RATE_LIMITED.

Privacy. The server does not store the user’s identity with a run event. It stores a keyed hash of the user and slug that rotates daily, so events cannot be tied to a user across days or across macros.

Retention. Raw run events are folded into daily aggregates and deleted after 90 days.

Errors

CodeHTTPWhen
RATE_LIMITED429More than 60 events per minute for this user
VALIDATION_ERROR400Slug not approved (macro not found or not approved), malformed JSON
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Third-party token

Operator endpoints

These require the operator token and are not reachable with any OAuth token.

MethodPathPurpose
POST/v1/admin/macrosPublish a macro or bump an existing slug (201 when new, 200 when bumped); validates slug, tags, band plan and the step verifier
GET/v1/admin/macrosList macros in every status (status, limit max 200, offset)
PUT/v1/admin/macros/:slugReplace a macro; body slug must equal the path slug; creates if absent
PATCH/v1/admin/macros/:slugChange status and optionally moderator_notes; writes a tombstone for withdrawn, tombstoned and author_deleted
DELETE/v1/admin/macros/:slugPermanently delete a macro, its history and its tombstone

Sync: the end-to-end encrypted journal

Carrier Wave Sync keeps a user’s data in step across devices. Each device appends encrypted journal entries; other devices pull them, and periodically a device folds the journal into a single encrypted snapshot. The server is blind. It stores only metadata about each entry (its sequence number, entity type, operation, byte size, content hash and blob key) and hands out presigned URLs so clients move ciphertext directly to and from blob storage. The server never reads, parses or decrypts blob contents, and it cannot unwrap the user’s data key.

This is the only data surface a third-party OAuth token can reach. All Sync JSON uses camelCase.

Data model

  • Tenant. The tenant is the account behind the bearer token, the unified account created by email sign-in. Clients never choose or see a tenant id beyond its appearance in blob keys. Legacy device tokens have no account and receive 403 ACCOUNT_REQUIRED on every Sync route.
  • Sequence numbers. Each tenant has its own 64-bit sequence. Sequence numbers are gap-free and strictly increasing. highWater is the highest sequence number assigned so far. A push reserves a contiguous block of sequence numbers atomically.
  • Entries. One entry per journal payload, identified by its sequence number within the tenant. Each entry carries a clientEntryId, which is unique per tenant and acts as the idempotency key for pushes. The entry’s ciphertext lives at blob key <tenant>/entries/<seq>.bin. entityType (for example "qso") and op (for example "upsert" or "delete") are free-form strings the server stores and returns but never interprets.
  • Cursor. Per tenant, the server tracks highWater and the live snapshot pointer: snapshotSeq and the snapshot’s blob key.
  • Snapshots. A snapshot folds the journal up to a sequence number into one blob at <tenant>/snapshots/<seq>-<snapshotId>.bin. Snapshots are two-phase. Phase 1 registers a pending snapshot and returns a presigned upload URL. Phase 2 commits it: the server checks that the object was uploaded, moves the snapshot pointer forward (never backward), and then garbage-collects entries with seq < snapshotSeq together with the previous snapshot blob.
  • Key envelope. The tenant’s data key, wrapped with an AEAD under a passphrase-derived key, plus opaque Argon2id kdfParams. The server stores and returns the envelope but cannot unwrap it.
  • Bootstrap. The live snapshot pointer plus the entries after it. A fresh device downloads one snapshot blob and a short tail of entries.
  • Blobs. Every upload and download of ciphertext goes through a presigned URL returned by the API. Presigned URLs are valid for 15 minutes. A pending snapshot may be committed for 30 minutes after it is begun. The default page size is 500 entries.

Authentication and scopes

All seven Sync routes require a bearer token. They are the only routes outside identity that a third-party OAuth token may call:

RoutesRequired scope
GET /v1/sync/bootstrap, GET /v1/sync/changes, GET /v1/sync/keysync:read
POST /v1/sync/push, POST /v1/sync/snapshot, POST /v1/sync/snapshot/commit, PUT /v1/sync/keysync:write

A third-party token without the required scope receives 403 FORBIDDEN. First-party tokens need no scope.

These errors can occur on any Sync route:

CodeHTTPWhen
INVALID_TOKEN401Missing or invalid bearer token
ACCOUNT_REQUIRED403The token belongs to a legacy device identity with no account
FORBIDDEN403Third-party token lacking the required scope
VALIDATION_ERROR400Malformed JSON or a missing required field
STORAGE_ERROR500The server could not presign a URL or reach blob storage

Typical client flow

  1. On a new device, GET /v1/sync/key and unwrap the data key with the passphrase-derived key. If the response is 404, no envelope exists for this account yet; the client that creates one stores it with PUT /v1/sync/key.
  2. GET /v1/sync/bootstrap. Download and decrypt the snapshot blob if snapshotUrl is set, then apply the returned entries. If 500 entries came back, continue with GET /v1/sync/changes?since=<last seq> until entries is empty or the last seq equals highWater.
  3. To write, encrypt each payload, POST /v1/sync/push with its metadata, then PUT the ciphertext to each returned uploadUrl before the URL expires.
  4. To read, poll GET /v1/sync/changes?since=<last seq> and download each downloadUrl.
  5. Periodically, fold the journal into a snapshot: POST /v1/sync/snapshot, PUT the snapshot blob to uploadUrl, then POST /v1/sync/snapshot/commit.

Push entries

POST /v1/sync/push

Auth: Bearer (third-party allowed, scope sync:write)

Request:

{
  "entries": [
    { "clientEntryId": "6f1c…", "entityType": "qso", "op": "upsert", "byteSize": 1832, "contentHash": "sha256-hex" }
  ]
}

All five fields are required on every entry. Values are not validated.

Behaviour. Entries whose clientEntryId already exists for this tenant are not assigned a new sequence number; they return their original seq and blobKey, so re-pushing after a failure is idempotent. Only new ids consume sequence numbers, as one contiguous block. If two devices push the same clientEntryId concurrently, both receive the same authoritative seq. An upload URL is presigned for every entry in the request, including already-stored ones, so a client can retry a blob upload that failed the first time.

Response 200, entries in request order:

{
  "entries": [
    { "clientEntryId": "6f1c…", "seq": 125, "blobKey": "<tenant>/entries/125.bin", "uploadUrl": "https://…" }
  ],
  "highWater": 125
}

The client must then PUT the ciphertext to each uploadUrl within 15 minutes. The server does not verify that entry blobs were uploaded; only snapshot uploads are checked, at commit.

Errors: the common Sync errors above.

Pull changes

GET /v1/sync/changes

Auth: Bearer (third-party allowed, scope sync:read)

ParamTypeRequiredDescription
sinceint64noReturn entries with seq > since. Default 0.
limitint64noDefault 500, clamped to 1 to 1000

Response 200:

{
  "entries": [
    { "seq": 121, "entityType": "qso", "op": "upsert", "blobKey": "<tenant>/entries/121.bin", "byteSize": 1832, "contentHash": "sha256-hex", "downloadUrl": "https://…" }
  ],
  "snapshotSeq": 120,
  "snapshotUrl": "https://…",
  "highWater": 125
}

snapshotSeq and snapshotUrl are null when no snapshot has been committed. Entries below the live snapshotSeq may have been garbage-collected, so a client whose since is older than snapshotSeq should re-bootstrap. Paginate by passing the last seq as since until entries is empty or the last seq equals highWater.

Errors: the common Sync errors above.

Begin a snapshot (phase 1)

POST /v1/sync/snapshot

Auth: Bearer (third-party allowed, scope sync:write)

Request:

{ "seq": 120, "byteSize": 48211, "contentHash": "sha256-hex" }

seq is required. byteSize and contentHash are optional; when given they are enforced at commit.

Validation: seq must be greater than the live snapshotSeq and at most highWater, otherwise 409 SYNC_SEQUENCE_CONFLICT. byteSize, when present, must be greater than 0. Before validating, the server discards this tenant’s pending snapshots that have expired or that target a sequence number at or below the live pointer, deleting any bytes uploaded for them.

Response 200:

{
  "snapshotId": "8b0a…",
  "seq": 120,
  "snapshotBlobKey": "<tenant>/snapshots/120-8b0a….bin",
  "uploadUrl": "https://…",
  "expiresAt": "2026-10-10T15:30:00Z"
}

Nothing is live yet. bootstrap and changes ignore pending snapshots. PUT the snapshot ciphertext to uploadUrl within 15 minutes, then commit before expiresAt.

Errors

CodeHTTPWhen
SYNC_SEQUENCE_CONFLICT409seq is at or below the live snapshot seq, or above highWater
VALIDATION_ERROR400byteSize is 0 or negative, malformed JSON
ACCOUNT_REQUIRED / FORBIDDEN403See the common Sync errors

Commit a snapshot (phase 2)

POST /v1/sync/snapshot/commit

Auth: Bearer (third-party allowed, scope sync:write)

Request:

{ "snapshotId": "8b0a…", "byteSize": 48211, "contentHash": "sha256-hex" }

snapshotId is required. byteSize and contentHash are optional, but if repeated they must equal the values declared in phase 1.

Behaviour. The server looks up the pending snapshot for this tenant and rejects it if it has expired (the pending record and any uploaded bytes are discarded). It compares the declared size and hash with those from phase 1. It then checks that the object exists in blob storage and that its stored size equals the declared byteSize (from the commit, or from phase 1 if the commit omitted it). If everything matches, it moves the snapshot pointer forward; the pointer never moves backward. Finally it garbage-collects on a best-effort basis: the pending record, entries with seq < snapshotSeq and their blobs, the previous snapshot blob, and any other pending snapshots at or below this sequence number. Garbage-collection failures are logged and do not fail the commit.

Response 200:

{ "snapshotSeq": 120, "snapshotBlobKey": "<tenant>/snapshots/120-8b0a….bin", "highWater": 124 }

Errors. The journal is untouched in every error case.

CodeHTTPWhen
SYNC_SNAPSHOT_NOT_FOUND404snapshotId is unknown for this tenant, already committed, expired (30 minutes), or discarded because a newer snapshot superseded it. details.snapshotId holds the id.
SYNC_SNAPSHOT_NOT_UPLOADED409No object exists at the snapshot’s blob key. Finish the upload and retry; the pending snapshot survives until expiresAt.
SYNC_SNAPSHOT_MISMATCH409The committed size or hash differs from the declared values, or the stored object’s size differs from the declared size. details.reason says which.
SYNC_SEQUENCE_CONFLICT409The live pointer is already at or beyond this sequence number (a newer snapshot won). The pending snapshot is discarded.
STORAGE_ERROR500Blob storage could not be queried for a reason other than not-found

Bootstrap

GET /v1/sync/bootstrap

Auth: Bearer (third-party allowed, scope sync:read)

No parameters. Returns the live snapshot pointer plus up to 500 entries with seq > snapshotSeq, or from the start of the journal if no snapshot has been committed.

Response 200:

{
  "snapshotUrl": "https://…",
  "snapshotSeq": 120,
  "entries": [ { "…": "same shape as a changes entry" } ],
  "highWater": 125
}

snapshotUrl and snapshotSeq are null when no snapshot has been committed. If entries contains 500 items, continue with GET /v1/sync/changes?since=<last seq>.

Errors: the common Sync errors above.

Store the key envelope

PUT /v1/sync/key

Auth: Bearer (third-party allowed, scope sync:write)

Request:

{ "wrappedKey": "<base64>", "kdfParams": { "…": "opaque Argon2id salt and cost parameters" } }

wrappedKey must be base64 in the standard alphabet with padding; it is decoded and stored as bytes. kdfParams may be any JSON value and is stored verbatim. This is an upsert: a new envelope overwrites the previous one with no versioning.

Response 204 No Content.

Errors

CodeHTTPWhen
VALIDATION_ERROR400wrappedKey is not valid base64 (message invalid base64 wrapped_key: …)

Fetch the key envelope

GET /v1/sync/key

Auth: Bearer (third-party allowed, scope sync:read)

Response 200:

{ "wrappedKey": "<base64>", "kdfParams": { "…": "…" } }

When no envelope is stored the server answers 404 with an empty body and no JSON error envelope.

Operator endpoints

These require the operator token and are not reachable with any OAuth token.

MethodPathPurpose
GET/v1/admin/sync/orphansList Sync datasets created under legacy device identities before Sync required an account, with the account that now owns the same callsign
POST/v1/admin/sync/orphans/:participant_id/adoptMove an orphaned dataset onto the account that owns its callsign; refused unless the account’s Sync data is empty

Telemetry and ingestion

These endpoints exist for Carrier Wave’s own apps. They are documented so the traffic is understandable, not as an integration surface for third parties. Third-party OAuth tokens cannot call the authenticated ones.

The ingestion request bodies use snake_case.

Ingest app metrics

POST /v1/metrics

Auth: Public

Intended for Carrier Wave’s own apps. Accepts an Apple MetricKit metrics payload.

Request:

{
  "metadata": { "app_version": "1.9.0", "build_number": "412", "device_model": "iPhone16,1", "os_version": "19.0", "locale": "en_US" },
  "payload": { "…": "raw MetricKit payload JSON" }
}

metadata.app_version, build_number, device_model and os_version are required; locale defaults to "unknown". payload is any JSON value, stored verbatim. Length limits: app_version, build_number, os_version and locale 32 characters; device_model 64. The whole request body must be under 1 MiB.

Response 200:

{ "accepted": true }

Errors

CodeHTTPWhen
VALIDATION_ERROR400A field exceeds its length limit, the body exceeds 1 MiB, or the JSON is malformed

Retention. Stored payloads are not deleted automatically.

Ingest app diagnostics

POST /v1/diagnostics

Auth: Public

Intended for Carrier Wave’s own apps. Identical to POST /v1/metrics, for MetricKit diagnostic payloads.

Inbound SMS webhook (SMS spotting)

POST /v1/twilio/sms
Content-Type: application/x-www-form-urlencoded

Auth: Webhook

Intended for Carrier Wave’s own SMS spotting feature. The sender is identified by a marker code in the message body. Marker codes are minted by POST /v1/spot-markers, documented elsewhere.

Request form fields: Body (required), From (required, not used), To and MessageSid (optional, not used). A missing Body or From, or a wrong content type, yields a plain-text 4xx.

Body grammar: MARKER REFERENCE FREQUENCY MODE [COMMENTS], split on whitespace into at most five parts. Marker, reference and mode are upper-cased. A reference containing / is treated as a SOTA reference, otherwise as a POTA reference.

Behaviour. For a POTA reference, the server posts a spot to the POTA spotting service with the marker’s callsign as both activator and spotter, the given frequency, reference, mode and comments, and the source "Carrier Wave SMS". SOTA spotting is currently disabled: nothing is posted and the reply says so. Nothing is stored locally.

Response always 200 with Content-Type: application/xml:

<?xml version="1.0" encoding="UTF-8"?><Response><Message>Spot posted! POTA K-1234 on 14.062 CW by W1ABC</Message></Response>

Any failure (bad format, unknown marker, upstream spotting error) is answered with the same 200 and the message Error processing your spot. Check your message format: MARKER REFERENCE FREQ MODE [COMMENTS]. The webhook never returns a non-200 status.

Report upload errors

POST /v1/telemetry/upload-errors

Auth: Bearer (first-party)

Intended for Carrier Wave’s own apps. Reports failures when uploading QSOs to external logging services.

Request:

{
  "errors": [
    { "service": "pota", "category": "authentication", "message_hash": "a1b2c3", "affected_count": 5, "is_transient": false, "app_version": "1.8.0", "os_version": "18.4" }
  ]
}

All seven fields are required on every entry. Validation: at most 50 entries; service is one of pota, qrz, clublog, eqsl, lotw, hamqth; category is one of authentication, validation, rate_limited, maintenance, network_timeout, network_offline, server_error, rejected, subscription_required; affected_count is 0 or more; message_hash is at most 64 characters; app_version and os_version are at most 32. Reports are stored with the caller’s callsign.

Response 200 with {"accepted": <n>}. An empty errors array returns {"accepted": 0} without validation.

Errors

CodeHTTPWhen
VALIDATION_ERROR400Too many entries, an unknown service or category, a field out of range, malformed JSON
INVALID_TOKEN401Missing or invalid bearer token
FORBIDDEN403Third-party token

Retention. Stored reports are not deleted automatically.

Operator endpoints

These require the operator token and are not reachable with any OAuth token.

MethodPathPurpose
GET/v1/admin/telemetry/upload-errorsUpload-error summary (days 1 to 90, default 7; service; category): totals, by service, by category, daily trend, 50 most recent
GET/v1/admin/metrickitMetricKit summary (type of metrics or diagnostics; days 1 to 90, default 7; device_model; app_version): totals, by type and device, daily trend, 50 most recent with full payloads
GET/v1/admin/statsUser counts: totalUsers, usersLast7Days, usersLast30Days
GET/v1/admin/stats/users-by-hourDistinct active callsigns per hour (days 1 to 365, default 30); hours with no activity are absent

Operational endpoints

A health check for load balancers and monitoring, plus a note on how unknown routes behave.

Health check

GET /v1/health

Auth: Public

Checks that the database answers a trivial query within 2 seconds. The rbn block describes the Reverse Beacon Network ingester and is informational only; it never affects the status code.

Response 200 when the database answers (snake_case):

{
  "status": "ok",
  "version": "0.4.59",
  "database": "ok",
  "rbn": { "connected": true, "spots_in_store": 1234, "oldest_spot": "2026-10-10T13:00:00Z", "spots_per_minute": 42.0 }
}
  • version is the server build version.
  • rbn is present only when the RBN ingester is connected or the spot store holds spots. oldest_spot is omitted when the store is empty. spots_per_minute is measured over the last minute.

Response 503 Service Unavailable when the database check fails or times out:

{ "status": "error", "version": "0.4.59", "database": "error", "error": "database check failed" }

error is "database check failed" or "database check timed out". The underlying database error is never returned. rbn may still be present. The response carries no caching headers.

Metrics and web surfaces

The server also exposes GET /metrics at the domain root (outside /v1) in Prometheus text format for operators; do not confuse it with POST /v1/metrics, the MetricKit ingestion endpoint. The same host serves the Carrier Wave web apps at /, /contests and /logbook/, and any unknown URL outside /v1 returns the main web app’s HTML page with 200.

Unknown /v1 routes

A request to a /v1 path with no matching route returns a JSON 404:

HTTP/1.1 404 Not Found
Content-Type: application/json

{ "error": { "code": "NOT_FOUND", "message": "Endpoint not found" } }

There is no details key. This applies before authentication, so an unauthenticated request to an unknown /v1/admin/... path receives this 404 rather than a 401.

Cross-origin requests

Browser requests are accepted from https://carrierwave.app and https://activities.carrierwave.app, with methods GET, POST, PUT, DELETE and OPTIONS and the headers Authorization and Content-Type. PATCH is not in the allowed method list. Other origins must call the API from outside the browser.

Appendix: error codes

Every application error code, its HTTP status, and the details object it attaches. The OAuth endpoints’ RFC 6749 errors are documented with those endpoints.

CodeHTTPdetailsMeaning
VALIDATION_ERROR400Request failed validation; message explains what. Also used for malformed JSON and bad path parameters.
CHALLENGE_ENDED400Time-bounded challenge is over; cannot join or report.
INVALID_CONTEST_DEFINITION400message, problems[]Contest definition failed schema/semantic validation.
INVALID_EVENT_REVIEW400Review action/state transition not allowed.
INVALID_TOKEN401Missing, invalid, expired or revoked bearer token on a route that requires one.
FORBIDDEN403Caller authenticated but not allowed: wrong callsign, third-party token outside its scope map, bad admin token.
ACCOUNT_REQUIRED403Route needs an email-verified account; a bare fd_ device token is not enough.
NOT_PARTICIPATING403Callsign has not joined this challenge.
INVITE_REQUIRED403Challenge is invite-only and no inviteToken was supplied.
INVITE_EXPIRED403Invite token past its expiry.
INVITE_EXHAUSTED403Invite token reached maxUses.
MAX_PARTICIPANTS403Challenge is full.
EVENT_NOT_OWNED403eventIdAttempt to edit or delete another user’s event.
NOT_FOUND404No route matched under /v1.
CHALLENGE_NOT_FOUND404challengeId
BADGE_NOT_FOUND404badgeId
INVITE_NOT_FOUND404token
USER_NOT_FOUND404userId
FRIEND_INVITE_NOT_FOUND404tokenFriend invite link unknown or expired.
FRIEND_REQUEST_NOT_FOUND404requestId
FRIENDSHIP_NOT_FOUND404friendshipId
ACTIVITY_NOT_FOUND404activityId
PROGRAM_NOT_FOUND404slug
SPOT_NOT_FOUND404spotId
CLUB_NOT_FOUND404clubId
CLUB_MEMBER_NOT_FOUND404clubId, callsign
PARK_NOT_FOUND404reference
TRAIL_NOT_FOUND404reference
EQUIPMENT_NOT_FOUND404equipmentId
CONTEST_DEFINITION_NOT_FOUND404contestId
EVENT_NOT_FOUND404eventId
SYNC_SNAPSHOT_NOT_FOUND404snapshotIdPending snapshot never started, expired, or superseded.
ALREADY_JOINED409Callsign already in the challenge.
ALREADY_FRIENDS409
FRIEND_REQUEST_EXISTS409A pending request already exists between the two users.
CALLSIGN_TAKEN409callsignCallsign belongs to another account.
SELF_SPOT_EXISTS409An active self-spot already exists for this program.
SYNC_SEQUENCE_CONFLICT409Push’s baseSeq is behind the server; pull changes and retry.
SYNC_SNAPSHOT_NOT_UPLOADED409snapshotIdCommit called before the blob was PUT to uploadUrl.
SYNC_SNAPSHOT_MISMATCH409reasonUploaded blob size/hash differs from what begin declared.
SYNC_ADOPT_CONFLICT409entryCount, hasCursor, hasKeyBlobTarget account already has sync data; merge manually.
FRIEND_INVITE_USED410tokenFriend invite link already consumed.
CANNOT_FRIEND_SELF422
CAPABILITY_NOT_SUPPORTED422capability, programSlugProgram lacks the capability (for example self-spotting).
SYNC_ADOPT_NO_ACCOUNT422callsignNobody has signed in with that callsign yet.
MAX_PENDING_EVENTS429User already has 10 events awaiting review.
RATE_LIMITED429Handler-level limiter tripped (macro run events). Note the global limiter returns plain text instead.
INTERNAL_ERROR500Database or other internal failure. Check logs by X-Request-Id.
STORAGE_ERROR500Blob store (S3) failure.

304 Not Modified has no body.