OAuth guide
Let SHOTA operators connect their account to your service. They sign in on SHOTA, see exactly what your app is asking for, and can disconnect it at any time.
OAuth or API key?
π API key
For your own scripts and loggers. Read public feeds, read and write your own log, post spots. Create one in your account, no review needed.
π§© OAuth app
For third-party services used by other operators: QSL services, club dashboards, logging platforms. Reviewed by us, scoped per app, approved by each user. Can also read profiles and email ("Sign in with SHOTA"), update profiles, and show cards on SHOTA pages.
Never ask users for their SHOTA password or API key. If your service acts for other people, use OAuth.
1. Register your app
- You need a SHOTA account with a verified email address that is at least 30 days old.
- Go to My apps and submit your app: name, logo, description, links, your callback URL and the permissions you need, with a short explanation of why.
- We review it (usually within a few days) and email you. Once it's approved, open the app to get your client ID and generate your client secret. The secret is shown once; keep it on your server only.
2. Connecting a user (authorization code + PKCE)
SHOTA uses the OAuth 2 authorization code grant. PKCE with S256 is required for every app, on top of your client secret. Requests without it, or with plain, are refused.
a. Make a code verifier and challenge
// Node.js
import crypto from 'node:crypto';
const verifier = crypto.randomBytes(32).toString('base64url'); // keep in the user's session
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
const state = crypto.randomBytes(16).toString('hex'); // keep too, check on return // PHP
$verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); b. Send the user to SHOTA
https://shacksontheair.com/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.example/oauth/callback
&scope=profile.read logbook.write
&state=STATE
&code_challenge=CHALLENGE
&code_challenge_method=S256 Open it in the browser (a popup is fine). The redirect_uri must match your registered callback URL exactly. Ask only for scopes your app has been granted; asking for anything else shows the user an error and nothing is sent back. Scopes are space-separated.
The user signs in if needed, sees your app's name, logo, description and the list of what it will be able to do, and chooses Allow or Cancel. You get them back at your callback with ?code=...&state=..., or ?error=access_denied if they cancelled.
c. Exchange the code for tokens (from your server)
curl -X POST https://shacksontheair.com/oauth/token \
-d grant_type=authorization_code \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d redirect_uri=https://yourapp.example/oauth/callback \
-d code=CODE_FROM_CALLBACK \
-d code_verifier=VERIFIER {
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJ0eXAiOiJKV1Qi...",
"refresh_token": "def50200..."
} Codes are single use and expire after 10 minutes. Then call the API with Authorization: Bearer ACCESS_TOKEN:
curl https://shacksontheair.com/api/partner/v1/me -H "Authorization: Bearer ACCESS_TOKEN" -H "Accept: application/json" 3. Refreshing tokens
Access tokens last 1 hour; refresh tokens last 90 days. Each refresh returns a new refresh token, and the old one stops working, so always store the new one.
curl -X POST https://shacksontheair.com/oauth/token \
-d grant_type=refresh_token \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d refresh_token=REFRESH_TOKEN If a refresh fails with invalid_grant, the user has disconnected your app (or the token expired). Ask them to connect again.
4. App-only tokens (client credentials)
Public feeds don't need a user. Get a token for your app itself:
curl -X POST https://shacksontheair.com/oauth/token \
-d grant_type=client_credentials \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d scope=feeds.read
curl https://shacksontheair.com/api/partner/v1/spots -H "Authorization: Bearer APP_TOKEN" 5. Permissions & scopes
Two locks, both checked on every request:
- Your app's permissions, chosen by us when we approve it. Calling an endpoint your app hasn't been given returns
404 Not found, as if it didn't exist. - The user's consent. For user scopes, the token must hold the scope the user agreed to. If not, you get
403with a message naming the missing scope; send the user through the authorize step again asking for it.
If we remove a permission from your app, it stops working at once, even for tokens you already hold.
| Scope | Level | What it allows |
|---|---|---|
| profile.read | User | See your callsigns, name, photo and shack details |
| profile.email | User | See your email address |
| profile.write | User | Update your profile and shack details |
| logbook.read | User | Read the QSOs in your SHOTA log |
| logbook.write | User | Add QSOs to your SHOTA log |
| logbook.delete | User | Delete QSOs from your SHOTA log |
| spots.write | User | Post spots on SHOTA as you |
| awards.read | User | See your activations, award progress and shacks you have hunted |
| feeds.read | App | Public feeds: spots, shacks, activations, leaderboards, events, operator profiles. App level: works with an app-only token. |
| members.read | App | Every registered, public SHOTA callsign, so you can invite your users who are on SHOTA to connect. App level. Rarely granted. |
| card:dashboard | App | Show your card on the connected user's My Shack dashboard. Cards guide |
| card:logs | App | Show your card on the connected user's Logs & uploads page. Cards guide |
Every token can call GET /me to learn the user's stable id and callsign. Without profile.read that's all it returns; email is only included with profile.email.
6. Endpoints
Base URL: https://shacksontheair.com/api/partner/v1. When your app is approved, its page links to an OpenAPI 3.1 document listing exactly the endpoints it may use, with request and response schemas.
| Method | Path | Needs | What |
|---|---|---|---|
| GET | /me | any | The connected user: id and callsign; more with profile.read / profile.email |
| PATCH | /me | profile.write | Update location, bio and shack details |
| POST | /me/photo | profile.write | Replace the profile photo (multipart, field photo) |
| GET | /me/callsigns | profile.read | The user's registered callsigns |
| GET | /me/qsos | logbook.read | The user's QSOs (from, to, band, mode, per_page) |
| POST | /qsos | logbook.write | Add QSOs: raw ADIF, {"adif": "..."} or {"qsos": [...]} |
| DELETE | /qsos/{id} | logbook.delete | Delete one of the user's QSOs |
| POST | /spots | spots.write | Post a spot as the user |
| GET | /me/activations | awards.read | The user's activations |
| GET | /me/awards | awards.read | Award progress |
| GET | /me/hunted | awards.read | Shacks the user has hunted |
| DELETE | /token | any | Disconnect: revoke this token and its refresh token |
| GET | /spots | feeds.read | Live spots (minutes, mode, band, program) |
| GET | /shacks | feeds.read | The shack list (program, q, active) |
| GET | /shacks/{reference} | feeds.read | One shack |
| GET | /shacks/{reference}/activations | feeds.read | A shack's activations |
| GET | /activations | feeds.read | Recent valid activations (date, program) |
| GET | /leaderboards/activators | feeds.read | Top activators |
| GET | /leaderboards/hunters | feeds.read | Top hunters |
| GET | /events | feeds.read | Official events |
| GET | /operators/{callsign} | feeds.read | A public operator profile |
| GET | /members | members.read | Every registered, public callsign |
Examples
# The connected user
curl https://shacksontheair.com/api/partner/v1/me -H "Authorization: Bearer TOKEN"
# Update their shack (profile.write)
curl -X PATCH https://shacksontheair.com/api/partner/v1/me -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"bio": "Licensed 1998", "shack": {"rig": "IC-7300", "antenna": "EFHW in the loft"}}'
# Their log (logbook.read)
curl "https://shacksontheair.com/api/partner/v1/me/qsos?from=2026-11-01&band=20m" -H "Authorization: Bearer TOKEN"
# Add QSOs as ADIF (logbook.write)
curl -X POST https://shacksontheair.com/api/partner/v1/qsos -H "Authorization: Bearer TOKEN" -H "Content-Type: text/plain" \
--data-binary '<STATION_CALLSIGN:5>G0ABC <CALL:5>M0XYZ <QSO_DATE:8>20261101 <TIME_ON:4>1200 <BAND:3>20m <MODE:3>SSB <EOR>'
# Or as JSON
curl -X POST https://shacksontheair.com/api/partner/v1/qsos -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"qsos": [{"call": "M0XYZ", "qso_date": "20261101", "time_on": "1200", "band": "20m", "mode": "SSB"}]}'
# Delete a QSO (logbook.delete)
curl -X DELETE https://shacksontheair.com/api/partner/v1/qsos/123 -H "Authorization: Bearer TOKEN"
# Spot the user (spots.write)
curl -X POST https://shacksontheair.com/api/partner/v1/spots -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
-d '{"activator": "G0ABC/P", "frequency": 14285, "mode": "SSB", "comment": "CQ SHOTA"}'
# Awards (awards.read)
curl https://shacksontheair.com/api/partner/v1/me/awards -H "Authorization: Bearer TOKEN"
# Public feeds with an app-only token (feeds.read)
curl "https://shacksontheair.com/api/partner/v1/spots?minutes=30&mode=CW" -H "Authorization: Bearer APP_TOKEN"
curl https://shacksontheair.com/api/partner/v1/shacks/SHGB-2222 -H "Authorization: Bearer APP_TOKEN"
curl https://shacksontheair.com/api/partner/v1/leaderboards/activators -H "Authorization: Bearer APP_TOKEN" QSOs are only accepted for the user's own callsigns (and their /P, /M and prefixed variants). Sending the same QSO twice updates it rather than duplicating it.
7. Errors & limits
Errors are JSON with a message; validation errors (422) add an errors object keyed by field.
| Status | Meaning |
|---|---|
| 401 | Missing, expired or revoked token, or your app is no longer approved. Refresh, or reconnect the user. |
| 403 | The user hasn't granted the scope this endpoint needs, or their account is suspended. |
| 404 | Not found, or your app hasn't been given the permission for this endpoint. |
| 422 | Validation failed. |
| 429 | Rate limited. Wait for Retry-After seconds. |
- 120 requests a minute per app and user (or per app, for app-only tokens).
- 60 writes a minute (QSOs, spots) per app and user.
- The
/oauth/*endpoints are limited per IP address. Don't poll them. - Poll spots no more than once a minute.
8. Disconnecting
Users can disconnect your app at any time from Connected apps in their SHOTA account. Their tokens stop working straight away and your next request gets 401. When a user disconnects SHOTA inside your app, revoke the token yourself:
curl -X DELETE https://shacksontheair.com/api/partner/v1/token -H "Authorization: Bearer TOKEN" Either way, delete the SHOTA data you hold for them, as the Developer Terms require.