Use this flow when your integration acts on behalf of we-fly users who are not you — a competition platform, a coaching tool, a phone app. Each user passes through a consent screen on we-fly.cloud before your app gets a token.
Decision tree
- Your app has a backend that can keep secrets? → register as a confidential client. PKCE is recommended but optional.
- Your app is a single-page web app, mobile app, or desktop app that ships its bundle to users? → register as a public client. PKCE is required.
If you're not sure, pick public client + PKCE — it's the safer default and it works for everything except backend-only integrations.
Registering the application
- Sign in to https://we-fly.cloud.
- Open https://we-fly.cloud/developer → New application.
- Fill out:
- Name and Description — both shown on the user consent screen. Make them recognisable.
- Homepage URL — optional, shown on the consent screen as a trust signal.
- Client type —
confidentialorpublic. - Redirect URIs — every URL you'll ever redirect users back to.
One per line. Must be
https://(orhttp://localhostfor dev). - Grant types — tick
authorization_codeandrefresh_token. - Allowed scopes — pick the union of everything any user might consent to. Individual flows can request a narrower subset.
You'll get a Client ID (wf_app_…). Confidential clients also get
a Client secret (wf_secret_…) — store it in your backend, never
ship it to the browser.
Flow
User Your app we-fly
│
│ 1. User clicks "Connect we-fly"
│ ─────────────────────────────► │
│ │
│ │ 2. Generate code_verifier (random),
│ │ derive code_challenge = SHA256+base64url,
│ │ generate `state` (CSRF token).
│ │
│ │ 3. Redirect user to /oauth/authorize
│ ◄────────────────────────── │
│ │
│ 4. Browser opens https://we-fly.cloud/oauth/authorize? │
│ response_type=code& │
│ client_id=…& │
│ redirect_uri=https://your-app.example/oauth/callback& │
│ scope=flights:read profile:read& │
│ state=…& │
│ code_challenge=…& │
│ code_challenge_method=S256 │
│ ────────────────────────────────────────────────────────────► │
│ │
│ 5. User signs in if needed, sees consent screen, │
│ clicks "Authorize". │
│ │
│ 6. we-fly redirects back to your redirect_uri: │
│ ?code=wf_code_…&state=… │
│ ◄────────────────────────────────────────────────────────── │
│ │ │
│ 7. Your callback handler │
│ verifies `state`, │
│ exchanges code for tokens. ─────────────────────────────► │
│ │
│ 8. POST /api/oauth/token │
│ grant_type=authorization_code │
│ code=wf_code_… │
│ redirect_uri=… │
│ code_verifier=… │
│ client_id=…&client_secret=… (confidential only) │
│ │
│ 9. Response: │
│ { access_token, refresh_token, expires_in, scope, … } │
│ │
│ 10. Call /api/v1/* with Authorization: Bearer … │
│ │
Step 1 — generate PKCE values
// Browser-side or anywhere.
function base64url(buf) {
return Buffer.from(buf)
.toString("base64")
.replace(/=+$/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const codeChallenge = base64url(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)),
);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
Store codeVerifier and state somewhere the callback handler can
read them (server-side session or browser sessionStorage).
Step 2 — redirect to the authorization endpoint
https://we-fly.cloud/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
&scope=flights%3Aread+profile%3Aread
&state=STATE_VALUE
&code_challenge=CHALLENGE
&code_challenge_method=S256
Parameters:
| Name | Required | Value |
|---|---|---|
response_type | yes | code (no implicit flow) |
client_id | yes | Your wf_app_… |
redirect_uri | yes | Must exactly match one of the registered URIs |
scope | yes | Space-separated; subset of your app's allowed scopes |
state | recommended | Opaque CSRF token your app generates |
code_challenge | required for public clients | Base64url SHA-256 of code_verifier |
code_challenge_method | required when code_challenge is sent | S256 (we don't accept plain) |
Step 3 — handle the callback
we-fly redirects the user back to your redirect_uri with either:
- Success:
?code=wf_code_…&state=… - Failure:
?error=access_denied&error_description=…&state=…
Verify state matches what you stored. If it doesn't, abort — the
request is forged.
Step 4 — exchange the code
curl -X POST https://we-fly.cloud/api/oauth/token \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "grant_type=authorization_code" \
-d "code=${CODE}" \
-d "redirect_uri=${REDIRECT_URI}" \
-d "code_verifier=${CODE_VERIFIER}"
For public clients, omit -u and send client_id as a body
parameter:
curl -X POST https://we-fly.cloud/api/oauth/token \
-d "grant_type=authorization_code" \
-d "client_id=${CLIENT_ID}" \
-d "code=${CODE}" \
-d "redirect_uri=${REDIRECT_URI}" \
-d "code_verifier=${CODE_VERIFIER}"
Response:
{
"access_token": "wf_at_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "wf_rt_…",
"refresh_token_expires_in": 2592000,
"scope": "flights:read profile:read"
}
Store the refresh token securely (encrypted at rest if it touches disk). The access token is short-lived; the refresh token is the sensitive material.
Refresh-token rotation
Access tokens last 1 hour. Refresh tokens last 30 days from issuance. When the access token expires, exchange the refresh:
curl -X POST https://we-fly.cloud/api/oauth/token \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "grant_type=refresh_token" \
-d "refresh_token=${REFRESH_TOKEN}"
Critical: every refresh issues a new refresh token. The old refresh token is invalid as of the moment of refresh — discard it. Storing or retrying with the old token will trigger our theft-detection branch and revoke every token in the chain for this user. If that happens, the user must re-authorise from scratch.
Write your refresh logic as a single atomic operation:
async function refresh(currentRefreshToken) {
const res = await tokenEndpoint({
grant_type: "refresh_token",
refresh_token: currentRefreshToken,
});
if (!res.ok) {
// Anything 4xx here, especially `invalid_grant`, means the
// refresh was rejected. Force the user back through consent.
return { kind: "reauth_required" };
}
const json = await res.json();
await storage.replace({
accessToken: json.access_token,
refreshToken: json.refresh_token, // <-- store the NEW one
expiresAt: Date.now() + json.expires_in * 1000,
});
return { kind: "ok", accessToken: json.access_token };
}
Revocation
When the user disconnects your app, call /api/oauth/revoke:
curl -X POST https://we-fly.cloud/api/oauth/revoke \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d "token=${REFRESH_TOKEN}" \
-d "token_type_hint=refresh_token"
Per RFC 7009, the response is always 200 OK. The user can also
revoke from https://we-fly.cloud/developer; once revoked, your
tokens will return 401 invalid_token on the next API call — handle
that by clearing local credentials and prompting re-auth.
Reference implementation (Next.js)
// app/oauth/start/route.ts
import { randomBytes, createHash } from "node:crypto";
import { redirect } from "next/navigation";
const CLIENT_ID = process.env.WEFLY_CLIENT_ID!;
const REDIRECT = `${process.env.PUBLIC_URL}/oauth/callback`;
function base64url(buf: Buffer) {
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
export async function GET() {
const verifier = base64url(randomBytes(32));
const challenge = base64url(createHash("sha256").update(verifier).digest());
const state = base64url(randomBytes(16));
// Persist verifier + state in an HTTP-only cookie or your session.
await session.set("oauth.pkce", { verifier, state });
const url = new URL("https://we-fly.cloud/oauth/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT);
url.searchParams.set("scope", "flights:read profile:read");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
redirect(url.toString());
}
// app/oauth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get("code");
const state = req.nextUrl.searchParams.get("state");
const error = req.nextUrl.searchParams.get("error");
if (error) return new Response(`OAuth error: ${error}`, { status: 400 });
if (!code) return new Response("missing code", { status: 400 });
const stored = await session.get("oauth.pkce");
if (!stored || stored.state !== state) {
return new Response("state mismatch", { status: 400 });
}
const basic = Buffer.from(
`${process.env.WEFLY_CLIENT_ID}:${process.env.WEFLY_CLIENT_SECRET}`,
).toString("base64");
const tokenRes = await fetch("https://we-fly.cloud/api/oauth/token", {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: `${process.env.PUBLIC_URL}/oauth/callback`,
code_verifier: stored.verifier,
}),
});
if (!tokenRes.ok) {
const body = await tokenRes.json().catch(() => ({}));
return new Response(JSON.stringify(body), { status: 400 });
}
const tokens = await tokenRes.json();
// Persist tokens against the local user...
await session.set("wefly", { ...tokens, obtainedAt: Date.now() });
return NextResponse.redirect(new URL("/connected", req.url));
}
Common errors
| You get | Why |
|---|---|
invalid_grant on code exchange | Code expired (10 min) / already used / redirect_uri mismatch / code_verifier doesn't match code_challenge |
invalid_grant on refresh | Refresh token expired (30 days), revoked, or already rotated. Force re-auth. |
invalid_scope | You asked for a scope the app isn't registered for, or tried to widen on refresh |
unauthorized_client | App is suspended, or pending app tried to authorise someone other than its owner |
access_denied (on callback URL) | User clicked "Cancel" on the consent screen |