Token in memory only
No cookies, no localStorage, sessionStorage or IndexedDB. The token arrives by message and dies with the page.
Your widget page is a web page the app shows inside its home screen: a WebView on iOS and Android, a sandboxed iframe on the web. It never signs anyone in. Everything it knows comes from the app, as messages.
flowchart TB
classDef ours fill:#13686C,stroke:#0B4A4D,color:#ffffff
classDef ext fill:#F3ECDF,stroke:#B9A88B,color:#12191B
classDef msg fill:#ffffff,stroke:#D9CFBE,color:#12191B
subgraph app["FINNS app · home screen"]
direction TB
card["Membership card · balance"]:::ours
subgraph slot["Slot 'home' · height set by your page"]
page["Your page: /w/home<br/>widgets: points, banner, …"]:::ext
end
tiles["Check in · Book"]:::ours
end
page -- "hello · ready · resize · empty · error<br/>token-expired · changed · scroll · scroll-end" --> host["App widget host"]:::ours
host -- "init · token · context · changed" --> page
Every message is a JSON object { "v": 1, "type": …, … }, at most 16 KB. Unknown types, other versions and bad payloads are ignored, so either side can add messages without breaking the other.
| Page → app (reports, never commands) | Meaning |
|---|---|
hello |
I’m loaded; send init |
ready {height} |
Show me at this height (1–600 logical px) |
resize {height} |
My content changed size |
empty |
Nothing to show; collapse the slot |
error {code} |
I can’t work; collapse the slot |
token-expired |
Your API said 401; launch again now |
changed {topics: ["points"]} |
I changed the member’s points; re-read them |
scroll {dy} / scroll-end {velocity} |
A swipe or wheel I didn’t use; scroll the app |
| App → page | Meaning |
|---|---|
init {token, expiresIn, slot, widgets, context} |
Start. token and expiresIn are null for a guest |
token {token, expiresIn} |
Same member, new token |
context {theme?, locale?, textScale?} |
Presentation changed |
changed {topics: ["points"]} |
Points changed elsewhere (check-in, booking, staff); re-read them |
sequenceDiagram
autonumber
box rgb(227,244,243) FINNS app
participant APP as Widget host
end
box rgb(246,241,233) Sota Platforms
participant W as Your page
end
W->>APP: {v:1, type:"hello"}
APP->>W: {v:1, type:"init", token, expiresIn, slot:"home", widgets, context}
W->>APP: {v:1, type:"ready", height:146}
Note over APP,W: nowrap:…member taps "Collect"…
W->>APP: {v:1, type:"changed", topics:["points"]}
Note over APP: nowrap:Re-reads balance from core
Note over APP,W: nowrap:…gate check-in adds 10 points…
APP->>W: {v:1, type:"changed", topics:["points"]}
Note over W: nowrap:Re-reads balance from your backend
changed carries no values: each side re-reads from its own source, and core is the record. Send changed only after a posting of your own succeeds, never after a read or in reply to the app’s changed, so nothing ping-pongs.
// Page → appFinnsHost.postMessage(JSON.stringify({ v: 1, type: 'ready', height: 146 }));
// App → page: the app calls this function with a JSON string literalwindow.finnsWidget = { receive(json) { const msg = JSON.parse(json); // handle init, token, context, changed },};The app keeps the WebView on your origin: navigation elsewhere is blocked, and messages are only delivered while the page is on your origin.
// The app adds ?parent=<app origin>; accept it only if it is one of FINNS's app origins.const parent = new URL(location.href).searchParams.get('parent');
window.parent.postMessage({ v: 1, type: 'ready', height: 146 }, parent); // never '*'
window.addEventListener('message', (e) => { if (e.origin !== parent || e.source !== window.parent) return; // handle e.data});The iframe is sandboxed (allow-scripts allow-same-origin) with no referrer.
Opened anywhere else (a plain browser tab), the page should say “Open this in the FINNS app”.
Token in memory only
No cookies, no localStorage, sessionStorage or IndexedDB. The token arrives by message and dies with the page.
Strict CSP
default-src 'none', scripts and connections to your own origin, Trusted Types, and frame-ancestors limited to FINNS’s app origins. No innerHTML.
Height ≥ 1
Never report a height of 0. While the app covers the slot, a hidden page measures 0; ignore that. Nothing to show? Send empty.
Handle 401 gracefully
Keep the pending action and its idempotency key, send token-expired, wait (up to ~15 s) for token, then retry with the same key.
Forward unused gestures
The app can’t see touches over your page. Report swipes and wheels you don’t use as scroll/scroll-end. Elements that use their own gestures (a scratch card) mark themselves data-gestures="own".
Guests are real users
Render a useful guest view. Protection is on your backend: a guest has no token, so member actions fail there.
Internal and confidential. Share partner sections only as partner builds (pnpm build:partner).