For CLIs, varios, and other devices that don't have a browser or can't
safely embed one. The pattern: the device shows the user a short code
and asks them to open we-fly.cloud/oauth/device on any other device
(phone, laptop) to enter it.
If your integration is a regular web/server app, use the authorization-code flow instead.
Prerequisites
Register an OAuth application with device_code ticked in its grant
types. Both client types work:
- Public client — recommended for native mobile/desktop apps and any
binary you ship to users. Authenticate with the
client_idalone; there is no secret to embed (a secret baked into a shipped app isn't a secret). This is the right choice for an iOS/Android app uploading on a pilot's behalf. - Confidential client — for a device backed by a server you control
(e.g. firmware that talks to your own cloud). Authenticate with
client_id+client_secret.
A public client gets a client_id (wf_app_…) and no secret; a
confidential client also gets a client_secret (wf_secret_…). The
examples below show both channels.
Write scopes need an approved app.
flights:write(upload) is not read-only, so while your application is stillpendingit can only be authorised by its own owner. Get the app promoted toapprovedin the developer portal before asking other pilots to connect for upload.
Flow
Device we-fly User
│
│ 1. Start the flow.
│ ─────────────────────────────────► /api/oauth/device_authorization
│ │
│ 2. Receive device_code, user_code, verification_uri. │
│ ◄───────────────────────────────── │
│ │
│ 3. Show the user_code and the URL on screen. │
│ "Open https://we-fly.cloud/oauth/device and enter ABCD-1234" │
│ │
│ 4. Begin polling /token with the device_code. │
│ While the user hasn't authorised yet, we return │
│ authorization_pending — keep polling at the │
│ advertised `interval` (seconds). │
│ │
│ 5. User │
│ opens │
│ URL, │
│ enters │
│ code, │
│ consents│
│ │
│ 6. Next poll returns access_token + refresh_token. │
│ ◄───────────────────────────────── │
Step 1 — request a device code
# Public client (native app) — client_id only, no secret:
curl -X POST https://we-fly.cloud/api/oauth/device_authorization \
-d "client_id=${CLIENT_ID}" \
-d "scope=flights:read flights:write"
# Confidential client — HTTP Basic with the secret:
curl -X POST https://we-fly.cloud/api/oauth/device_authorization \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "scope=flights:read flights:write"
Response:
{
"device_code": "wf_dc_…",
"user_code": "WXYZ-1234",
"verification_uri": "https://we-fly.cloud/oauth/device",
"verification_uri_complete": "https://we-fly.cloud/oauth/device?user_code=WXYZ-1234",
"expires_in": 600,
"interval": 5
}
device_code— opaque secret your device polls with. Don't show it to the user.user_code— 8 characters in two groups (XXXX-XXXX) drawn from an unambiguous alphabet (no0/O, no1/I, no vowels). Show this prominently.verification_uri— URL the user opens.verification_uri_complete— same URL but pre-fills the code via a query string. Useful for "tap-to-open" buttons or QR codes.interval— minimum seconds between consecutive polls. Honour this. Polling faster doesn't get you a token any sooner — the token endpoint is rate-limited and answersHTTP 429once you exceed it, which only burns your polling budget.expires_in— total lifetime of the codes (seconds). The user has this long to type the code before the device must restart the flow.
Step 2 — instruct the user
Display both the URL and the code prominently. Examples:
┌────────────────────────────────────────────┐
│ Connect this vario to we-fly │
│ │
│ 1. Open https://we-fly.cloud/oauth/device │
│ 2. Enter the code: WXYZ-1234 │
│ │
│ Waiting… │
└────────────────────────────────────────────┘
If you can render a QR code, encode the verification_uri_complete
value — the user just scans and lands on the consent screen.
Step 3 — poll /token
# Public client — client_id in the body:
curl -X POST https://we-fly.cloud/api/oauth/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=${DEVICE_CODE}" \
-d "client_id=${CLIENT_ID}"
# Confidential client — HTTP Basic:
curl -X POST https://we-fly.cloud/api/oauth/token \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=${DEVICE_CODE}"
The grant_type value is the full URN, exactly as written. We translate
to a friendlier short name internally; the wire format is fixed by the
RFC.
While the user hasn't authorised, you'll get:
{
"error": "authorization_pending",
"error_description": "the user has not yet completed the authorization flow"
}
HTTP 400 — keep polling. Once the user clicks Authorize:
{
"access_token": "wf_at_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "wf_rt_…",
"refresh_token_expires_in": 2592000,
"scope": "flights:read flights:write"
}
If the user clicks Cancel:
{
"error": "access_denied",
"error_description": "the user denied the request"
}
If you poll faster than the advertised interval, the token endpoint's
rate limiter answers HTTP 429 (rather than an OAuth slow_down body).
Back off — add ~5 seconds to your interval — and keep polling.
If the code expires before the user enters it (the device timed them
out): expired_token. Restart the flow from step 1.
After you have a token — lifecycle & errors
Uploads present the access_token as Authorization: Bearer wf_at_…
against /api/v1/* (see native-app upload and
the endpoint reference). The access token is
short-lived; the refresh token is how you stay connected without
re-prompting the pilot — so silent background upload (e.g. after every
landing) relies on refreshing correctly.
Access token expired. A /api/v1/* call with a stale token returns:
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="token has expired"
{ "error": "invalid_token", "error_description": "token has expired" }
On a 401 invalid_token, refresh once and retry the call:
# Public client:
curl -X POST https://we-fly.cloud/api/oauth/token \
-d "grant_type=refresh_token" \
-d "refresh_token=${REFRESH_TOKEN}" \
-d "client_id=${CLIENT_ID}"
Refresh tokens rotate and are single-use. Every refresh returns a
new refresh_token alongside the new access token — persist it,
replacing the old one, before the next call. Presenting an already-used
refresh token is treated as theft and revokes the entire token chain
for that pilot + app (RFC 6819 §5.2.2.3); recover by restarting the
device flow from step 1.
403 insufficient_scope means the token is missing a required scope
(e.g. flights:write for upload). Refreshing won't fix it — that's a
registration/consent problem, so don't retry-loop on a 403.
Refresh failed / pilot revoked access. If the refresh itself returns
invalid_grant (expired, rotated away, or the pilot revoked the app from
their We-Fly dashboard), fall back to the device flow from step 1 to
re-link.
Reference implementation (Node.js)
import { setTimeout as sleep } from "node:timers/promises";
const DEVICE_URL = "https://we-fly.cloud/api/oauth/device_authorization";
const TOKEN_URL = "https://we-fly.cloud/api/oauth/token";
async function connect() {
const basic = Buffer.from(
`${process.env.WEFLY_CLIENT_ID}:${process.env.WEFLY_CLIENT_SECRET}`,
).toString("base64");
// Step 1: start the flow.
const init = await fetch(DEVICE_URL, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ scope: "flights:read flights:write" }),
}).then((r) => r.json());
console.log(
`Open ${init.verification_uri} and enter ${init.user_code}`,
);
let interval = init.interval * 1000;
const deadline = Date.now() + init.expires_in * 1000;
// Step 3: poll.
while (Date.now() < deadline) {
await sleep(interval);
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: init.device_code,
}),
});
if (res.status === 429) {
interval += 5000; // rate-limited — back off and keep polling
continue;
}
const body = await res.json();
if (res.ok) {
return body; // { access_token, refresh_token, … }
}
if (body.error === "authorization_pending") continue;
throw new Error(`flow failed: ${body.error_description ?? body.error}`);
}
throw new Error("device code expired before user authorised");
}
What you must implement
- Honour the
interval. Polling faster than advertised earns youHTTP 429from the rate limiter (not a token any sooner); sustained abuse can get your app suspended. - Use the prefixed device_code (
wf_dc_…) as-is. We pattern-match on the prefix when scrubbing logs. - Don't reuse device_codes. Once a token has been issued, the code is consumed; if you present it again we treat it as a replay and revoke any tokens minted from it.
- Restart the flow if it times out. No silent re-prompt — start from step 1 with a fresh code.
Compared to authorization-code
| Device code | Authorization code + PKCE | |
|---|---|---|
| Requires browser on the same device? | No | Yes |
| Code visible to user | Yes | No (encoded in URL) |
| User types anything? | Yes (8 chars) | No |
| Suitable for SPAs? | Overkill | Yes (recommended) |
| Suitable for embedded devices (vario, Pi)? | Yes | Awkward |
| Polling required? | Yes (5s default) | No |
| User experience | Two-device | Single-device |
Pick device code when you genuinely don't have a usable browser on the device. Otherwise authorization-code + PKCE is simpler and faster.