Authentication
Two tokens, doing different jobs. The ID token says who is here — which workspace, which member, which mode — and lives 60 seconds. The access token says what your app may do and is what your server calls /v1 with. Your frontend receives the first; your server trades it for the second. Nothing secret ever reaches the browser.
The embed request
When a merchant opens your app we load your application_url with these query parameters:
embedded'1'- Always present when framed by the dashboard. Absent when someone opens your URL directly.
workspacestring- The workspace id. Informational — read the real one from the verified token.
slugstring- The workspace slug, for display.
mode'live' | 'test'- Which side of the sandbox toggle the merchant is on. Informational; the claim is authoritative.
hoststring- The dashboard’s origin, base64-encoded. Decode it for your
frame-ancestorsheader; the bridge decodes it to know who to talk to. localestring- The merchant’s locale, e.g.
en. timestampstring- Unix seconds at signing. Part of the HMAC; reject requests older than a few minutes if you verify server-side.
id_tokenstring- The 60-second JWT described below.
hmacstring- HMAC-SHA256 over every other parameter, keyed with your client secret.
frame'popup'- Present only on a page loaded as the body of a dashboard-rendered popup.
The HMAC is computed the way Shopify computes theirs, so a library that verifies Shopify’s will verify ours: drop hmac, sort the remaining keys, join as key=value&key=value, HMAC-SHA256 with the client secret, compare in constant time. Verifying it on the server lets you trust mode and host on the very first request; verifying the ID token is what lets you trust the workspace.
The ID token
An HS256 JWT signed with your client secret. Every claim, and what to do with it:
| Claim | Value | Check |
|---|---|---|
iss | The dashboard origin, e.g. https://paysio.com | Hostname must match the decoded host. |
dest | {iss}/w/{workspaceId} | Hostname must match iss; the trailing id is the workspace. |
aud | Your client_id | Must equal your client id. This is what stops another app’s token being replayed at yours. |
sub | The member’s user id | Who is acting. Stable across sessions. |
sid | A hash of the dashboard session | Tells two tabs apart; useful in your logs. |
wsp | The workspace id | Read the workspace from here, never from the query string. |
mode | live or test | Bound into the access token you exchange for. Honour it in your own behaviour. |
jti | Unique token id | Reject a repeat if you want single-use tokens. |
iat / nbf / exp | Issued, not-before (with skew), expiry (+60s) | Standard time checks. A token older than a minute is dead. |
import { verify } from 'hono/jwt' // or jose, jsonwebtoken, any HS256-capable library
async function requireMerchant(idToken: string) {
const claims = await verify(idToken, process.env.PAYSIO_CLIENT_SECRET!, 'HS256')
if (claims.aud !== process.env.PAYSIO_CLIENT_ID) throw new Error('wrong audience')
if (new URL(String(claims.dest)).hostname !== new URL(String(claims.iss)).hostname) throw new Error('iss/dest mismatch')
return {
workspaceId: String(claims.wsp), // <- trust this, not req.query.workspace
userId: String(claims.sub),
mode: claims.mode as 'live' | 'test',
}
}paysio.idToken() in the browser always returns a token with at least a few seconds left, minting a new one through the dashboard when the boot token nears expiry. paysio.fetch() attaches it as a bearer token for calls to your own server. Never cache one, and never store one anywhere that outlives the request it arrived in.
Exchanging it for an access token
Your server trades the ID token for a pat_ token. No redirect, no callback route, no consent screen at this step — the merchant already consented at install; this is your app proving it is your app.
POST https://api.paysio.com/oauth/token
Content-Type: application/json (form encoding is accepted too)
{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": "<id_token>",
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"client_id": "app_...",
"client_secret": "appsecret_...",
"access_type": "offline" // or "online"
}{
"access_token": "pat_live_...",
"token_type": "Bearer",
"scope": "transactions:read products:read inventory:write",
"expires_in": 86400 // online tokens only
}access_type: offline- Bound to the install. Lives until the app is uninstalled or suspended. What background jobs and webhook handlers use. Store it encrypted, keyed by workspace and mode.
access_type: online- Bound to the member who opened the app. Expires in 24 hours. Use it when an action should be attributed to a person — a refund they clicked, a note they wrote.
Errors follow the OAuth shape, so an OAuth client library reads them:
| Status | error | Cause |
|---|---|---|
| 400 | unsupported_grant_type | grant_type is not the token-exchange URN. |
| 400 | invalid_request | Missing subject_token, or wrong subject_token_type. |
| 401 | invalid_client | Unknown client_id, or the secret does not match. |
| 401 | invalid_grant | The ID token failed verification, expired, or was issued for another app. |
| 403 | invalid_scope | The install grants no scopes (the merchant declined everything, or a widening is pending). |
| 404 | invalid_grant | The app is not installed in that workspace any more. |
Calling the API
A pat_ token is a bearer token against /v1, exactly like a merchant’s secret key — with three differences. Its scopes are the intersection of what you declared and what the merchant granted, recomputed on every request. It is bound to the mode it was minted in. And it unlocks the app-only endpoints: per-install storage and customer blocks.
const res = await fetch('https://api.paysio.com/v1/transactions?limit=20', {
headers: { Authorization: 'Bearer ' + accessToken },
})
if (res.status === 403) {
const { scope } = await res.json() // the scope you are missing, by name
}Sandbox and live
Every workspace has a sandbox toggle, and your app is inside it. The mode claim — and paysio.context().mode — follow whichever side the merchant is looking at.
We enforce the data half. The mode is bound into the access token, so a test-mode session physically cannot read or write live data. App data storage is keyed by mode. Anything you create through /v1 — a payment link, a fulfilment, a block — inherits the caller’s mode.
The behaviour half is yours. A test order is a rehearsal: ship nothing, spend no ad budget, charge no third-party service, send no email you would not want a tester to receive. Mirror the split in whatever you store on your own servers, and show a small badge so a merchant always knows which side they are on. Reviewers open every app in sandbox and exercise it.
When the merchant flips the toggle while your app is open, the dashboard either reloads your frame with a new token, or — if you registered paysio.onModeChange() before ready() — hands you the new token in place. See the App Bridge.
Rotating the secret
paysio apps rotate-secret issues a new client secret and the old one stops working immediately: ID tokens are signed with the new one from the next embed, and token exchange rejects the old one. Deploy the new secret to your server first, then rotate.
Common mistakes
- Reading the workspace from
req.query.workspaceinstead of the verifiedwspclaim. - Caching the ID token. It expires in 60 seconds; call
paysio.idToken()per request. - Shipping the client secret to the browser. It signs identities; in a browser it is a full compromise.
- Skipping the
audcheck, which is the whole defence against a token from another app. - Storing one offline token per workspace and using it in both modes. Store one per (workspace, mode).
- Treating a 403 as a bug. It names the missing scope; the merchant can grant it from your app’s page.