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

# Agent Vault SDK

> 让 Agent 通过商户自己的 PSP 完成支付，真实卡数据始终留在 Clink PCI 边界内。

Agent Vault SDK 让 Agent 在商户结账页完成支付，全程接触不到用户的真实卡。Agent 只填写公开的占位卡；SDK 拦截页面发往商户支付服务商（PSP）的请求，由 Clink Vault 在 PCI 边界内换成真实卡后再调用 PSP。

把真实卡号交给 LLM 或脚本会带来严重的合规与泄露风险。使用 SDK 后，页面操作权留在 Agent，卡数据与授权校验留在 Clink。全部方法和参数见 [SDK API 参考](https://docs.clinkbill.com/cn/guides/agent/vault_sdk/api_reference.md)。

## 工作原理

```mermaid actions={false} theme={null}
%%{init: {"theme":"base","themeVariables":{"fontFamily":"inherit","actorBkg":"#F7F7F7","actorBorder":"#E7E7E7","actorTextColor":"#161616","actorLineColor":"#E7E7E7","signalColor":"#71717A","signalTextColor":"#3F3F46","noteBkgColor":"#F8E1CC","noteBorderColor":"#F8E1CC","noteTextColor":"#161616"},"sequence":{"width":104,"height":44,"actorMargin":40,"diagramMarginX":12,"diagramMarginY":12,"messageMargin":28,"noteMargin":14,"boxMargin":12,"wrap":false,"mirrorActors":false,"actorFontSize":14,"messageFontSize":14,"noteFontSize":14}}}%%
sequenceDiagram
  participant A as Agent
  participant P as 商户结账页
  participant S as Vault SDK
  participant V as Clink Vault
  participant M as 商户 PSP
  A->>P: 填写占位卡
  P->>S: 令牌化请求
  Note over S: 匹配规则并暂停
  S->>V: 请求模板
  Note over V: 换成真实卡
  V->>M: 调用 PSP
  M-->>V: PSP 响应
  V-->>S: 转发响应
  S-->>P: 回放给页面
```

1. **拦截**：SDK 挂载到 Agent 驱动的浏览器（Playwright 或原生 CDP），拦截页面及所有跨域 iframe 的请求。
2. **占位卡**：SDK 返回一张与用户真实卡**同品牌**的公开测试卡（如 Visa `4111 1111 1111 1111`，有效期取真实卡），Agent 把它填进结账表单。
3. **识别**：页面向 PSP 发令牌化请求时，SDK 按审核过的 PSP 规则（host + path + 字段）匹配，且卡字段必须等于本次下发的占位卡，才会暂停该请求。
4. **中继**：SDK 只把请求模板、占位卡标记、`card_token` 和白名单请求头发给 Clink Vault。
5. **换卡**：Clink Vault 校验 30 分钟支付窗口和 PSP 规则后，在 PCI 边界内把占位卡替换为真实卡，直接调用 PSP。
6. **回放**：SDK 把 PSP 的原始状态码、响应头和响应体回放给页面，页面像正常支付一样继续下单。

> **Agent / 浏览器能看到**
>
> * 卡品牌和后四位
> * 同品牌占位测试卡
> * 绑卡链接、支付状态
> * PSP 返回给页面的原生响应

> **只在 Clink PCI 边界内**
>
> * 真实卡号 PAN 与 CVV（KMS 加密存储）
> * 支付窗口与 PSP 规则校验
> * 携带真实卡号发往 PSP 的请求
> * Adyen 等客户端加密场景下的卡密文

> [!WARNING]
>
> 支付走的是商户自己的 PSP，Clink 只能看到 PSP 的 HTTP 响应。令牌化接口返回 2xx 只代表“卡已令牌化”，不等于“订单已扣款成功”。最终结果必须以商户页面的订单结果为准，并通过 `reconcile()` 上报。

## 快速接入

SDK 运行在你的应用服务端或可信的结账 Worker 中，不要运行在用户浏览器里。

| 项目 | 要求 |
| :- | :- |
| 运行时 | Node.js `>=20.19`，ESM-only |
| 浏览器驱动 | Playwright `>=1.40`（peer 依赖）；只用原生 CDP 时不需要 |
| 凭证 | Clink 签发的 `client_id` / `client_secret`（OAuth2 `client_credentials`），只放在服务端 |
| 环境 | `sandbox`（默认）→ `uat-api.clinkbill.com`；`production` → `api.clinkbill.com` |

**1. 安装 SDK**

```bash theme={null}
npm install @clink-ai/agent-vault playwright
```

**2. 挂载到结账页**

创建客户端，然后挂载到 Agent 驱动的页面。传入 `email`，用户没有可用的卡时 SDK 才能发起绑卡。

```typescript theme={null}
import { ClinkVault } from '@clink-ai/agent-vault';

const vault = new ClinkVault({
  clientId: process.env.CLINK_CLIENT_ID!,
  clientSecret: process.env.CLINK_CLIENT_SECRET!,
  environment: 'sandbox',
});

const checkout = await vault.attach(page, {
  // 已绑卡用户传保存的标识；首次使用只传 email
  customerId,
  cardToken,
  email,
  // 链接和二维码由你的代码发给用户，不经过模型
  onUserAction: (action) => {
    if (action.url) notifyUser(action.type, action.url);
    if (action.qr_image_base64) showQrCode(action.qr_image_base64);
  },
  // 绑卡完成后保存 customerId 和 cardToken
  onCardBound: (card) => saveCard(card),
  // 从商户页面读取订单结果
  resolveMerchantResult: () => readOrderResult(page),
});
```

`vault.attach(page)` 适用于 Playwright（本地，或通过 `connectOverCDP` 连云浏览器），天然覆盖跨域 iframe。不用 Playwright 时（如云浏览器 WebSocket），改用 `vault.attachToCdp(cdp, sessionId, options)`。两者返回同一个 `CheckoutController`。

**3. 循环调用 pay() 直到终态**

每次调用 `pay()` 都会在 `next_action` 里返回 Agent 下一步要做的事。执行完再调 `pay()`。`fillCard`、`clickPayButton` 是你自己实现的页面操作函数，见[核心代码示例](#核心代码示例)。

```typescript theme={null}
let step = await checkout.pay();
while (step.status === 'requires_action') {
  const action = step.next_action!;
  if (action.type === 'fill_card') {
    // 只填占位卡，不是真卡
    await fillCard(page, action.card);
  } else if (action.type === 'submit') {
    await clickPayButton(page);
  } else if (action.type === 'select_method') {
    await selectPaymentMethod(page, action.method);
  } else if (action.type === 'wait_user') {
    // pay_qr：二维码已通过 onUserAction 发给用户
    // 3ds 或 redirect：由用户在浏览器里完成
    // 这几种情况都先退出循环，在第 4 步调 reconcile()
    if (action.reason !== 'bind_card') break;
    // 绑卡：等用户完成绑卡链接
    await waitForUser(action.expires_at);
  }
  step = await checkout.pay();
}
```

| `next_action.type` | Agent 该做什么 |
| :- | :- |
| `fill_card` | 把 `card` 填进结账表单。这是占位卡，支付窗口有效时下发 |
| `submit` | 点击页面的支付按钮。SDK 默认等 30 秒拦截 PSP 请求 |
| `select_method` | 在页面选中 `method` 指定的支付方式。用于扫码支付，以及扫码确认未付款后回退到卡 |
| `wait_user` | 需要用户参与，见[需要用户参与时](#需要用户参与时) |

**4. 上报结果并卸载**

```typescript theme={null}
// declined、failed、unsupported：未发生扣款，无需上报
if (!['declined', 'failed', 'unsupported'].includes(step.status)) {
  // 等用户扫码或完成验证，页面出现订单结果
  await waitForOrderResult(page, 10 * 60_000);
  const state = await checkout.reconcile();
}
await checkout.detach();
```

`waitForOrderResult` 是你自己实现的页面函数：轮询 `readOrderResult`，直到页面出现结果或超时，见[核心代码示例](#核心代码示例)中的 `card-form.ts`。循环停在 `wait_user` 时，它等的是用户扫码、完成 3DS 或跳转；返回 `succeeded` 时，等的是订单页加载出来。

`reconcile()` 通过 `resolveMerchantResult` 读取商户订单结果并上报 Clink Vault。结账结束时必须调用 `detach()`。

| `resolveMerchantResult` 返回 | `state.status` |
| :- | :- |
| `completed` | `completed` |
| `failed` | `failed` |
| `pending` | 不变，结账保持打开；银行卡支付时为 `awaiting_merchant` |
| `requires_user_action` | `requires_user_action` |
| `unknown`，或 resolver 抛错 | `outcome_unknown`。结果无法上报 Clink Vault 时也返回它 |

如果还没有卡请求发到 PSP，或没有设置 `resolveMerchantResult`，`reconcile()` 直接返回当前状态。见[处理结果](#处理结果)。

付款前可以先调 `checkout.getPaymentMethods()` 看页面能用哪些方式：卡的状态是 `ready`、`needs_bind` 或 `unsupported`；页面上的扫码钱包和 PayPal 是 `needs_user`，问过用户后再用 `pay({ method })` 发起，见 [PSP 覆盖范围](#psp-覆盖范围)。

## 需要用户参与时

Vault 不需要 Passkey、短信验证码或逐笔确认链接。用户只需要打开一次**绑卡链接**：添加新卡，或选择已有的卡重新输入 CVV。完成后开启 30 分钟支付窗口，窗口内 Agent 无需用户参与即可完成 `fill_card` → `submit`。绑卡页由 Clink 托管，免登录、一次性。

```mermaid actions={false} theme={null}
%%{init: {"theme":"base","themeVariables":{"fontFamily":"inherit","actorBkg":"#F7F7F7","actorBorder":"#E7E7E7","actorTextColor":"#161616","actorLineColor":"#E7E7E7","signalColor":"#71717A","signalTextColor":"#3F3F46","noteBkgColor":"#F8E1CC","noteBorderColor":"#F8E1CC","noteTextColor":"#161616"},"sequence":{"width":116,"height":44,"actorMargin":64,"diagramMarginX":12,"diagramMarginY":12,"messageMargin":28,"noteMargin":14,"boxMargin":12,"wrap":false,"mirrorActors":false,"actorFontSize":14,"messageFontSize":14,"noteFontSize":14}}}%%
sequenceDiagram
  participant U as 用户
  participant A as Agent + SDK
  participant V as Clink Vault
  A->>V: pay()
  V-->>A: wait_user (bind_card)
  A-->>U: 绑卡链接
  U->>V: 添加新卡或重输 CVV
  Note over V: 开启 30 分钟支付窗口
  A->>V: pay()
  V-->>A: 绑卡完成
  Note over A: 触发 onCardBound，返回 fill_card
```

`next_action.type` 为 `wait_user` 时，`reason` 说明用户要做什么。同一个动作也会传给 `onUserAction`。

| `reason` | 触发条件 | 接入方要做的事 |
| :- | :- | :- |
| `bind_card` | 没有可用的卡：未绑卡，或支付窗口已过期。`attach` 时需要传 `email` | 把链接发给用户，然后继续调 `pay()`。绑卡完成后，SDK 会调用 `onCardBound`，同一次 `pay()` 返回 `fill_card`。在 `onCardBound` 里保存 `customerId` / `cardToken`，下次直接传入 |
| `pay_qr` | 页面已展示二维码 | 由宿主直接把 `qr_image_base64` 里的 PNG 展示给用户扫码，不要经过模型。之后调 `reconcile()` |
| `3ds` | PSP 要求持卡人验证 | PSP 自己的脚本会在页面上拉起验证。让用户看到浏览器（本地窗口或云浏览器 live view），之后调 `reconcile()`。SDK 不代为完成验证 |
| `redirect` | 银行卡支付：PSP 把用户跳转到其他页面验证。PayPal：`url` 是 paypal.com 批准链接 | 银行卡支付：和 `3ds` 一样处理，没有 `url`，由用户在浏览器里完成，之后调 `reconcile()`。PayPal：把链接交给用户，SDK 到此结束 |

> [!NOTE]
>
> **通知边界：** Clink 不会自动向用户发送短信、邮件或支付通知。绑卡链接由开发者代码通过自己的 UI、消息或通知渠道下发；链接内容不要交给 LLM 拼接或改写。

你也可以不等 `pay()`，主动发起绑卡：结账中用 `checkout.bind()`，结账外用 `vault.sessions.create({ email })`。新卡和已有卡重新输入 CVV 都用这个链接。`vault.cards.list({ customerId })` 可以查看卡和窗口到期时间。

## 处理结果

| 状态 | 含义 | 处理方式 |
| :- | :- | :- |
| `succeeded` | PSP 授权成功 | 等页面出现订单结果后调 `reconcile()`，只有订单确认可见才算完成 |
| `declined` | PSP 明确拒绝，拒绝响应已回放给页面 | 告知用户，可换卡或重新发起 |
| `outcome_unknown` | 无法确定是否已扣款（超时、网络错误等） | **禁止自动重试**，先 `reconcile()` 或人工核实 |
| `unsupported` | 页面的 PSP、支付方式或卡品牌不在覆盖范围 | 换其他支付方式，或交给用户手动支付 |
| `failed` | 确定性失败，未发生扣款 | 按 `reason` 处理，见下方列表 |

`failed` 的常见 `reason`：

* `card_not_bound`：没有可用的卡，且没传 `email`，SDK 无法发起绑卡。
* `window_expired`：30 分钟支付窗口已到期，且没传 `email`。传了 `email` 时 `pay()` 直接返回 `wait_user`（`bind_card`），用户选择这张卡重新输入 CVV 即可。
* `card_frozen` / `token_revoked`：卡已冻结，或卡 token 已被撤销。请用户通过 `checkout.bind()` 绑定其他卡。
* `placeholder_mismatch`：页面请求里出现了非占位卡的卡号或 CVV，SDK 拒绝放行。
* `checkout_request_not_seen`：`submit` 后 30 秒内没拦截到 PSP 请求，检查是否真的点了支付按钮。
* `session_expired`：绑卡 session 失败或被锁定，调 `retry()` 后再 `pay()` 生成新链接。链接只是过期时不会报错，`pay()` 会自动生成新链接。
* `card_token_missing` / `card_token_not_found`：绑卡已完成，但 session 没带卡 token，或卡列表里找不到这个 token。SDK 不会再发起绑卡，需要排查服务端数据。
* `merchant_declined`：PSP 已授权，但 `resolveMerchantResult` 返回 `failed`，`reconcile()` 已上报。可调 `retry()` 重新发起。
* `paypal_redirect_url_not_found`：点击 PayPal 支付后 30 秒（`submitTimeoutMs`）内没找到 PayPal 批准链接。
* `cancelled`：支付拿到结果前调用了 `cancel()` 或 `detach()`。
* `api_error` / `network_error`：调用 Clink Vault 被拒绝或无法连接，例如 Client 凭证无效。检查凭证和网络后重试。

`unsupported` 的 `reason`：

* `rule_not_matched` / `psp_unsupported`：页面的 PSP 请求没有匹配到启用的规则，见 [PSP 覆盖范围](#psp-覆盖范围)。
* `unsupported_card_brand`：用户的卡品牌没有对应的占位卡。
* `card_encrypted`：页面把卡字段加密后发往没有规则覆盖的地址，例如商户自己的服务器。
* `cse_request_unsupported`：SDK 不中继的 Adyen 请求，例如保存卡或使用已存的卡支付。

`outcome_unknown` 的 `reason`：

* `pay_qr_unavailable`：提交后没取到可用的二维码。商户可能已受理，不能自动换卡。
* `psp_timeout` / `missing_response` / `merchant_result_unknown`：PSP 没有及时响应、Clink Vault 没有返回 PSP 响应，或 `resolveMerchantResult` 返回 `unknown` 或抛错。先核实订单，再做其他操作。

## PSP 覆盖范围

SDK 在 `attach` 时从服务端同步识别规则，新增标准 PSP 只需下发规则，不用发 SDK 版本。规则同步失败（网络错误、超时、429、5xx）或返回格式不正确时，回退到内置规则。Clink 返回其他错误（如 401 / 403）时不会回退：`attach` 仍会成功，但不加载任何规则，页面的卡请求会返回 `unsupported`（`rule_not_matched`）。Client 凭证无效时，`pay()` 会更早返回 `failed`（`api_error`）。

SDK 只支持在页面上填写占位卡的一次性支付。请关闭商户页面上保存卡的选项。如果页面使用商户已保存的卡支付，请求中没有占位卡，不会经过 Clink Vault。

* **Token 模式**：页面明文发出占位卡，Clink Vault 换成真实卡。覆盖 18 家 PSP、80 条内置规则：Stripe、Checkout.com、Airwallex、Rapyd、Global Payments、Square、Authorize.net、Nuvei、EBANX、Mercado Pago、PayU、Razorpay、Mollie、Paysafe、Recurly、Bambora、Spreedly、Shopify。
* **CSE 模式**：Adyen Sessions 支付接口（`checkoutshopper-*.adyen.com`），已启用。识别 adyen-web 的 JWE 密文和旧版 securedFields 3.x 的 `adyenjs_0_1_*` 密文。服务端用 Adyen 公钥加密真实卡后代发，页面和 Agent 都看不到密文。
* **暂不支持**：Braintree、Worldpay、dLocal、Stripe Checkout Session confirm、Hosted Form、商户自托管的 Adyen。返回 `unsupported`，加密后的占位卡不会被发出。

占位卡支持 Visa、Mastercard、American Express、Discover、JCB、UnionPay。其他卡品牌 `pay()` 返回 `unsupported`（`unsupported_card_brand`）。

除银行卡外，SDK 还支持两类**只在浏览器侧完成**的支付方式：不走 Clink Vault 授权，也不发送卡 token。页面必须已经提供这种支付方式，SDK 不创建订单、不生成二维码。

* **扫码支付**（`paynow`、`wechat_pay`、`upi_qr`、`pix`、`bcmc_mobile`）：从页面或 iframe 取出该钱包自己的二维码（跳过 logo 和其他钱包的码），统一转成 PNG 返回。PayNow 的过期时间取二维码里写的到期时间，其他默认 10 分钟。付款结果以 `reconcile()` 为准。
* **PayPal 链接**（`paypal`）：Agent 选中 PayPal 并点击支付后，调一次 `pay({ method: 'paypal' })`，返回 paypal.com 批准链接。之后用户在 PayPal 的操作不归 SDK 管。

`fallback: 'card'` 只用于扫码：`resolveMerchantResult` 明确返回 `{ status: 'failed', payment_confirmed_unpaid: true }` 时才回退到卡；结果未知、待定或二维码本地过期都不会自动回退。

## 核心代码示例

以下示例都运行在你的服务端。不同商户的结账页结构不同，请按 Agent 实际操作的结账页调整选择器和页面判断。

### 完整结账流程

`checkout.ts` 从挂载到上报，完成一次银行卡支付。`card-form.ts` 负责填写占位卡、点击支付和读取商户订单结果。`loadVaultCard`、`saveVaultCard`、`notifyUser`、`showQrCode` 是你应用自己的函数。

```typescript checkout.ts theme={null}
import { chromium } from 'playwright';
import { ClinkVault, type CheckoutState } from '@clink-ai/agent-vault';
import {
  clickPayButton,
  fillCard,
  readOrderResult,
  waitForOrderResult,
} from './card-form.js';
import {
  loadVaultCard,
  notifyUser,
  saveVaultCard,
  showQrCode,
} from './app.js';

const vault = new ClinkVault({
  clientId: process.env.CLINK_CLIENT_ID!,
  clientSecret: process.env.CLINK_CLIENT_SECRET!,
  environment: 'sandbox',
});

export async function payOnCheckout(
  user: { id: string; email: string },
  checkoutUrl: string,
): Promise<CheckoutState> {
  // 有界面的浏览器，PSP 要求 3DS 时用户可以直接完成
  const browser = await chromium.launch({ headless: false });
  const page = await browser.newPage();
  const saved = await loadVaultCard(user.id);

  const checkout = await vault.attach(page, {
    customerId: saved?.customerId,
    cardToken: saved?.cardToken,
    email: user.email,
    // 链接和二维码由你的代码发给用户，不经过模型
    onUserAction: async (action) => {
      if (action.url) await notifyUser(user.id, action.type, action.url);
      if (action.qr_image_base64) {
        await showQrCode(user.id, action.qr_image_base64);
      }
    },
    onCardBound: (card) => saveVaultCard(user.id, card),
    resolveMerchantResult: () => readOrderResult(page),
  });

  try {
    await page.goto(checkoutUrl);
    // Agent 在这里填写联系人和收货信息

    const setupDeadline = Date.now() + 15 * 60_000;
    let step = await checkout.pay();
    while (step.status === 'requires_action') {
      const action = step.next_action!;
      if (action.type === 'fill_card') {
        await fillCard(page, action.card);
      } else if (action.type === 'submit') {
        await clickPayButton(page);
      } else if (action.type === 'wait_user') {
        // 扫码、3DS 或跳转：用户完成后再 reconcile
        if (action.reason !== 'bind_card') break;
        // 绑卡：轮询直到用户完成绑卡链接
        if (Date.now() > setupDeadline) {
          await checkout.cancel();
          return checkout.getState();
        }
        await page.waitForTimeout(2_000);
      }
      step = await checkout.pay();
    }

    // 未发生扣款
    if (['declined', 'failed', 'unsupported'].includes(step.status)) {
      return checkout.getState();
    }

    // succeeded、outcome_unknown，或用户正在验证
    await waitForOrderResult(page, 10 * 60_000);
    return await checkout.reconcile();
  } finally {
    await checkout.detach();
    await browser.close();
  }
}
```

```typescript card-form.ts theme={null}
import type { Page } from 'playwright';
import type { FillCard, MerchantResult } from '@clink-ai/agent-vault';

// 示例选择器，请按商户结账页调整
const FIELDS = {
  number: ['input[autocomplete="cc-number"]', 'input[name="cardnumber"]'],
  expiry: ['input[autocomplete="cc-exp"]', 'input[name="exp-date"]'],
  cvc: ['input[autocomplete="cc-csc"]', 'input[name="cvc"]'],
};

// 卡字段通常在 PSP 的 iframe 里，所以遍历所有 frame
export async function fillCard(page: Page, card: FillCard): Promise<void> {
  await typeInto(page, FIELDS.number, card.number);
  const expiry = `${card.exp_month}/${card.exp_year.slice(-2)}`;
  await typeInto(page, FIELDS.expiry, expiry);
  await typeInto(page, FIELDS.cvc, card.cvc);
}

async function typeInto(
  page: Page,
  selectors: string[],
  value: string,
): Promise<void> {
  for (const frame of page.frames()) {
    for (const selector of selectors) {
      const input = frame.locator(selector).first();
      if (await input.isVisible().catch(() => false)) {
        await input.fill('');
        // 逐键输入；PSP 字段常常忽略直接 fill()
        await input.pressSequentially(value, { delay: 30 });
        return;
      }
    }
  }
  throw new Error(`Card field not found: ${selectors[0]}`);
}

export async function clickPayButton(page: Page): Promise<void> {
  const name = /pay now|place order|complete order/i;
  await page.getByRole('button', { name }).first().click();
}

// reconcile() 时调用，只上报商户页面实际展示的结果
export async function readOrderResult(page: Page): Promise<MerchantResult> {
  const confirmed = page.getByText(/thank you for your order/i);
  if (await confirmed.first().isVisible()) {
    const orderId = await page.locator('.order-number').first()
      .textContent({ timeout: 1_000 })
      .catch(() => null);
    return orderId
      ? { status: 'completed', order_id: orderId.trim() }
      : { status: 'completed' };
  }
  const declined = page.getByText(/payment (declined|failed)/i);
  if (await declined.first().isVisible()) {
    return { status: 'failed' };
  }
  return { status: 'unknown' };
}

export async function waitForOrderResult(
  page: Page,
  timeoutMs: number,
): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if ((await readOrderResult(page)).status !== 'unknown') return;
    await page.waitForTimeout(2_000);
  }
}
```

### 把支付步骤封装为 Agent 工具

由 LLM 决定每一步时，给它推进支付的工具，而不是卡数据。模型只能看到状态和下一步；卡由你的代码填写，订单结果由 `resolveMerchantResult` 从页面读取，模型无法上报商户页面没有展示的成功结果。

```typescript agent-tools.ts theme={null}
import type { Page } from 'playwright';
import type { CheckoutController, PayStep } from '@clink-ai/agent-vault';
import { clickPayButton, fillCard } from './card-form.js';

// 在你的 Agent 框架里把这些函数注册为工具
export function createPaymentTools(
  page: Page,
  checkout: CheckoutController,
) {
  let step: PayStep | undefined;

  return {
    async start_payment() {
      step = await checkout.pay();
      return forModel(step);
    },
    async fill_placeholder_card() {
      if (step?.next_action?.type !== 'fill_card') {
        return { error: 'Call start_payment first' };
      }
      await fillCard(page, step.next_action.card);
      step = await checkout.pay();
      return forModel(step);
    },
    async submit_payment() {
      if (step?.next_action?.type !== 'submit') {
        return { error: 'Fill the card first' };
      }
      await clickPayButton(page);
      step = await checkout.pay();
      return forModel(step);
    },
    // resolveMerchantResult 读取页面结果，模型无法自行上报成功
    async reconcile_checkout() {
      return checkout.reconcile();
    },
  };
}

// 返回给模型前去掉卡号和二维码图片
function forModel(step: PayStep) {
  const { qr_image_base64: _image, qr_data: _data, ...rest } = step;
  const action = rest.next_action;
  if (action?.type === 'fill_card') {
    return { ...rest, next_action: { type: 'fill_card' } };
  }
  if (action?.type === 'wait_user') {
    const { qr_image_base64: _img, qr_data: _qr, ...next } = action;
    return { ...rest, next_action: next };
  }
  return rest;
}
```

### 使用云浏览器

SDK 不负责启动或管理浏览器。使用云浏览器时，挂载到 Agent 已经在操作的页面，然后执行同样的 `pay()` 循环。

```typescript Playwright (connectOverCDP) theme={null}
import { chromium } from 'playwright';

// 云浏览器的 CDP 地址，如 Browserbase 会话的 connectUrl
const browser = await chromium.connectOverCDP(connectUrl);
const context = browser.contexts()[0] ?? (await browser.newContext());
const page = context.pages()[0] ?? (await context.newPage());

const checkout = await vault.attach(page, { customerId, cardToken, email });
await page.goto(checkoutUrl);
```

```typescript 原生 CDP theme={null}
// cdp：flatten 模式的 CDP 连接，实现 send() 和 on/off
const { targetId } = (await cdp.send('Target.createTarget', {
  url: checkoutUrl,
})) as { targetId: string };
const { sessionId } = (await cdp.send('Target.attachToTarget', {
  targetId,
  flatten: true,
})) as { sessionId: string };

const checkout = await vault.attachToCdp(cdp, sessionId, {
  customerId,
  cardToken,
  email,
});
```

云浏览器的 API Key 和 Clink Client Secret 只放在服务端，不要交给 Agent。遇到 3DS 时，把云浏览器的 live view 展示给用户完成验证。


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