> ## Documentation Index
> Fetch the complete documentation index at: https://docs.paywithatoa.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent Pay Approvals SDK for Human Sign-Off

> Embed Atoa's hosted approval page in your own web UI so a human approves gated agent payouts and charges with a one-time code or a passkey.

Every gated payment — a [payout](https://docs.paywithatoa.com/agent-pay/send.md#sca-on-a-payout) or an
[off-session charge](https://docs.paywithatoa.com/agent-pay/collect.md#sca-on-a-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-side partner of the
`nextAction` you get back from `payment.send` / `payment.collect` — an alternative to sending the raw `approvalUrl`.

> [!NOTE]
>
> 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](https://docs.paywithatoa.com/agent-pay/send.md#sca-on-a-payout)); this SDK is just a
> nicer surface for the same approval, not a separate one.

## Install

```bash theme={null}
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.

```typescript theme={null}
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](#theming). Amounts, warnings, and the decline button are never restyled. |
| `onEvent` | — | Fires for every [lifecycle event](#events) (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.

```typescript theme={null}
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** — they tap the backdrop, swipe the sheet away,
or your component unmounts. 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.