Embeddable Checkout Widget
The bopen checkout widget is a self-contained, zero-dependency JavaScript component that merchants embed on their checkout pages. It handles the complete Open Banking A2A payment UX in a sandboxed iframe.
Architecture
MERCHANT PAGE (any origin)
│
│ <script src="/v1/checkout.js" data-checkout-token="..." async>
│
▼ (injects)
┌─────────────────────────────────────────────┐
│ SECURE IFRAME /widget/index.html │
│ │
│ Vanilla JS · No external dependencies │
│ Isolated from merchant DOM │
│ Communicates via window.postMessage only │
└─────────────────────────────────────────────┘
XSS isolation: merchant-page scripts cannot access the iframe DOM, input fields, or API payloads. Communication is exclusively via postMessage with origin checking.
Bundle size: loader + widget = 12 KB gzipped (well within the 20 KB target).
Merchant integration snippet
<!-- Payment container -->
<div id="bopenbanking-payment-widget"></div>
<!-- Loader script -->
<script
src="https://dev-checkout.bopenbanking.com/v1/checkout.js"
data-checkout-token="BASE64_ENCODED_TOKEN"
data-container-id="bopenbanking-payment-widget"
async>
</script>
Checkout token format
The data-checkout-token is a base64-encoded JSON object generated server-side:
{
"amount": "150.00",
"currency": "GBP",
"merchantName": "Acme Store",
"apiUrl": "https://dev.api.bopenbanking.com",
"returnUrl": "https://acme.example.com/checkout/success",
"merchantClientId": "acme-corp-client-id",
"callbackOrigin": "https://dev-checkout.bopenbanking.com",
"payerName": "John Smith",
"debtorAccountScheme": "UK.OBIE.SortCodeAccountNumber",
"debtorIdentification": "20000012345678",
"remittanceReference": "INV-2026-001"
}
Payer context fields (payerName, debtorAccountScheme, debtorIdentification, remittanceReference) are optional. They are stored on payment_consents for AML compliance.
Screen state machine
The widget progresses linearly through five screens. The user cannot go backward past ST-03.
[ST-01: Consent] → [ST-02: Bank] → [ST-03: Handover] → [ST-04: Processing] → [ST-05: Receipt]
| ID | Screen | Key elements |
|---|---|---|
| ST-01 | Consent Disclosure | Exact OBL legal text: "By clicking 'Pay with Bank', you explicitly authorize bopenbanking to securely route a one-off payment of £X from your bank account to [Merchant]." Amount, merchant name. "Cancel and Return to Merchant" link. |
| ST-02 | Bank Directory | Quick Pay tile for returning users (last-used bank from localStorage). Popular banks grid. Full searchable list (16 UK banks). HTML-injection-safe search filter. "Cancel" link. |
| ST-03 | Handover Gateway | Mobile: "Open [Bank] App" deep-link button. Desktop: Canvas QR code (self-hosted RS error-correction encoder, no external library). Trust copy: "We will never see your online banking passwords." "Cancel" link removed after handover click. |
| ST-04 | Processing Loop | Animated circular spinner. Full interaction lock: beforeunload listener + history.pushState override prevents back-button and tab-close during polling (2 500 ms intervals, 2-minute max). No cancel option. |
| ST-05 | Receipt | Success: green ✓, end_to_end_id reference, gross/fee/net breakdown. Failure: friendly error message mapped from failure_code (e.g. "Insufficient funds in your account"), "Retry Transaction" button loops back to ST-02. |
Fast-path (skip ST-01 + ST-02)
If the token includes debtorAccountScheme + debtorIdentification, the widget skips consent and bank selection entirely — the user goes straight to ST-03 using their preferred bank from localStorage (or the first available bank). Optimises conversion for returning customers where the merchant already knows the payer's account.
Quick Pay (ST-02)
On second and subsequent visits, the widget reads bopen_bank_preference from localStorage on the widget's origin. If found, it displays a "⚡ Quick Pay" tile at the top of ST-02 with the last-used bank pre-selected. On successful payment, the preference is stored:
{
"lastUsedBankId": "barclays",
"lastUsedTimestamp": "2026-07-08T10:45:22Z",
"payerFingerprint": "fp_sha256_a8f3c21b..."
}
Lifecycle events
Listen for events on the merchant page:
window.addEventListener("message", function(event) {
if (event.origin !== "https://dev-checkout.bopenbanking.com") return;
const { eventType, payload } = event.data;
switch (eventType) {
case "BOPEN_WIDGET_READY": // widget rendered
break;
case "BOPEN_BANK_SELECTED": // payload.bankId, payload.bankName
break;
case "BOPEN_PAYMENT_SUCCESS": // payload.transactionId, payload.endToExternalId
// clear cart, redirect
break;
case "BOPEN_PAYMENT_FAILED": // payload.errorCode
break;
case "BOPEN_PAYMENT_CANCELLED": // user clicked "Cancel and return"
break;
}
});
Regulatory safeguards
- "Cancel and Return to Merchant" link is prominent in ST-01, ST-02, ST-03. Clicking it fires
BOPEN_PAYMENT_CANCELLEDand navigates the parent toreturnUrl. - No credential harvesting — the widget never renders fields for card PANs, PINs, or online banking passwords. It discovers the bank and hands the payer over to the bank's native application layer.
- Navigation lock at ST-04 —
beforeunload+history.pushStateoverride prevents the payer from accidentally double-submitting by hitting Back.
Files
| File | Purpose |
|---|---|
apps/web/public/v1/checkout.js | Loader — reads data-* attributes, injects iframe, relays postMessage + BOPEN_RESIZE |
apps/web/public/widget/index.html | 5-screen widget — all JS + CSS inline, zero external dependencies, self-hosted QR encoder |
apps/web/public/widget-demo.html | Merchant integration demo — live event log, adjustable parameters |
Demo
The widget files above exist in the repository and build correctly, but as of this writing they have not been deployed to the live dev-checkout.bopenbanking.com Amplify app — that app's most recent deployment predates the widget's creation, so /widget/index.html, /v1/checkout.js, and /widget-demo.html all currently return 404 on the live site. Verified: curl -I https://dev-checkout.bopenbanking.com/widget-demo.html → 404.
To deploy it: ./infra/cloudformation/scripts/deploy-web-amplify.sh (see Getting Started). Until that runs, use the demo locally instead:
cd apps/web && npm run dev
Then open http://localhost:3000/widget-demo.html. Set amount, currency, and API URL, click Launch widget to preview all 5 screens with a live event log.