client_secret
and refresh token. The browser only sees a short-lived access token.
Prerequisites
After your app is approved, Blackbird sends you:client_id- embed in your frontendclient_secret- backend-only- A registered redirect URI - your callback URL
- Your allowed scopes - e.g.
read:profile,read:checkins,read:wallets
Environment
For staging:The flow
/oauth/token from your backend.
Steps 1 and 3 happen in the browser.
Step 1 - Initiate authorization
Generate PKCE parameters, store them insessionStorage, and
redirect the browser to /oauth/authorize.
Tasting note - Scope names are exact-match.
read:profiles
returns invalid_request. Use read:profile. Request only the
scopes your app needs: a token is granted exactly the scopes you
ask for, and member routes outside those scopes return
403 insufficient_scope.Tasting note -
/oauth/authorize requires PKCE: the
code_challenge + code_challenge_method=S256 parameters above are
mandatory, and the matching code_verifier is required on token
exchange. A bare authorize URL without them is rejected. The
authorize request 302-redirects the browser to the consent host
passport.staging.flynet.org: the auth tenant is a separate host
from the API gateway, the same split you see in the token’s iss
claim.Step 2 - Callback and token exchange
On the callback URL, your frontend reads thecode and forwards
it to your backend with the code_verifier. Your backend calls
/oauth/token with client_secret, sets the refresh token in an
HttpOnly cookie, and returns the access token to the frontend.
Tasting note - On a member’s first successful OAuth completion,
two wallets are automatically minted for them: a MEMBERSHIP wallet
and a SPENDING wallet.
Step 3 - Make API calls
The access token goes inAuthorization: Bearer .... Tokens last
60 minutes.
Tasting note - Member routes resolve the subject from your token.
A call to
/users/me/wallets returns the authenticated member’s
wallets: there’s no member UUID in the path, and you can’t read
another member’s data with the token. Each member route is gated by
scope (read:wallets, read:checkins, read:profile); a token
missing the required scope returns 403 insufficient_scope. Never
expose the token client-side beyond the in-memory pattern in
Security notes.Step 4 - Refresh
When the access token expires, your frontend asks the backend for a new one. The backend reads the refresh token from the cookie, exchanges it at/oauth/token, rotates the cookie, and returns a
fresh access token.
Identify the authenticated member
/users/me/* is the canonical way to read the authenticated
member: the subject is resolved server-side from the token, so you
rarely need the UUID at all. When you do need it (to key your own
state, for example), it lives in the access token’s sub claim.
Decode it client-side or backend-side.
sub, client_id,
scope, aud, iss, iat, exp, jti. The sub is a Flynet
user UUID; it matches the id returned by /users/me.
Security notes
- Keep the access token in memory only. Do not put it in
localStorageorsessionStorage. - Store the refresh token in an HttpOnly cookie scoped to
/api/oauth/refresh. - Blackbird allowlists your registered redirect URI’s origin when your app is provisioned.
- JWT issuer claim. Tokens carry
iss: https://api-staging.blackbird.xyz(staging). Note the dash betweenapiandstaging. The API host itself uses a dot:api.staging.blackbird.xyz. Both are valid; the hyphen form is the auth tenant, the dot form is the API gateway. If you verify JWT signatures or checkiss, expect the hyphen form.
Token TTLs
| Token | Lifetime |
|---|---|
| Access token | 60 minutes |
| Refresh token | Up to 30 days, rotated on each use |
Token response shape
Common error responses
| Code | When |
|---|---|
invalid_grant | Authorization code expired, was consumed, did not match code_verifier, or a rotated refresh token was reused. |
invalid_client | Wrong client_id or client_secret. |
invalid_request | Malformed request, missing parameter, or wrong scope name. |