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

# Elements 嵌入式收银台

> 把 Clink 的支付输入嵌进你自己的结账页，服务端仍然负责创建 Session。

Elements 把 Clink 管理的支付输入、钱包按钮、3DS、二维码和第三方支付交互，嵌进你自己的页面里。订单摘要、页面结构、支付按钮和状态提示都由你控制。

想最快收到钱就用 [Hosted Checkout](/cn/build-integration)。需要自己定义订单摘要、页面结构和交互时，再用 Elements。

<Note>
  `@clink-ai/clink-elements` 目前发布到 `0.0.1`，API 还可能调整。建议在 `package.json` 里锁定版本。
</Note>

## 完整链路长什么样

Elements 只替换前端那一段。**创建 Session 仍然必须在你的服务端完成**，因为那一步要用 Secret Key。

```mermaid theme={null}
sequenceDiagram
    participant B as 浏览器
    participant M as 商户后端
    participant C as Clink API
    participant E as Clink Elements
    participant W as 商户 Webhook

    B->>M: 1. 提交商品 ID 或购物车
    M->>M: 2. 校验商品与金额，创建待支付本地订单
    M->>C: 3. 用 Secret Key 调 POST /checkout/session
    C-->>M: 4. 返回 sessionId、url、expireTime 等
    M->>M: 保存 本地订单 - merchantReferenceId - sessionId 映射
    M-->>B: 5. 只返回 sessionId
    B->>E: 6. 用 sessionId + Publishable Key 初始化
    E->>E: 7. 渲染支付方式，处理提交、钱包、3DS、二维码
    C->>W: 8. 推送支付事件
    W->>M: 验签、幂等、更新本地订单
    B->>M: 9. 查询商户订单状态
    M-->>B: 返回最终结果
```

第 1 步要提交的是**商品标识**，不是浏览器算好的最终金额。金额由第 2 步在服务端重新算。

### 四方职责

| 参与方                  | 负责                                                  | 不能做                                               |
| -------------------- | --------------------------------------------------- | ------------------------------------------------- |
| 商户前端                 | 调用你自己的包装接口、初始化并挂载 Elements、按 SDK 事件更新 UI、查询商户订单状态   | 不能持有 Secret Key，不能直接调 Clink 创建 Session，不能自行认定付款成功 |
| 商户后端                 | 校验商品与金额、创建本地订单、调 Clink、保存 ID 映射、返回前端安全字段、处理 Webhook | 不能把 Secret Key 或 Clink 的完整原始响应透传给浏览器              |
| Clink API / Elements | 创建 Session，渲染安全支付 UI，处理提交、第三方按钮、3DS 和二维码            | 不负责你的本地订单和履约                                      |
| Clink Webhook        | 把服务端的支付状态送到你的后端                                     | 不能被前端事件替代                                         |

## 一、服务端：写一个你自己的 Session 接口

这一层不是可选优化。它同时是三条边界：**Secret Key 的安全边界、金额校验的边界、本地订单关联的边界**。

下面用的 `clinkRequest` 就是 [Hosted Checkout 接入](/cn/build-integration) 里那个带认证头的 helper，它返回的是响应中的 `data`。

```javascript theme={null}
// 这是你自己的后端接口，不是 Clink 的浏览器 API
app.post('/api/payments/elements-session', requireUser, async (req, res) => {
  const { productId, quantity } = req.body;

  // 金额在服务端重算，不信浏览器传来的价格
  const quote = await catalog.quote(productId, quantity);

  const order = await orders.createPending({
    customerId: req.user.id,
    amount: quote.amount,
    currency: quote.currency,
  });

  const session = await clinkRequest('/checkout/session', {
    customerEmail: req.user.email,
    merchantReferenceId: order.id,
    originalAmount: quote.amount,
    originalCurrency: quote.currency,
    priceDataList: quote.items,
    uiMode: 'elements',
    returnUrl: `${APP_ORIGIN}/payment/return?session_id={ELEMENTS_SESSION_ID}`,
  });

  await orders.bindSession(order.id, session.sessionId);

  // 这是你自己定义的前端响应，越少越好
  res.json({ sessionId: session.sessionId });
});
```

三个要点：

* `uiMode` 传 `elements`，`returnUrl` 必填。URL 里可以放 `{ELEMENTS_SESSION_ID}`，Clink 会替换成真实 Session ID
* 字段名是 `returnUrl`。有些旧资料写成 `redirectUrl`，那是错的——`redirectUrl` 是 `requires_action` 场景下响应里的跳转地址
* `merchantReferenceId` 只用于关联对账，**不是幂等键**。同一个值调两次会得到两个 Session

### Clink 返回什么

上面 `clinkRequest` 拿到的 `data`，节选如下：

```json theme={null}
{
  "code": 200,
  "msg": "success",
  "data": {
    "sessionId": "sess_xxxxxxxx",
    "uiMode": "elements",
    "returnUrl": "https://merchant.example.com/payment/return?session_id=sess_xxxxxxxx",
    "url": "https://uat-checkout.clinkbill.com/...",
    "merchantReferenceId": "merchant_order_xxx",
    "expireTime": "2026-07-30T12:00:00Z"
  }
}
```

这是节选，完整字段以 [Create checkout session](/api-reference/endpoint/create-checkout-session) 为准。两件事要注意：

* **响应里没有 Publishable Key，也没有 `environment`。** 这两样来自你自己的应用配置，见下一节
* 响应里**有** `url`，那是托管收银台地址。Elements 接入不使用它

<Warning>
  不要把 Clink 的原始响应整个转发给浏览器。你的接口应该只返回前端真正需要的字段，推荐就一个 `sessionId`。
</Warning>

## 二、前端：调你自己的接口，然后初始化

浏览器只做两件事：向你自己的后端要一个 `sessionId`，然后用它初始化 SDK。

```javascript theme={null}
import { loadClinkElements } from '@clink-ai/clink-elements';

// 调的是你自己的后端，不是 Clink
const response = await fetch('/api/payments/elements-session', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ productId, quantity }),
});

if (!response.ok) throw new Error('Unable to create checkout session');
const { sessionId } = await response.json();

const clink = await loadClinkElements({
  sessionId,
  publishKey: PUBLIC_CLINK_PUBLISHABLE_KEY,
  environment: 'sandbox',
  presetOptions: {
    locale: 'zh-CN',
    theme: 'light',
    primaryColor: '#1677FF',
  },
});
```

`PUBLIC_CLINK_PUBLISHABLE_KEY` 和 `environment` 是**你的应用部署配置**，来自后台 **开发者 > API Keys** 里的 Publishable Key（`pk_uat_` 开头）。它们可以公开出现在浏览器里，但不来自 Create Session 的响应。

<Tabs>
  <Tab title="Vite">
    ```bash theme={null}
    # .env
    VITE_CLINK_PUBLISHABLE_KEY=pk_uat_xxxxxxxx
    VITE_CLINK_ENVIRONMENT=sandbox
    ```
  </Tab>

  <Tab title="Next.js">
    ```bash theme={null}
    # .env.local
    NEXT_PUBLIC_CLINK_PUBLISHABLE_KEY=pk_uat_xxxxxxxx
    NEXT_PUBLIC_CLINK_ENVIRONMENT=sandbox
    ```
  </Tab>
</Tabs>

<Note>
  SDK 的参数名是 `publishKey`，正文里说的 Publishable Key 就是它。代码里必须写 `publishKey`。
</Note>

## 三、挂载、提交与第三方按钮

结账页上可能出现两类按钮，处理方式完全不同：

**你自己的支付按钮** —— 银行卡这类需要宿主触发提交的流程。点击后调 `clink.submit()`。

**SDK 内置的第三方按钮** —— Apple Pay、Google Pay、PayPal 等。它们由 `paymentMethod` 组件**内部渲染**，点击也由 SDK 接管。这时 SDK 会发 `submit-visible: false`，你要把自己的按钮藏起来。

```javascript theme={null}
const paymentMethod = clink.createElement('paymentMethod');
paymentMethod.mount('#payment-method');

// 需要客户选币种时才创建，且必须晚于 paymentMethod
const currencySelect = clink.createElement('currencySelect');
currencySelect.mount('#currency-select');

let canSubmit = false;
let submitting = false;

clink.on('submit-enabled', (enabled) => {
  canSubmit = enabled;
  payButton.disabled = !enabled || submitting;
});

// 第三方按钮接管提交时隐藏自己的按钮
clink.on('submit-visible', (visible) => {
  payButton.hidden = !visible;
});

payButton.addEventListener('click', () => {
  if (!canSubmit || submitting) return;
  submitting = true;
  payButton.disabled = true;
  clink.submit();
});
```

<Warning>
  三件事别做：

  * **别按支付方式名称硬编码按钮显隐。** 一切以 `submit-visible` 为准
  * **别在自己的页面里再画一套 Apple Pay、Google Pay 或 PayPal 按钮。** 那些由 SDK 渲染，你画的那套点了没用
  * **别对第三方按钮调 `clink.submit()`。** 它们的点击由 SDK 处理

  另外，第三方方式会不会出现，取决于商户配置、Session、币种、浏览器和设备能力。不要在页面上承诺每次都有。
</Warning>

`submit-enabled` 报告的是「能不能提交」。按钮写 `disabled = !enabled`，别把事件值直接塞进 `disabled`——那样逻辑正好反过来。

## 四、事件

按用途分两组。

**更新宿主 UI**

| 事件                     | 你要做什么              |
| ---------------------- | ------------------ |
| `session-init-success` | 清掉自己的骨架屏           |
| `submit-enabled`       | 控制支付按钮和优惠码控件的可用状态  |
| `submit-visible`       | 第三方按钮接管时隐藏自己的支付按钮  |
| `amount-change`        | 更新总价、币种、优惠、税费和按钮文案 |
| `promo-code-error`     | 结束优惠码加载态，显示输入级错误   |
| `error`                | 按错误类型分别处理，见下面的错误表  |

**流程提示**

| 事件                | 你要做什么     |
| ----------------- | --------- |
| `session-success` | 跳到你自己的结果页 |
| `session-pending` | 显示「支付确认中」 |

<Warning>
  `session-success` **不是付款凭证**，`returnUrl` 也不是。浏览器里的事件可以被伪造。

  正确做法是：收到 `session-success` 后跳到你自己的结果页，结果页查询**你自己后端**的订单状态。发货、充值、开通权益只能由后端根据验签后的 Webhook 和服务端查询结果决定。

  验签、去重和订单匹配见 [Hosted Checkout 接入](/cn/build-integration)。
</Warning>

## 五、优惠码

可选能力。服务端的 Coupon 和 Promotion Code 怎么建，见 [优惠与促销码](/cn/promotions)。这里只讲前端。

```javascript theme={null}
clink.on('amount-change', ({ amount }) => {
  promoSection.hidden = !amount.enablePromotionCode;
  renderApplied(amount.promotionCodeInfo);
  updateTotal(amount.dueTodayAmount, amount.currency);
});

applyButton.addEventListener('click', () => {
  setPromoLoading(true);
  clink.promoCodeChange({ type: 'apply', code: promoInput.value });
});

removeButton.addEventListener('click', () => {
  setPromoLoading(true);
  clink.promoCodeChange({ type: 'clear' });
});

clink.on('promo-code-error', ({ message }) => {
  setPromoLoading(false);
  showPromoError(message);
});
```

回调参数是 `{ amount }`，金额字段在 `amount` 里面。写成 `(info) => info.enablePromotionCode` 拿到的是 `undefined`，优惠码入口会一直不显示。

折扣和最终应付金额以 `amount` 返回的为准，不要自己算。

## 六、错误处理

| 错误                         | 什么情况                       | 怎么办                                            |
| -------------------------- | -------------------------- | ---------------------------------------------- |
| `SessionExpiredError`      | Session 过期                 | 让后端建一个新 Session，重新初始化                          |
| `SessionCompleteError`     | 这个 Session 已经付过了           | 显示已完成，刷新你自己的订单状态                               |
| `SessionLoadError`         | 加载失败                       | 允许重试，或重建 Session                               |
| `SessionNotSupportedError` | 这个 Session 不是给 Elements 用的 | 后端创建时 `uiMode` 没传 `elements`，改了重建              |
| `ClinkApiError`            | 商户或 Session 校验没过           | 检查 `publishKey`、`environment`、`sessionId` 是否配套 |
| `PromoCodeError`           | 优惠码有问题                     | 保持支付表单可用，只在优惠码区域提示                             |

`SessionNotSupportedError` 是接入初期最常见的一个，基本都是后端还在用 `uiMode: "hostedPage"`。

## 七、实现约束

这几条写错会直接导致接入失败，其余布局细节按你自己的设计来就行。

| 约束                | 写错的后果                         | 怎么做                                                     |
| ----------------- | ----------------------------- | ------------------------------------------------------- |
| 一个实例只对应一个 Session | 复用旧 Session 会加载到已完成或已过期状态     | `sessionId` 变化时销毁旧实例并重新初始化                              |
| 创建顺序固定            | `currencySelect` 先创建会抛错       | 先 `paymentMethod`，再创建可选的 `currencySelect`；每种组件每实例只能创建一次 |
| 卸载必须清理            | 残留 iframe 和 window message 监听 | 路由离开或组件卸载时调 `destroy()`。重复调用可安全忽略，但销毁后的实例不能再用           |
| 容器稳定且高度可自适应       | 钱包、二维码或 3DS 交互被裁切             | 别给支付 iframe 的父级写死高度，也别用 `overflow: hidden` 裁掉动态内容       |
| SDK 只在浏览器执行       | SSR 阶段访问 DOM 报错               | Next.js 这类框架放进 client component 或客户端初始化阶段               |

## 上线自查

* [ ] 浏览器 Network 和构建产物里没有 Secret Key，也没有 Webhook 签名密钥
* [ ] 浏览器只调用你自己的 Session 包装接口，没有直接调 Clink 的 Create Session
* [ ] 后端不相信浏览器传来的最终金额，按商品或购物车重新计算
* [ ] 本地订单、`merchantReferenceId` 和 `sessionId` 三者的映射已保存
* [ ] Publishable Key 和 `environment` 来自应用公开配置，没有被当成 Create Session 的响应字段
* [ ] Apple Pay、Google Pay、PayPal 等按钮出现时，`submit-visible` 能让你自己的按钮隐藏
* [ ] `session-success` 不直接触发发货，最终状态来自验签 Webhook 和后端查询
* [ ] Session 变化或页面卸载时销毁了旧实例

## 接下来

<CardGroup cols={2}>
  <Card title="Hosted Checkout 接入" icon="wrench" href="/cn/build-integration">
    后端和 Webhook 那两块，Elements 和 Hosted Checkout 完全一样。
  </Card>

  <Card title="上线检查" icon="rocket" href="/cn/go-live">
    切生产前要过的清单。
  </Card>
</CardGroup>
