App Bridge

The bridge is the JavaScript API your page uses to talk to the dashboard around it. It is a postMessage channel with origin checks on both ends: the dashboard only believes messages from the frame it created, and your page only believes the origin that framed it. It exposes one global, paysio, and asks nothing about how you build.

Loading it

HTML
<script src="https://cdn.paysio.com/paysio-bridge-1.12.js"></script>
<link rel="stylesheet" href="https://cdn.paysio.com/folio-1.15.css">

Versioned by URL. paysio-bridge-1.js and folio-1.css roll forward within v1 and pick up new capabilities without a change on your side; -1.6.js and -1.1.css pin exact releases. A pinned file is never changed once published. The React starter pins both and is typed against the pinned bridge.

Reference

paysio.context()PaysioContext

Synchronous. { embedded, workspaceId, slug, mode, locale, frame, headerInset }. embedded is false when your URL was opened outside the dashboard; render a hint rather than crashing. frame is 'main' for the page and 'popup' for the body of a dashboard-rendered popup. Informational: trust the verified ID token on your server for anything that matters.

paysio.idToken()Promise<string>

A valid ID token, always: the boot token while fresh, then a new one minted through the dashboard. Do not cache the result; it is already cached for exactly as long as it is safe.

paysio.fetch(input, init?)Promise<Response>

fetch with a fresh ID token as Authorization: Bearer. For calls to your own server, which verifies it and uses its access token for /v1.

paysio.ready()void

Tells the dashboard you have painted; it fades its loading state out. Call it once you can show something — not after your data fetch, or the merchant stares at a loader while your page is fully rendered behind it. If you never call it, the dashboard reveals the frame on document load anyway.

paysio.titleBar(config)void

Declares what belongs in the header bar above your app. Detailed below.

paysio.navigation({ items })void

Declares your app’s sections for the dashboard’s sidebar, drawn under your app’s row while the merchant is on it. Detailed below.

paysio.onNavigate(cb)() => void

The merchant picked one of those sections; cb receives its path. Navigate your own router — the frame is not reloaded. Returns an unsubscribe.

paysio.popup.open({ path, width?, height? })Promise<unknown>

Asks the dashboard to render a popup with one of your pages as the body. Detailed below.

paysio.popup.close(result?)void

From inside a popup body: closes it and resolves the opener’s promise with result.

paysio.toast(message, { tone? })void

A dashboard toast. Tones: default, success, error. Short, sentence case, no exclamation mark.

paysio.open({ transaction } | { customer } | { subscription })void

Opens one of the dashboard’s own records — the transaction, customer or subscription behind something in your app — by id. The dashboard picks the route; these three kinds are the whole allowlist, so an app cannot steer the merchant anywhere else. The record page’s first crumb becomes your app’s icon and name and leads straight back to the screen the merchant left, so the round trip is one click each way. Pair it with a Property row’s onClick on a detail page.

paysio.navigate(path)void

Mirrors your in-app route into the browser’s address bar: /{slug}/apps/{handle}/products/abc for your /products/abc. It goes both ways — a refresh or a shared link at that URL loads your app at that path, so every screen you have is a real URL. Call it on every route change, including Back and Forward; the React starter does this from its root layout, so an app built on it gets this for free. It does not navigate the dashboard anywhere else; for that, see open.

paysio.onModeChange(cb)() => void

Live ↔ sandbox without a reload. Register it before ready(); the dashboard then switches you in place and hands over a fresh token. Refetch everything on screen when it fires. Returns an unsubscribe.

paysio.onThemeChange(cb)() => void

Fires on dark mode and white-label tint changes, with the current theme. If you style from the CSS variables you need never handle it: the variables update in place.

paysio.theme()Theme

The current pushed theme: { variables, colorScheme }.

paysio.requestScope(scope)Promise

Asks the merchant to grant an optional scope you declared. Sends them to the consent page; resolves { redirected: true }. The grant lands as an app/scopes_update webhook.

paysio.resize(height?)void

Reports the document height. Called for you by a ResizeObserver; only relevant for a popup body, whose popup grows to the reported height. The main frame fills the island regardless.

The header bar

The 44px bar above your app is the dashboard’s: your app’s logo and name leading the trail, and an About action. You do not draw into it — you declare what belongs there and we render it with our own header components, so it cannot look anything but native. Your logo and name are permanent; everything after them is yours to fill.

JavaScript
paysio.titleBar({
  crumbs: [                                   // the sub-page trail after your app's name
    { id: 'list', label: 'Deliverables', onClick: () => go('/') },
    { id: 'item', label: 'Pro licence' },      // last crumb: the current page
  ],
  onHome: () => go('/'),                      // clicking your app's name (a link only when crumbs follow it)
  primaryAction: { label: 'New', icon: 'plus', onAction: create },
  secondaryActions: [{ label: 'Export', icon: 'download', onAction: exportAll }],
  menu: [                                     // the ⋯ overflow; About joins it
    { label: 'Settings', icon: 'settings', onAction: openSettings },
    { label: 'Delete all', icon: 'trash', destructive: true, onAction: nuke },
  ],
  search: { placeholder: 'Search keys', onChange: setQuery },
  tabs: {                                     // your top-level views, centred on the bar
    items: [{ id: 'products', label: 'Products' }, { id: 'log', label: 'Deliveries' }],
    value: view,
    onChange: (id) => setView(id),
  },
  layout: 'immersive',                        // the bar floats OVER your frame as a glass pill
  about: false,                               // hide the About action
})
title / subtitlestring
Shown after your app’s name when there are no crumbs.
crumbsCrumb[]
Up to four. onClick makes a crumb a link; the last one is the current page and never links. Clicks are events — you navigate your own routes. loading: true draws a skeleton pill in place of the label until you declare again with the real name.
onHome() => void
Runs when the merchant clicks your app’s name while crumbs follow it. Usually “go to my root”.
primaryActionAction
One. Rendered as the bar’s primary button.
secondaryActionsAction[]
Up to two, as secondary buttons before the primary.
menuAction[]
Up to eight items in the ⋯ overflow, which leads the action cluster so your primary button stays rightmost. About joins this menu the moment you have any control of your own.
search{ placeholder?, value?, onChange }
The dashboard’s own header search field. onChange receives the query, debounced 150ms.
tabs{ items, value, onChange }
Up to five top-level views as a segmented control centred on the bar. A click is an event: switch your view and declare the new value. This is your page header; draw no title row of your own.
layout'default' | 'immersive'
Immersive floats the bar over the top of your frame, pointer-transparent except for its controls. var(--paysio-header-inset) on your <html> is how much of your top edge sits under it (40px).
aboutboolean
false hides the About action. Your logo and name cannot be hidden.

An Action is { label, icon?, disabled?, loading?, destructive?, onAction }. Icons come by name from the dashboard’s own set, so a header never carries a glyph from a different family: plus download upload settings trash refresh send copy link key user search filter check edit eye star calendar bell external mail box cart card file chart lightning. Labels are capped at 40 characters. Call titleBar() again whenever state changes — a Save button that enables once the form is dirty — and the bar re-renders in place. The React starter’s useTitleBar() does that for you.

Your sections in the sidebar

An app with more than one screen needs somewhere to switch between them. Header tabs work for two or three views; a vertical app with Customers, Schedule, Routes and Reports wants what the rest of the dashboard has — rows in the sidebar. So declare them and the dashboard draws them there, indented under your app’s row, for as long as the merchant is on your app. Leaving it collapses them.

JavaScript
paysio.navigation({
  items: [
    { id: 'customers', label: 'Customers', icon: 'users',    path: '/customers' },
    { id: 'schedule',  label: 'Schedule',  icon: 'calendar', path: '/schedule' },
    { id: 'routes',    label: 'Routes',    icon: 'map',      path: '/routes' },
    { id: 'reports',   label: 'Reports',   icon: 'chart',    path: '/reports' },
  ],
})

paysio.onNavigate((path) => router.navigate({ to: path }))   // a click; no reload
itemsItem[]
Up to eight. Each is { id, label, icon?, path }: a label of up to 24 characters, an icon by name from the same vocabulary as the header bar, and one of your own routes. An empty list clears the rows.
pathstring
Which row is lit follows the route you mirror through navigate(): the item whose path is the longest prefix of the current one. / is the fallback, lit only when nothing more specific matches, so a “Home” at / and a “Customers” at /customers behave the way you would expect.

Declare them once, at your root, not per page — they are your app’s structure, not a page’s state. The React starter’s useAppNavigation() does this, and its root layout already handsonNavigate to the router. Keep header tabs for views within a section; the two are not the same thing and an app that uses both for the same switch will confuse.

Popups the dashboard renders

An iframe can only paint inside its own rectangle. A popup your app draws itself therefore stops at your edge: no scrim over the sidebar, no page recede, centred on your frame rather than the viewport — the one place an app still reads as “embedded”. So do not draw it. Ask the dashboard to.

JavaScript
// In your page
const result = await paysio.popup.open({ path: '/popups/new-item', width: 440, height: 344 })

// In /popups/new-item — paysio.context().frame === 'popup'
paysio.popup.close({ created: true })   // hands the value back to the opener

The dashboard opens its own popup — the same flare, shadow, scrim, shell recede and stacking as every native one — and loads that page of your app inside it, signed like the main frame. The close button, the scrim and Escape all close it and resolve the promise with undefined. A popup body can open another popup; they stack, and closing a parent closes its children. Switching sandbox/live closes every open popup.

pathstringrequired
A path within your app, starting with /. Query strings are allowed.
widthnumber
320–720px. Default 440, the dashboard’s own.
heightnumber
The body’s expected height. Pass it and the popup opens at that size at once, instead of appearing small and growing when the body reports. The body’s real height replaces it on paint. Measure yours once; a wrong guess only costs a short resize.

Inside the body: no page padding (the dashboard supplies it, and the close button), a title, an optional description, content, and a footer that stacks full-width actions with cancel first. Buttons square to 12px inside a popup automatically. The starter’s PopupPage lays that out; in plain HTML, use the .folio-popup-frame classes.

Switching modes without a reload

JavaScript
paysio.onModeChange(async (mode) => {
  // mode is 'live' or 'test'. Everything on screen belongs to the old one.
  render(await loadDeliveries())
})

paysio.ready()   // announces that you can handle the switch

By default the dashboard reloads your frame when the merchant flips the toggle — correct, because an app that ignored the switch would sit there showing live data under a sandbox banner. Register onModeChange before ready() and it switches you in place instead: no reload, no lost scroll, a fresh token handed over the bridge.

Messages, for the curious

You never have to touch these; the bridge wraps them all. Listed so nothing is a black box. Your page posts { source: "paysio-app", type, id?, payload } to the dashboard origin; the dashboard posts { source: "paysio-host", type, id?, payload } back.

From the appMeaning
handshakeBridge loaded; the dashboard replies with theme.
readyPainted. Carries capabilities.modeSwitch when onModeChange was registered.
idTokenMint a fresh token (request/response by id).
titleBar, navigation, toast, navigate, open, resizeAs the methods above.
popup:open, popup:closeOpen a popup (by id); close this popup body.
requestScopeSend the merchant to consent.
From the dashboardMeaning
themeThe CSS variables and colour scheme, on handshake, ready, and every change.
modeThe toggle flipped: new mode and a new ID token.
responseThe answer to a request, by id.
popup:closedA popup you opened closed, with its result.
titleBar:action, titleBar:crumb, titleBar:searchThe merchant used a header control you declared.
navigation:selectThe merchant picked one of your sidebar sections; carries its path.
layoutThe header inset changed (immersive on or off).