> ## Documentation Index
> Fetch the complete documentation index at: https://devdocs.paywithatoa.co.uk/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](/agent-pay/send#sca-on-a-payout) or an
[off-session charge](/agent-pay/collect#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 companion to the
`nextAction` you get back from `payment.send` / `payment.collect` — an alternative to delivering the raw `approvalUrl`.

<Info>
  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](/agent-pay/send#sca-on-a-payout)); this SDK is just a
  nicer surface for the same approval, not a separate one.
</Info>

## 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** — 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.
