Skip to content

Token exchange

Token exchange (RFC 8693) is how your backend turns a session token — proof that a specific user has your embedded app open right now — into a normal OneBooks access token you can call the API with.

POST /oauth/token, form-encoded or JSON, with HTTP Basic or body client authentication (same as any other grant):

Terminal window
curl -s https://api.getonebooks.com/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "subject_token=$SESSION_TOKEN" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:jwt"
ParameterRequiredNotes
grant_typeYesurn:ietf:params:oauth:grant-type:token-exchange
subject_tokenYesThe session token from App Bridge
subject_token_typeYesurn:ietf:params:oauth:token-type:jwt (or :id_token, accepted identically)
requested_token_typeNoIf sent, must be urn:ietf:params:oauth:token-type:access_token
client_id / client_secretYesBasic auth or body — this grant is confidential-client-only

Identical shape to every other grant, plus issued_token_type:

{
"access_token": "…",
"refresh_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "invoices:read app-data:write",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

The issued token is scoped to the installation’s granted scopes, and every call it makes is still checked against the viewing user’s own role — a viewer-role employee opening your embedded action gets a token that can read but not write, even if an owner installed your app. See Errors for that permission-vs-scope distinction.

A standard OAuth error body, like every /oauth/token failure — branch on error; error_description, when present, is only for your logs:

{ "error": "invalid_grant" }

See OAuth endpoint errors for the status codes, headers and the 429 body. The errors specific to this grant:

HTTPerrorCause
401invalid_clientUnknown, inactive or admin-suspended client; a PUBLIC client attempted this grant (confidential only); or client authentication failed.
400invalid_requestsubject_token_type is missing or not one of the two supported values, or requested_token_type was sent and isn’t urn:ietf:params:oauth:token-type:access_token.
400invalid_grantEverything else: missing/malformed/unsigned-correctly subject_token, wrong kid, iss/aud/typ mismatch, expired or not-yet-valid (outside 30 s clock-skew tolerance), the jti was already redeemed, the installation is no longer ACTIVE, or the user is no longer active in that business. Deliberately not distinguished further in the response, so it can’t be used to probe installation state.

Never retry an exchange with the same subject_token: its jti is spent by the first attempt, whether that attempt succeeded or not. Exchanging the same token twice is also the most common cause of invalid_grant — the pattern below avoids it entirely. The other causes (the app was uninstalled, the user is no longer active, the token expired) aren’t fixed by retrying: answer your page with a 401 and let it show an error.

Every OneBooks access token — however it was minted — is bound to one (user, business, app) triple and gated at call time by that user’s current role. Token exchange doesn’t create a new kind of token; it’s a frictionless way to mint one for whoever has your iframe open, without sending them through a browser redirect they’d find bewildering inside an embed.

A session token can be exchanged exactly once, but App Bridge attaches the same session token to every request your page sends for up to ~50 seconds. So an exchange per request fails from the second request on. Do this instead, in the backend route your page calls:

  1. Verify the session token on every request (Session tokens) — that’s how you know the business (bid) and user (sub) behind it. Verifying never uses the token up.
  2. If you have no stored tokens for that bid + sub, exchange this session token now and store the access and refresh token you get.
  3. Otherwise use the stored access token, and refresh it as it nears expiry, storing the rotated pair each time. If the refresh fails, or an API call answers 401 (the tokens were revoked, say), delete the stored pair and exchange the current session token instead.
  4. Run one renewal at a time per business and user. A page often sends several requests at once, all carrying the same session token — if each one exchanged it, every exchange but the first would fail with invalid_grant. Let the first request that needs an exchange or refresh do it, and have the others wait for its result. After a 401, delete the stored pair only if it’s still the one that was rejected — a parallel request may already have replaced it — and don’t remember a failed renewal: let the next request try again.

Each user who opens your app gets their own token pair the first time, so every call still runs with the current viewer’s role — not the installer’s. The starter template implements all four steps; the invoice-action guide shows the first three.

Every exchange and refresh counts against your client’s own token-endpoint budget — 600 requests a minute across all your servers — which is one more reason to exchange once and keep the tokens.

Use…When
Token exchangeYou’re running inside the iframe (or your backend just received a session token from your own frontend). Zero redirects, always reflects the current viewer.
Authorization code (redirect)Your app has no embedded UI at all (redirect-install), or you need to obtain a token once, out-of-band, for background work that isn’t tied to a specific open browser tab.

Managed install (the one-click flow for embedded apps with a published listing) never sends the merchant through a browser redirect at all — the very first tokens your backend obtains for a newly-installed business come from token exchange, the moment your app’s home page first loads. There is no separate “offline” grant in this system to reach for instead.

For work that must happen without a user currently viewing your app — a nightly reconciliation job, reacting to a webhook outside a request from your iframe — don’t try to exchange a session token, since none exists outside an open embed. Instead:

  1. The first time you exchange a session token for a given (user, business), persist the returned refresh_token the same way you would for any OAuth integration — encrypted at rest, server-side only.
  2. Use ordinary refresh-token rotation to keep that credential alive indefinitely for background use, exactly as described on the Authentication page (strict rotation, single-flighted, family-burn on reuse).
  3. If the installation is uninstalled, every token for it — including this one — is revoked immediately; see Installation & lifecycle.

Hosted functions are the one case where you never need to manage this yourself: OneBooks mints a short-lived run token for each invocation automatically, scoped to the installing user’s permissions.