Overview OAuth guide Cards guide My apps Developer Terms

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

  1. You need a SHOTA account with a verified email address that is at least 30 days old.
  2. 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.
  3. 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:

  1. 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.
  2. The user's consent. For user scopes, the token must hold the scope the user agreed to. If not, you get 403 with 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.

ScopeLevelWhat it allows
profile.readUserSee your callsigns, name, photo and shack details
profile.emailUserSee your email address
profile.writeUserUpdate your profile and shack details
logbook.readUserRead the QSOs in your SHOTA log
logbook.writeUserAdd QSOs to your SHOTA log
logbook.deleteUserDelete QSOs from your SHOTA log
spots.writeUserPost spots on SHOTA as you
awards.readUserSee your activations, award progress and shacks you have hunted
feeds.readAppPublic feeds: spots, shacks, activations, leaderboards, events, operator profiles. App level: works with an app-only token.
members.readAppEvery registered, public SHOTA callsign, so you can invite your users who are on SHOTA to connect. App level. Rarely granted.
card:dashboardAppShow your card on the connected user's My Shack dashboard. Cards guide
card:logsAppShow 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.

MethodPathNeedsWhat
GET/meanyThe connected user: id and callsign; more with profile.read / profile.email
PATCH/meprofile.writeUpdate location, bio and shack details
POST/me/photoprofile.writeReplace the profile photo (multipart, field photo)
GET/me/callsignsprofile.readThe user's registered callsigns
GET/me/qsoslogbook.readThe user's QSOs (from, to, band, mode, per_page)
POST/qsoslogbook.writeAdd QSOs: raw ADIF, {"adif": "..."} or {"qsos": [...]}
DELETE/qsos/{id}logbook.deleteDelete one of the user's QSOs
POST/spotsspots.writePost a spot as the user
GET/me/activationsawards.readThe user's activations
GET/me/awardsawards.readAward progress
GET/me/huntedawards.readShacks the user has hunted
DELETE/tokenanyDisconnect: revoke this token and its refresh token
GET/spotsfeeds.readLive spots (minutes, mode, band, program)
GET/shacksfeeds.readThe shack list (program, q, active)
GET/shacks/{reference}feeds.readOne shack
GET/shacks/{reference}/activationsfeeds.readA shack's activations
GET/activationsfeeds.readRecent valid activations (date, program)
GET/leaderboards/activatorsfeeds.readTop activators
GET/leaderboards/huntersfeeds.readTop hunters
GET/eventsfeeds.readOfficial events
GET/operators/{callsign}feeds.readA public operator profile
GET/membersmembers.readEvery 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.

StatusMeaning
401Missing, expired or revoked token, or your app is no longer approved. Refresh, or reconnect the user.
403The user hasn't granted the scope this endpoint needs, or their account is suspended.
404Not found, or your app hasn't been given the permission for this endpoint.
422Validation failed.
429Rate 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.