Approvals SDK
Every gated payment — a payout or an off-session charge — needs a human to approve it before money moves. The approval happens on Atoa’s hosted page, where the approver enters a one-time code or uses a passkey. Your app never sees the credential.
@atoapayments/agentic-payment-approvals-js embeds that hosted page inside a container you provide and resolves to the
decision, so the approver stays in your own web UI instead of following a link away. It’s the browser companion to the
nextAction you get back from payment.send / payment.collect — an alternative to delivering the raw approvalUrl.
Browser/TypeScript only, zero dependencies. If you’re on the Python SDK — or have no web layer at all — deliver the
approvalUrl from nextAction instead (see SCA on a payout); this SDK is just a
nicer surface for the same approval, not a separate one.
Install
npm i @atoapayments/agentic-payment-approvals-js
Use
When a gated call returns a nextAction, pass its clientSecret to confirmApproval along with a container you
render. You get back an ApprovalHandle: await handle.result for the decision, handle.on(…) to follow the
lifecycle, and handle.destroy() to close it.
import { confirmApproval } from "@atoapayments/agentic-payment-approvals-js";
const result = await atoa.payment.send({
contractId,
payments: [{ /* … */ }],
});
if (result.nextAction) {
const approval = confirmApproval({
container: "#approval", // a selector or HTMLElement you render + size
clientSecret: result.nextAction.clientSecret,
colorScheme: "light", // "light" (default) | "dark"
onEvent: (e) => console.log(e.type), // opened, loaded, approved, declined, …
});
const { status } = await approval.result; // APPROVED | DECLINED | EXPIRED | SUPERSEDED | CANCELLED
// From YOUR close affordance (backdrop click, sheet dismiss, route change):
approval.destroy(); // cancels if still pending (→ CANCELLED); no-op after a decision
}
Options
confirmApproval(options) → ApprovalHandle.
| Option | Default | Notes |
|---|---|---|
container | — (required) | A CSS selector or an HTMLElement. You render and size it; the iframe fills it. Throws synchronously if it doesn’t resolve to an element. |
clientSecret | — (required) | From nextAction. Environment 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 — hex/length-validated and contrast-clamped. See Theming. Amounts, warnings, and the decline button are never restyled. |
onEvent | — | Fires for every lifecycle event (same stream as on(…)). |
onResult | — | Terminal-only convenience — called once with the final { status }. |
Theming
theme accepts a small, bounded set of branding tokens applied as CSS custom properties — not freeform CSS. Each
token is hex/length-validated; malformed tokens are dropped. The two critical contrast pairs (button-text vs primary,
text vs background) are contrast-clamped to ~4.5:1 — a failing pair falls back to the accessible default, so you can
never render an invisible decline button or white-on-white amount.
theme: {
primaryColor: "#E42646",
buttonTextColor: "#FFFFFF",
textColor: "#111111",
headingColor: "#111111",
backgroundColor: "#FFFFFF",
borderRadius: "12px",
fontFamily: "system-ui, sans-serif", // system font stacks only — no remote fonts
}
The amount, warnings, masked-contact copy, and layout are never customisable.
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 | Synchronously, the instant the handle is created. SDK-emitted. | — | no |
loaded | The page loaded 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 (bad secret state, network, …). | reason: string | no |
closed | You called destroy() (or it auto-cancelled). Precedes a CANCELLED result. SDK-emitted. | — | no |
opened and closed are emitted by the SDK; everything else originates on the Atoa page. error does not settle
result — the page may recover — so it’s up to you to destroy() if you want to give up.
Result & terminal statuses
result (and onResult) resolve exactly once, with { status }:
| Status | Source |
|---|---|
APPROVED | approved event |
DECLINED | declined event |
EXPIRED | expired event |
SUPERSEDED | superseded event |
CANCELLED | You called destroy() before any of the above (never a page decision). |
A declined or expired approval also comes back on the Payment itself — failureReason: APPROVAL_DECLINED /
APPROVAL_EXPIRED — so a purely server-side integration behaves identically. Use onEvent / on(…) for everything
between opened and the terminal status.
When to call destroy()
Call destroy() when the approver closes your UI without deciding — a backdrop tap, a swipe-away, or the component
unmounting. It removes the iframe and resolves result as CANCELLED.
After a real decision you don’t need it: the SDK cleans up on its own, and calling destroy() then does nothing.