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-ancestors header; 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:

ClaimValueCheck
issThe dashboard origin, e.g. https://paysio.comHostname must match the decoded host.
dest{iss}/w/{workspaceId}Hostname must match iss; the trailing id is the workspace.
audYour client_idMust equal your client id. This is what stops another app’s token being replayed at yours.
subThe member’s user idWho is acting. Stable across sessions.
sidA hash of the dashboard sessionTells two tabs apart; useful in your logs.
wspThe workspace idRead the workspace from here, never from the query string.
modelive or testBound into the access token you exchange for. Honour it in your own behaviour.
jtiUnique token idReject a repeat if you want single-use tokens.
iat / nbf / expIssued, not-before (with skew), expiry (+60s)Standard time checks. A token older than a minute is dead.
TypeScript
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.

JSON
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"
}
Response
JSON
{
  "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:

StatuserrorCause
400unsupported_grant_typegrant_type is not the token-exchange URN.
400invalid_requestMissing subject_token, or wrong subject_token_type.
401invalid_clientUnknown client_id, or the secret does not match.
401invalid_grantThe ID token failed verification, expired, or was issued for another app.
403invalid_scopeThe install grants no scopes (the merchant declined everything, or a widening is pending).
404invalid_grantThe 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.

JavaScript
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.workspace instead of the verified wsp claim.
  • 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 aud check, 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.