@atoapayments/pay-embed
v0.0.1
Published
Zero-dependency browser loader for Atoa's chat-native checkout. Mount an existing payment into any container as a cross-origin, origin-pinned iframe that announces its own height; map a nextAction to a surface; degrade an unknown action to a sentence and
Readme
@atoapayments/pay-embed
Mount an existing Atoa payment into any container — a chat bubble, a card, a sheet — as a cross-origin iframe that announces its own height.
Zero dependencies. ~8 KB brotli for the <script>-tag build, less when a bundler
minifies the ESM entry. It is on the host's critical path, so it buys nothing it does
not need.
Install
With a bundler:
npm i @atoapayments/pay-embedimport { AtoaUI } from '@atoapayments/pay-embed';Or no install at all — one script tag, one mount() call, events back. @0 floats within the 0.x line
so docs and demos stay current; in production pin an exact version (@atoapayments/[email protected])
— a payment surface must not change under you between deploys.
<div id="checkout"></div>
<script src="https://unpkg.com/@atoapayments/pay-embed@0/dist/index.global.js"></script>
<script>
const { AtoaUI } = window.AtoaUI; // the tag exposes the package's exports as `window.AtoaUI`
AtoaUI.mount({ paymentRequestId: 'pr_…' }, {
container: document.getElementById('checkout'),
environment: 'sandbox', // or 'production'
onPaymentCompleted: (e) => console.log('completed:', e.data), // UX signal — your server polling is the truth
onEvent: (e) => console.log('event:', e.type), // every domain event, known or future
});
</script>A result carrying only a paymentRequestId and no nextAction is a first-class boot path,
not a degenerate one — pass the bare id (as above, or as paymentRequestId in the options)
and the payment surface mounts.
Framing is open by design. Any page may frame the checkout — production, localhost, a
notebook, a sandboxed widget — and there is nothing to register. clientId is optional:
a publishable identifier that says who is integrating and authorises nothing on its own —
omit it until Atoa issues you one.
What authorises is the server-minted paymentRequestId/clientSecret your backend created
with its API key, and the customer's own OTP or passkey — a ceremony that runs inside the
frame on Atoa's origin, where the credentials are bound. A page that frames the embed
without a payment it minted has framed an empty surface. The merchant API key must never
reach the browser. Approval secrets travel only in the URL fragment, never the query
string.
import { AtoaUI } from '@atoapayments/pay-embed';
const ui = AtoaUI.mount(result, {
container: bubbleEl,
clientId,
environment: 'sandbox',
theme: { mode: 'auto' },
onPaymentCompleted: (e) => confirmBooking(e.data),
onPaymentFailed: (e) => showRetry(e.data.reason),
onUiState: (s) => chip.set(s),
});result is whatever an Atoa tool or SDK method returned, in either casing. mount reads
result.nextAction, picks the surface, escalates to a popup when must_escalate, and
degrades to a link when the action is unknown.
What it refuses to do
- It cannot create a payment. There is no create verb in this package.
mount()takes an existingpaymentRequestId— that is what makes double-spend impossible at the UI layer: every re-render, double-tap and chat reload resolves to the same payment. - One live mount per order. Pass
orderIdand a secondmount()returns the existing handle instead of a second iframe. - It never opens a bank with
window.open(). The hop is an anchor, rendered inside the frame, always. An anchor click is a navigation, so popup blockers, sandbox flags and in-app browsers have nothing to block. - It never posts to
'*'. The child'satoa:hellois the single exception, it carries a frame id and a version, and everything after the handshake is pinned to the exact origin that replied. - It never trusts a claimed origin.
MessageEvent.origin, compared as an exact string. No prefix match, noendsWith('.atoa.me'). - It does not gate on who is framing. Framing is open; the security boundary is the server-minted payment capability and the customer's SCA on Atoa's origin, not the identity of the page around the frame.
The child owns its height
This is the one deliberate divergence from @atoapayments/agentic-payment-approvals-js, which is
otherwise the direct precedent for this package. There, the caller sizes the container
and the iframe fills 100%×100%. Here the child announces resize and the host applies it,
because a chat bubble cannot be pre-sized.
Three levels of control
Level 1 — automatic. AtoaUI.mount(result, { container, environment }).
Level 2 — explicit placement. Switch on nextAction.type yourself and use
componentFor() to decide the surface. Components take the whole action, never spread
props, so adding a field to data never changes a signature.
Level 3 — your own UI over the exported primitives (watchStatus, parseNextAction,
hostedUrl). What your own UI can never hold is card fields and the approval
credential — that is the surface law, not a product decision: a secret entered outside
an Atoa origin changes our PCI scope and yours.
nextAction → surface
| type | Component | Surface |
|---|---|---|
| PAY | AtoaCheckout | inline iframe |
| AUTHORIZE_BANK | AtoaCheckout | inline iframe |
| COLLECT_CARD | AtoaCard | sheet — 590 px floor, never inline |
| APPROVE / APPROVAL | AtoaApproval | inline iframe |
| AUTHORIZE_CONTRACT | AtoaContract | inline iframe |
| VERIFY_CONTACT | AtoaApproval | inline iframe |
| ENROL_CREDENTIAL | AtoaApproval | popup, always — Safari cannot credentials.create() cross-origin |
| AWAIT | AtoaStatusChip | native |
| NONE | AtoaReceipt | native |
| anything else | AtoaFallback | native — fallback.text + fallback.url. Never throws |
must_escalate: true overrides the table and forces a popup: the SDK does not try an
iframe first and fail.
AtoaFallback is the entire forward-compatibility contract. A server can ship a new
action type before any SDK knows about it. APPROVAL stays a valid alias for APPROVE
forever.
The fixtures are the contract
test/fixtures/next-action/*.json — one per catalogue type, plus unknown-type.json,
plus approval-legacy-alias.json (exactly what agentic-payments-service emits today,
with none of the envelope). Each records the wire payload and the parsed camelCase form.
Sprint 2's service work and the Python to_render_spec() parity suite are verified
against these exact files. Changing a fixture is changing the wire contract.
Theming
theme: { mode } — 'auto' | 'light' | 'dark'. That is the whole surface.
Colours, fonts, radius and density are not settable, by design. A payment surface a host can restyle is one a host can make look like something it is not, and the trust the frame carries is the product. Brand treatment, when it ships, will be issued per business from the backend against a verified merchant — never accepted from the embedding page.
Anything else passed in theme is dropped, so an older host degrades to the default
rather than breaking.
Clients must not strip the iframe attributes
allow="publickey-credentials-get; otp-credentials; payment"
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox
allow-same-origin allow-top-navigation-by-user-activation"Removing allow-popups kills the hand-off; removing allow kills passkeys and OTP
autofill. The loader logs a warning if it detects either was altered after mount.
Events
Outcomes arrive as resource.event domain events — one dictionary (the same one
webhooks will share when they arrive in a later phase). Each named callback gets a versioned envelope; onEvent fires for every
domain event after its named callback, including types newer than this SDK — that is the
forward-compatibility contract on the event surface.
AtoaUI.mount(result, {
container,
environment: 'sandbox',
onPaymentCompleted: (e) => confirmBooking(e.data), // { status, reason?, … }
onPaymentExpired: (e) => offerNewLink(e.resourceId),
onContractActivated:(e) => enableAutopay(e.resourceId),
onEvent: (e) => log(e.type, e), // the catch-all — always fires
});The envelope:
{
specVersion: 1, // bumps only on a breaking shape change
type: 'payment.completed', // payment.* | contract.* | approval.* | future types
resource: 'payment',
resourceId: 'pr_…', // null when the SDK holds only a secret (approvals)
occurredAt: '2026-08-23T12:00:00.000Z',
livemode: false, // true only against production
data: { status: 'COMPLETED', … }
}Named callbacks exist for payment.completed/failed/cancelled/expired,
contract.activated/declined and approval.approved/declined/expired. Everything else
(approval.superseded, contract.revoked, whatever ships next) reaches onEvent only.
UI signals live apart from the domain dictionary: onUiState(phase) reports the frame's
own phase machine, onError the API/bridge failures. Neither is a settlement fact.
These events are UX signals. Your server polling is the truth — confirm with
payment.get / awaitSettled before fulfilling an order, never from a client event.
Webhooks arrive in a later phase.
