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.

OptionDefaultNotes
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.
colorSchemelight"light" or "dark". Anything else (incl. undefined) normalizes to light. Independent of theme.
themeBounded branding tokens — hex/length-validated and contrast-clamped. See Theming. Amounts, warnings, and the decline button are never restyled.
onEventFires for every lifecycle event (same stream as on(…)).
onResultTerminal-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.

typeEmitted whenPayloadTerminal?
openedSynchronously, the instant the handle is created. SDK-emitted.no
loadedThe page loaded and resolved which CTA it will show.method?: "PASSKEY" | "SETUP_OFFERED" | "OTP_ONLY"no
approvedThe human approved.decidedBy?: stringyesAPPROVED
declinedThe human declined.decidedBy?: stringyesDECLINED
expiredThe approval window lapsed before a decision.yesEXPIRED
supersededA newer approval for the same action replaced this one.yesSUPERSEDED
errorA recoverable page error (bad secret state, network, …).reason: stringno
closedYou 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 }:

StatusSource
APPROVEDapproved event
DECLINEDdeclined event
EXPIREDexpired event
SUPERSEDEDsuperseded event
CANCELLEDYou 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.