@atoapayments/agentic-payment-approvals-js
v0.0.4
Published
Zero-dependency browser SDK to surface an Atoa payment-approval — open the hosted approval page (popup, modal, or redirect) and resolve the human decision. The decision credential (OTP, passkey) is only ever collected on the Atoa origin; host apps forward
Readme
@atoapayments/agentic-payment-approvals-js
Keep the human approval of a gated payment inside your own web UI, instead of sending the user off to a redirect. When an Atoa Agent Pay payment.send(...) or payment.collect(...) needs Strong Customer Authentication, it returns a nextAction with a clientSecret. Hand that secret to confirmApproval and this SDK mounts Atoa's hosted approval page as an iframe inside a container you provide — the approver enters their one-time code or passkey right there, and you get back the decision.
This is the browser companion to the nextAction that Atoa Agent Pay returns on a gated payment. It is not a payments SDK and is separate from @atoapayments/agent-pay: its one job is to surface the approval UI. It is zero-dependency and framework-agnostic (React is an optional peer, not required).
There is exactly one surface: the mounted iframe. You own the chrome around it — render the container inline, inside your own modal, a bottom sheet, wherever — and the iframe fills it 100% x 100%. No popup windows, no full-page redirects.
The decision credential (OTP, passkey) is only ever collected on the Atoa origin. Your app provides a container and forwards the clientSecret; it never re-skins the approval or sees the code. That boundary is the security model — don't work around it.
Install
npm i @atoapayments/agentic-payment-approvals-jsShips ESM + CJS + a .d.ts, plus a unpkg global build for a plain <script> tag.
Quickstart
import { confirmApproval } from '@atoapayments/agentic-payment-approvals-js';
// `payments` came from a gated `atoa.payment.send(...)` (or `.collect(...)`) in @atoapayments/agent-pay
if (payments.nextAction) {
const approval = confirmApproval({
container: '#approval', // a selector or an HTMLElement you render + size
clientSecret: payments.nextAction.clientSecret,
colorScheme: 'light', // 'light' (default) | 'dark'
onEvent: (e) => console.log(e.type),
});
const { status } = await approval.result;
// status: 'APPROVED' | 'DECLINED' | 'EXPIRED' | 'SUPERSEDED' | 'CANCELLED'
// ...and from YOUR close affordance (backdrop click, sheet swipe-away, route change):
approval.destroy(); // cancels if still pending (-> CANCELLED), removes the iframe
}You decide where #approval lives and how big it is; the iframe is 100% x 100% of it. Wrap it in your own modal or bottom sheet and the "Approve GBP X" card + code entry render inside — you never draw the approval itself.
Documentation
- Approvals SDK — https://docs.paywithatoa.co.uk/agent-pay/approvals · Theming · Events
- Where
nextActioncomes from — SCA on a payout · SCA on a charge - Agent Pay overview — https://docs.paywithatoa.co.uk/agent-pay/overview
confirmApproval(options) → ApprovalHandle
| Option | Default | Notes |
|---|---|---|
| container | required | A CSS selector or an HTMLElement. You render + size it; the iframe fills it. Throws synchronously if it doesn't resolve to an element. |
| clientSecret | required | From nextAction. Env (live/sandbox) is read from its prefix (ap_live_... / ap_test_...) — no publishable key; a malformed secret throws before any iframe is created. |
| colorScheme | light | 'light' or 'dark'. Anything else (incl. undefined) normalizes to light. Independent of theme. |
| theme | — | Bounded branding tokens (see Theming) — hex/length-validated and contrast-clamped. Amounts, warnings, and the decline button are never restyled. |
| labels | — | Enumerated approve-CTA text only (see Labels). |
| onEvent | — | Fires for every lifecycle event (same stream as handle.on(...)). |
| onResult | — | Terminal-only convenience — called once with the final { status }. |
| apiUrl | — | Non-prod override for the hosted-page origin (e.g. http://localhost:3001). Ignored in normal use — the origin is derived from the secret's env prefix. |
ApprovalHandle — the live handle you get back:
result: Promise<{ status }>— resolves exactly once with the terminal decision. Never rejects.on(listener) => unsubscribe— subscribe to the lifecycle stream (same events asonEvent); returns an unsubscribe function.destroy()— tear down: cancel a still-pending approval (->CANCELLED), drop listeners, remove the iframe from the DOM.
Labels
labels restyles only the approve button's verb, and only from a fixed enum — freetext is rejected (relabeling the decision away from "you are paying" is the dark-pattern vector this guards against). The page always appends the amount, so PAY renders as "Pay GBP 42.00". The decline label is fixed and never configurable.
labels: { approve: 'PAY' } // 'APPROVE' (default) | 'PAY' | 'CONFIRM' | 'AUTHORIZE'Anything outside the enum (or a non-object) is dropped and the default APPROVE is used. Branding tokens (theme) work the same way — see Theming.
Events
Every event flows through both onEvent and every on(...) listener, as the same { type, ...payload } object — filter on e.type. Payloads are enumerated/masked only: never an OTP, a full contact, or an account number. A listener that throws is swallowed and never breaks the stream.
| type | Emitted when | Payload | Terminal? |
|---|---|---|---|
| opened | The instant the handle is created (iframe appended, channel listening). SDK-emitted. | — | no |
| loaded | The page finished loading and resolved which CTA it will show. | method?: 'PASSKEY' \| 'SETUP_OFFERED' \| 'OTP_ONLY' | no |
| approved | The human approved. | decidedBy?: string | yes -> APPROVED |
| declined | The human declined. | decidedBy?: string | yes -> DECLINED |
| expired | The approval window lapsed before a decision. | — | yes -> EXPIRED |
| superseded | A newer approval for the same action replaced this one. | — | yes -> SUPERSEDED |
| error | A recoverable page error. Non-terminal — the page may recover. | reason: string | no |
| closed | You called destroy() (or it was auto-cancelled). Immediately precedes a CANCELLED result. SDK-emitted. | — | no |
error does not settle result — the promise stays pending, and it's up to you to destroy() if you want to give up. closed is not itself terminal: the terminal CANCELLED status arrives on result/onResult, never as an event type.
Result & terminal statuses
result (and onResult) resolve exactly once, with { status }. Five statuses — the first four come from the page's terminal events, the fifth only from you:
| Status | Source |
|---|---|
| APPROVED | approved event |
| DECLINED | declined event |
| EXPIRED | expired event |
| SUPERSEDED | superseded event |
| CANCELLED | You called destroy() before any of the above. |
result never rejects — a page error leaves it pending rather than throwing, so always await a terminal status (or drive teardown yourself via destroy()).
When to call destroy()
destroy() cancels a still-pending approval (resolving result as CANCELLED and emitting closed), drops all listeners, and removes the iframe. It is a no-op once a decision has landed. Call it from your own close affordance — the SDK draws no chrome, so nothing else can cancel for you:
- a backdrop / overlay click, or the close button on your modal;
- a bottom-sheet swipe-away or dismiss;
- a route change or component unmount (
useEffectcleanup,onUnmounted, etc.) — so an abandoned approval doesn't leak an iframe and a livepostMessagelistener.
You do not need to call it after a terminal decision — the SDK self-cleans on resolve. It's purely for the "user walked away without deciding" path.
Browser support, bundle size & CSP
- Zero runtime dependencies, framework-agnostic. React is an optional peer (
>=17) — used only for the ambient types; the SDK is plain DOM. - Works in any modern browser with
iframe+postMessage. Passkey approval uses WebAuthn delegated to the cross-origin frame (publickey-credentials-get/publickey-credentials-create); where the browser doesn't support cross-origin passkey creation, the page falls back to a one-time code, so "Set up passkey" never dead-ends. - The hosting origin must be in the approval page's server-side
frame-ancestorsallowlist (per-business registered — never a wildcard). ThepostMessagehandshake is origin-pinned: the page requests the secret and the SDK answers pinned to the page origin, so the secret never rides a URL.
Relationship to Agent Pay
This package is the browser front-end for the SCA approval that @atoapayments/agent-pay surfaces via nextAction. It is optional and independent: the same approval can be completed by handing the approver the raw approvalUrl from nextAction instead (and the Python SDK does exactly that, since this browser SDK is TypeScript-only). Use this package when you want the approval to happen inside your own web UI rather than via a redirect.
License
License. MIT. This covers the code in this package.
Service terms. Use of the Atoa API is governed by the Atoa Services Agreement: https://paywithatoa.co.uk/terms/. The MIT license applies to this SDK only and grants no rights to the Atoa service.
Trademarks. "Atoa" and the Atoa logo are trademarks of Atoa Payments Limited. The MIT license grants rights in the code, not in our names or marks — a modified or redistributed copy must not be presented as an Atoa product.
Security. Report vulnerabilities to [email protected] — please do not open a public issue.
