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

# Hosted Checkout 接入

> 后端、前端、Webhook 三块分别要写什么代码。

这一页假设你已经选好了接入方式。如果还没有，先看 [选择接入方式](/cn/choose-integration)。

下面的例子用 Hosted Checkout + Node.js/Express。换成别的语言或框架，结构是一样的。

<Note>
  这一页讲的订单模型、Session 创建和 Webhook 处理，是**所有付款共用的基础**——一次性支付、订阅、带优惠的支付都建在它上面。

  示例本身走的是一次性支付。周期收费另见 [订阅支付](/cn/subscriptions)，折扣另见 [优惠与促销码](/cn/promotions)。
</Note>

## 你至少要写三个接口

| 接口                          | 干什么                                 | 别做什么              |
| --------------------------- | ----------------------------------- | ----------------- |
| `POST /api/checkout/create` | 校验商品和金额，建你自己的订单，再调 Clink 创建 Session | 别让前端决定价格          |
| `GET /api/orders/:id`       | 给回跳页面查订单状态                          | 别把 Clink 的原始响应透出去 |
| `POST /api/webhooks/clink`  | 接收事件，验签，更新订单，触发发货                   | 别在没验签的情况下信任请求内容   |

## 商户订单要存什么

至少这些字段，不然出问题时排查不了：

```sql theme={null}
merchant_order_id      -- 你的订单号
customer_id            -- 你系统里的用户
product_snapshot       -- 商品名、单价、数量（下单那一刻的快照）
original_amount        -- 金额
original_currency      -- 币种
clink_session_id       -- Clink 返回的 sessionId
clink_order_id         -- 付款产生的 orderId
payment_status         -- 你自己的支付状态
fulfillment_status     -- 发货状态，和支付状态分开存
created_at
updated_at
```

商品快照要存下单那一刻的值。商品涨价之后，你还得知道这个客户当时买的是多少钱。

支付状态和发货状态分开存，是因为它们会不同步：钱收到了但发货失败，需要能查出来重试。

## 1. 创建订单和 Session

<CodeGroup>
  ```javascript Node.js（直接调 API） theme={null}
  import crypto from 'node:crypto';

  const CLINK_API = 'https://uat-api.clinkbill.com/api';

  async function clinkRequest(path, body) {
    const res = await fetch(`${CLINK_API}${path}`, {
      method: 'POST',
      headers: {
        'X-API-Key': process.env.CLINK_SECRET_KEY,
        'X-Timestamp': String(Date.now()),
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    });

    const json = await res.json();
    if (json.code !== 200) {
      throw new Error(`Clink ${path} failed: ${json.code} ${json.msg}`);
    }
    return json.data;
  }

  app.post('/api/checkout/create', async (req, res) => {
    const { productId, quantity } = req.body;
    const user = req.user;

    // 价格从你自己的数据库取，不要信前端传来的金额
    const product = await db.products.findById(productId);

    // 价格按整数分存，算完再转回元
    const unitAmount = product.unitAmountMinor / 100;              // 1999 → 19.99
    const amount = (product.unitAmountMinor * quantity) / 100;

    const order = await db.orders.create({
      customerId: user.id,
      productSnapshot: { name: product.name, unitAmount, quantity },
      originalAmount: amount,
      originalCurrency: 'USD',
      paymentStatus: 'created',
      fulfillmentStatus: 'none',
    });

    const session = await clinkRequest('/checkout/session', {
      customerEmail: user.email,
      referenceCustomerId: user.id,
      originalAmount: amount,
      originalCurrency: 'USD',
      merchantReferenceId: order.id,
      uiMode: 'hostedPage',
      priceDataList: [
        { name: product.name, quantity, unitAmount, currency: 'USD' },
      ],
      successUrl: `https://your-site.com/pay/result?orderId=${order.id}`,
      cancelUrl: `https://your-site.com/pay/cancel?orderId=${order.id}`,
    });

    await db.orders.update(order.id, {
      clinkSessionId: session.sessionId,
      paymentStatus: 'pending',
    });

    res.json({ merchantOrderId: order.id, checkoutUrl: session.url });
  });
  ```

  ```javascript Node.js（用服务端 SDK） theme={null}
  import { ClinkPayClient } from '@clink-ai/clink-typescript-sdk';

  const client = new ClinkPayClient({
    apiKey: process.env.CLINK_SECRET_KEY,
    env: 'sandbox',
  });

  app.post('/api/checkout/create', async (req, res) => {
    const { productId, quantity } = req.body;
    const user = req.user;

    const product = await db.products.findById(productId);

    const unitAmount = product.unitAmountMinor / 100;
    const amount = (product.unitAmountMinor * quantity) / 100;

    const order = await db.orders.create({
      customerId: user.id,
      productSnapshot: { name: product.name, unitAmount, quantity },
      originalAmount: amount,
      originalCurrency: 'USD',
      paymentStatus: 'created',
      fulfillmentStatus: 'none',
    });

    const session = await client.createCheckoutSession({
      customerEmail: user.email,
      referenceCustomerId: user.id,
      originalAmount: amount,
      originalCurrency: 'USD',
      merchantReferenceId: order.id,
      uiMode: 'hostedPage',
      priceDataList: [
        { name: product.name, quantity, unitAmount, currency: 'USD' },
      ],
      successUrl: `https://your-site.com/pay/result?orderId=${order.id}`,
      cancelUrl: `https://your-site.com/pay/cancel?orderId=${order.id}`,
    });

    await db.orders.update(order.id, {
      clinkSessionId: session.sessionId,
      paymentStatus: 'pending',
    });

    res.json({ merchantOrderId: order.id, checkoutUrl: session.url });
  });
  ```
</CodeGroup>

<Note>
  服务端也可以用 [`@clink-ai/clink-typescript-sdk`](/cn/api-reference/SDK)，它替你处理认证请求头和类型定义。上面直接调 API 的写法是为了让你看清每个字段的位置。
</Note>

几个容易踩的点：

* **金额从数据库取**。前端传过来的价格一律不信，否则客户改个请求就能一块钱买走会员。
* **金额是主货币单位，不是分**。19.99 美元传 `19.99`。传 `1999` 会被当成 1999 美元，扣款差 100 倍。日元、韩元、印尼盾这类零小数位币种只能传整数。
* **别用浮点直接乘金额**。JavaScript 里 `0.1 * 3` 等于 `0.30000000000000004`，而 Clink 会精确比对商品明细之和与总额，这点误差就会被拒。价格按整数分存，乘完数量再转回主单位；金额结构复杂时用 decimal 类库。
* **`merchantReferenceId` 填你自己的订单号**。Clink 不拿它做幂等——同一个值调两次会得到两个 Session。防重复下单是你自己的事。
* **`referenceCustomerId` 填你系统里的用户 ID**。以后再给同一个客户创建 Session，Clink 能自动关联到同一个 Clink 客户。

## 2. 回跳页面

客户付完款跳回 `successUrl`。这个页面要做的事只有一件：查你自己后端的订单状态，把结果显示出来。

```javascript theme={null}
// 回跳页面
const orderId = new URLSearchParams(location.search).get('orderId');
const order = await fetch(`/api/orders/${orderId}`).then((r) => r.json());

if (order.paymentStatus === 'paid') {
  showSuccess();
} else if (order.paymentStatus === 'pending') {
  showPending();   // 「支付确认中」，几秒后再查一次
} else {
  showFailed();
}
```

刚跳回来时 Webhook 可能还没到，订单状态还是 `pending`。这时候显示「支付确认中」，隔几秒再查一次，别直接显示失败。

如果一直是 `pending`，你的后端可以主动查一次 `GET /checkout/session/{id}` 补上状态。

## 3. 接收 Webhook

这是整个接入里最要紧的一段。付款结果以这里为准。

### 验签

Clink 用 HMAC SHA-256 签名，签的是 `时间戳 + "." + 原始请求体`。

Node 环境直接用 SDK 就行，不用自己写：

```javascript theme={null}
import { ClinkWebhook, ClinkWebhookSignatureError } from '@clink-ai/clink-typescript-sdk';

const webhook = new ClinkWebhook({
  signatureKey: process.env.CLINK_WEBHOOK_SIGNING_KEY,
});

// 验签失败会抛 ClinkWebhookSignatureError，成功则返回解析好的事件
const event = webhook.verifyAndGet({
  timestamp: req.headers['x-clink-timestamp'],
  body: rawBody,
  headerSignature: req.headers['x-clink-signature'],
});
```

<Warning>
  必须用**原始请求体**验签。JSON 解析之后再序列化回去，字段顺序和空格都可能变，算出来的签名一定对不上。Express 里要用 `express.raw()`，不能用 `express.json()`。
</Warning>

<Accordion icon="code" title="其他语言：自己算 HMAC">
  签名规则很简单，任何语言都好实现：

  ```javascript theme={null}
  import crypto from 'node:crypto';

  function verifySignature(rawBody, headers) {
    const timestamp = headers['x-clink-timestamp'];
    const signature = headers['x-clink-signature'];
    const signType = headers['x-clink-signtype'];

    if (signType !== 'SHA256') return false;

    const expected = crypto
      .createHmac('sha256', process.env.CLINK_WEBHOOK_SIGNING_KEY)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

    // 用定长比较，避免时序攻击
    const a = Buffer.from(expected);
    const b = Buffer.from(signature ?? '');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  三个请求头：`X-Clink-Timestamp` 是时间戳，`X-Clink-Signature` 是签名，`X-Clink-SignType` 目前固定为 `SHA256`。
</Accordion>

### 完整的处理器

```javascript theme={null}
// 注意：express.raw()，不是 express.json()
app.post(
  '/api/webhooks/clink',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody = req.body.toString('utf8');

    if (!verifySignature(rawBody, req.headers)) {
      return res.status(401).send('invalid signature');
    }

    const event = JSON.parse(rawBody);

    // 1. 幂等：同一个事件 ID 只处理一次
    const seen = await db.webhookEvents.findById(event.id);
    if (seen) return res.status(200).send('ok');

    try {
      await handleEvent(event);
      await db.webhookEvents.create({ id: event.id, type: event.type });
    } catch (err) {
      // 处理失败就别返回 2xx，让 Clink 重试
      logger.error({ err, eventId: event.id }, 'webhook failed');
      return res.status(500).send('retry later');
    }

    // 2. 状态写入成功之后才返回 200
    res.status(200).send('ok');
  }
);

async function handleEvent(event) {
  const obj = event.data.object;

  // 退款事件里没有你的订单号，用 orderId 反查
  if (event.type.startsWith('refund.')) {
    const local = await db.orders.findByClinkOrderId(obj.orderId);
    if (!local) return;

    if (event.type === 'refund.succeeded') {
      await db.orders.update(local.id, { paymentStatus: 'refunded' });
      await revokeOnce(local.id);
    }
    return;
  }

  if (!event.type.startsWith('order.')) return;

  // 下面这三步对所有 order.* 事件都一样，所以放在 switch 外面

  // 1. 双重匹配：订单号和 Session 都要对得上
  const local = await db.orders.findById(obj.merchantReferenceId);
  if (!local || local.clinkSessionId !== obj.sessionId) {
    logger.warn({ orderId: obj.orderId }, 'order mismatch, skipped');
    return;
  }

  // 2. 已经是终态就不动了。迟到的失败事件不能把成功改回去
  if (['paid', 'refunded'].includes(local.paymentStatus)) return;

  switch (event.type) {
    case 'order.succeeded':
      await db.orders.update(local.id, {
        paymentStatus: 'paid',
        clinkOrderId: obj.orderId,
      });
      await fulfillOnce(local.id);       // 3. 发货本身也要幂等
      break;

    case 'order.failed':
      await db.orders.update(local.id, {
        paymentStatus: 'payment_failed',
        failureCode: obj.failureCode,
        failureMessage: obj.failureMessage,
      });
      break;

    case 'order.next_action':
      await db.orders.update(local.id, { paymentStatus: 'action_required' });
      break;
  }
}
```

<Warning>
  匹配订单、终态保护、幂等发货这三件事要对**所有** `order.*` 事件生效，不能只写在成功分支里。

  常见的写法是成功事件做了完整校验，失败事件却直接按订单号改成失败——一个迟到的 `order.failed` 就能把已经收到的钱标记成失败，货也退了。
</Warning>

退款事件要单独处理，因为它的结构不一样：`refund.*` 的对象里没有 `merchantReferenceId`，只有 `orderId` 和 `refundMerchantOrderId`。所以要用付款时存下的 `clinkOrderId` 反查本地订单。

### 这五件事一件都不能少

<Steps>
  <Step title="验签">
    用原始请求体算 HMAC SHA-256，比对 `X-Clink-Signature`。签名对不上就返回 401。
  </Step>

  <Step title="按事件 ID 去重">
    投递失败 Clink 会重试，同一个事件你会收到多遍。用 `event.id` 去重，重复的直接返回 200。
  </Step>

  <Step title="双重匹配订单">
    同时校验 `merchantReferenceId` 和 `sessionId`。只对上一个就当异常处理，别更新状态。
  </Step>

  <Step title="容忍乱序">
    Clink 不保证事件按发生顺序到达。已经是 `paid` 或 `refunded` 的订单，不能被后到的旧事件改回 `pending`。
  </Step>

  <Step title="写库成功后再返回 2xx">
    返回 200 表示"我处理完了"。还没落库就返回 200，这个事件就永远丢了。
  </Step>
</Steps>

### 订阅哪些事件

一次性支付至少订阅这几个：

| 事件                  | 你要做什么                             |
| ------------------- | --------------------------------- |
| `order.succeeded`   | 确认收款，触发发货                         |
| `order.failed`      | 记录失败原因，允许客户重试                     |
| `order.next_action` | 客户需要额外验证，订单进入等待状态                 |
| `session.expired`   | 支付入口过期。注意别覆盖后到的 `order.succeeded` |
| `refund.succeeded`  | 退款到账，回收权益                         |

做订阅业务的话，订阅和账单事件另有一套，见 [订阅支付](/cn/subscriptions)。完整事件列表见 [Webhook 参考](/api-reference/webhook/order)，也可以调 `GET /webhook/events` 查当前支持的事件名。

<Note>
  订阅时要写完整的事件名。`subscription.*` 这类通配符不是能提交给 API 的值。
</Note>

### 本地怎么调

Webhook 地址必须是公网可达的 HTTPS，localhost、回环地址和内网 IP 都会被拒绝。

如果你已经有可用的公网地址（预览环境、Vercel/Netlify 之类的部署地址、自己的域名），直接用那个。纯本地开发才需要隧道：

```bash theme={null}
cloudflared tunnel --url http://127.0.0.1:3000 --no-autoupdate
```

如果 QUIC 连不上，加 `--protocol http2` 重试。

隧道地址每次重启都会变，换了地址记得回后台更新 Webhook 端点。

#### 不刷卡也能调验签

隧道加真实付款能验证端到端，但反复调验签逻辑时，每次都刷一遍卡太慢。自己造一个带签名的请求打到本地就行：

```javascript theme={null}
// send-test-event.mjs
import crypto from 'node:crypto';

const event = {
  id: 'evt_test_001',                       // 换个 id 测新事件，用同一个 id 测去重
  type: 'order.succeeded',
  object: 'event',
  created: Date.now(),
  data: {
    object: {
      orderId: 'ord_test_001',
      merchantReferenceId: 'order_10001',   // 换成你库里真实存在的订单号
      sessionId: 'sess_test_001',           // 要和该订单的 clinkSessionId 一致
      status: 'success',
    },
  },
};

const body = JSON.stringify(event);
const timestamp = String(Date.now());
const signature = crypto
  .createHmac('sha256', process.env.CLINK_WEBHOOK_SIGNING_KEY)
  .update(`${timestamp}.${body}`)
  .digest('hex');

const res = await fetch('http://127.0.0.1:3000/api/webhooks/clink', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Clink-Timestamp': timestamp,
    'X-Clink-Signature': signature,
    'X-Clink-SignType': 'SHA256',
  },
  body,
});

console.log(res.status, await res.text());
```

改几个字段就能把该测的都测一遍：

| 想测什么  | 怎么改                                                  |
| ----- | ---------------------------------------------------- |
| 重复投递  | 同一个 `id` 跑两次，第二次应该直接返回 200 且不重复发货                    |
| 乱序    | 先跑 `order.succeeded`，再跑一个 `order.failed`，订单应该仍是 paid |
| 错误签名  | 把 `signature` 随便改一个字符，应该返回 401                       |
| 订单对不上 | 改掉 `sessionId`，应该被跳过而不是更新                            |

## 谁负责什么

| 模块          | 负责                                | 不能做                               |
| ----------- | --------------------------------- | --------------------------------- |
| 后端          | 建业务订单、调 Clink API、存各种 ID、提供订单状态查询 | 不能把 Secret Key 返回给浏览器；结果未知时不能重复扣款 |
| 前端          | 调你自己的后端、跳转或挂载收银台、展示支付状态           | 不能直接调 Clink API；不能根据回跳或 SDK 事件发货  |
| Webhook 处理器 | 验签、去重、匹配订单、更新状态、触发发货              | 不能依赖事件顺序；不能让旧状态覆盖新状态              |

## 常见错误

**在回跳页面发货。** `successUrl` 是个普通地址，客户手动敲一遍也能打开。发货只能由验过签的 Webhook 触发。

**Webhook 收到就发货，不去重。** Clink 会重试，同一个事件你会收到好几遍。不去重的话客户下单一次收到三件货。

**`pending` 当成失败，让客户重新付。** `pending` 是"还不知道"。这时候重新扣款，很可能扣两次。等 Webhook，或者查 `GET /order/{id}`。

**用 `express.json()` 解析 Webhook 请求。** 解析过的 body 再序列化回去，签名对不上，所有事件都会验签失败。

**Secret Key 出现在前端代码里。** 拿到它的人可以用你的账户发起收款和退款。前端只能拿 Publishable Key。

## 接下来

<CardGroup cols={2}>
  <Card title="密钥与 Webhook 配置" icon="key-round" href="/cn/integration">
    密钥轮换、IP 限制、投递重试规则。
  </Card>

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