Skip to main content
这一页假设接入方式已经选好。如果还没有,先看 选择接入方式 下面的例子用 Hosted Checkout + Node.js/Express。换成别的语言或框架,结构是一样的。
这一页讲的验签、去重和订单模型,是所有付款方式的共同底子。但代码不能直接照搬:订阅续费按 subscriptionId + invoiceId 建账和履约,跟这里按 merchantReferenceId + sessionId 匹配一次性订单不是一套逻辑。示例本身走的是一次性支付。周期收费另见 订阅支付,折扣另见 优惠与促销码

至少要写三个接口

商户订单要存什么

至少这些字段,不然出问题时排查不了: 支付尝试、退款记录和人工对账证据要分开保存。
如果已有 merchant_orders 表,应先增加 Session 事件版本列,再部署处理器:
paymentAttempts.insertIfAbsent() 不是「先查再写」。它必须让 clink_order_id 主键裁决并发首次投递:
没有返回行时,重新读取胜出的记录,并在任何更新前同时校验 merchant_order_idclink_session_id。归属不同就返回 manual_review;绝不能去锁另一商户订单,也不能覆盖它的 attempt。 refunds 行不可变。下文的 insertIfAbsent() 指执行以下 SQL;没有返回行时,再读取数据库中已有的记录:
只有商户订单、Clink Order、Decimal 金额、规范化币种和状态全部相同,已有 refund_id 才算精确重放。绝不能使用 DO UPDATE:不可变字段不同是人工对账证据,不是覆盖旧行的理由。reconciliation_cases.insertIfAbsent() 同样采用 INSERT ... ON CONFLICT (dedupe_key) DO NOTHING
下面用 decimal.js 做金额运算:
项目已有的 Decimal 库也可以。金额在数据库里用 NUMERIC,应用层用 Decimal,绝不能用二进制浮点数。
**退款基准的适用范围:**下面的自动退款逻辑只适用于 Hosted Checkout 的单一现金资金来源支付,并且成功 Order 的 amountTotal 就是权威可退款现金额。公开 Order 契约有 amountTotalpaymentCurrency,但余额/积分加现金的拆分支付没有单独暴露 cashAmount如果启用了混合资金来源,不能拿 originalAmount 或总 amountTotal 代替。必须先从后端契约取得权威的可退款现金字段;在此之前,这类订单应转人工对账,不运行下面的自动退款状态计算。
确定性数据冲突返回 manual_review,不能作为可重试异常抛出。返回前,同一事务必须写入不可变的 reconciliation_cases 行和去重后的 manual_reconciliation Outbox 任务。只有这两项持久化后,Webhook 才能标成 processed 并确认接收。Worker 再用稳定 case key 幂等创建或更新运营工单和告警。
不要用本地插入时间判断哪一笔是当前尝试。 received_at 是商户库自己的写入时刻,受推送顺序、重试和处理延迟影响,和 Clink 那边 Order 的创建先后没有关系。它只用来排查问题。能代表 Order 创建顺序的只有一个来源:order.created 事件的外层 event.created。其他 order.* 事件的 event.created 是各自状态发生的时间,不是 Order 创建时间;data.object 里没有 Order 的创建时间,GET /order/{orderId} 目前也不返回。所以 order.created 必须订阅。四个事件一个都不能少:order.createdorder.next_actionorder.succeededorder.failed
为什么不能只用一张表。 一个 Session 可以产生多个 Order——客户第一张卡被拒、换第二张付成功,就是两笔 Order。如果所有 order.* 事件都写进商户订单那一行,第一笔的失败事件迟到就会把第二笔的成功盖掉。 所以按 obj.orderId 一笔支付尝试一行,商户订单的支付状态由这些尝试聚合出来。
下面的 SQL 用 snake_case 列名,JavaScript 示例统一用 camelCase 属性名(clinkOrderIdclinkSessionStatuspaymentStatusattemptCreatedAt)。两者指的是同一个字段,具体转换交给你的 ORM 或查询层。因此本页所有 tx.* / db.* repository 方法都假定返回 camelCase 对象。如果绕过 repository 直接执行 node-postgres SQL,必须像后面的 claimTask() 一样为每个会读取的列显式加 alias。不得返回“业务 ID 是 camelCase、claim_token 却是 snake_case”的混合对象。
商品快照要存下单那一刻的值。商品涨价之后,仍然需要知道这个客户当时买的是多少钱。 支付状态和发货状态分开存,是因为它们会不同步:钱收到了但发货失败,需要能查出来重试。 Session 生命周期也要单独保存。session.complete 只把 clinkSessionStatus 推进到 completedsession.expired 只推进到 expired;两者都不能决定支付、退款、履约或 Attempt 状态。clinkSessionLastEventCreated 保存 Session 终态事件的 Unix 毫秒版本,防止迟到的旧终态覆盖较新的状态。面向用户的提示应综合 Session 状态、聚合支付状态,以及是否仍有 pending/action-required Attempt。

1. 创建订单和 Session

服务端也可以用 @clink-ai/clink-typescript-sdk,它会处理认证请求头并提供类型定义。上面直接调 API 的写法是为了让每个字段的位置更清楚。
几个容易踩的点:
  • 金额从数据库取。前端传过来的价格一律不信,否则客户改个请求就能一块钱买走会员。
  • 金额是主货币单位,不是分。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。这个页面要做的事只有一件:查本地后端的订单状态,把结果显示出来。
刚跳回来时 Webhook 可能还没到,订单状态还是 pending。这时候显示「支付确认中」,隔几秒再查一次,别直接显示失败。 如果一直是 pending,后端可以主动查一次 GET /checkout/session/{id} 补上状态。

3. 接收 Webhook

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

验签

Clink 用 HMAC SHA-256 签名,签的是 时间戳 + "." + 原始请求体 完整的验签要做三件事,缺一件都不算验过:校验 signType、校验时间戳新鲜度、恒定时间比对签名。下面这段是完整实现,直接抄:
X-Clink-Timestamp 是 Unix 毫秒时间戳,所以直接和 Date.now() 比。5 分钟是这里选的窗口,平台没有强制值——窗口越大,被截获的请求可重放的时间越长;越小则对时钟漂移越敏感。服务器请用 NTP 校时。
时间校验和 HMAC 都必须在 JSON.parse 之前完成,而且用原始请求体。 解析后再序列化回去,字段顺序和空格都可能变,签名一定对不上。Express 里要用 express.raw(),不能用 express.json()
时间窗口只防重放,不防重复。 Clink 的重试也在窗口内到达,同一个事件仍然会收到多遍。重复必须另外按 event.id 原子去重,见下一节。
@clink-ai/clink-typescript-sdk@1.0.1ClinkWebhook.verifyAndGet() 只做 HMAC 那一步,上面三件事里它只覆盖了一件:
  • 不校验 X-Clink-SignType
  • 不校验时间戳新鲜度
  • 签名用 === 比较,不是恒定时间比较
前两条可以在调用它之前自己挡掉,第三条在 SDK 内部绕不过去。
在 SDK 提供恒定时间比较之前,正式接入建议用上面那段本地实现

完整的处理器

webhook_events 不只是一张去重表,它同时是待处理队列。表上至少要有这几列:
依赖对象不存在时,绝对不能把事件标成 processedrefund.succeeded 完全可能早于 order.succeeded 到达。如果这时候直接 return 并返回 200,事件就被记成处理过了——Clink 不会再推,这笔退款永远不会入账。依赖缺失时应保持 pending 并登记重处理任务,等依赖具备后由 Worker 再试。只有业务已完成,或确定性冲突及其人工对账任务已在同一事务可靠隔离后,才能置 processed
handleEvent 返回 'done''deferred''manual_review'manual_review 之所以是终态,是因为冲突证据和运营处理任务已经在同一事务内持久化。 事务里的步骤顺序是固定的,退款和订单事件都走同一套,锁顺序一致才不会死锁:
1

插入并去重 webhook_events

2

定位商户订单,SELECT ... FOR UPDATE 锁住这一行

3

校验必要标识和商户订单的 Session

4

插入或重读 payment_attempts,再校验商户与 Session 归属

5

锁内重新读取该订单的全部 attempts

6

聚合并更新 paymentStatus

7

同一事务写入 fulfillment / reconciliation Outbox

8

标记 processed,或依赖缺失时保持 pending

webhook_events 的唯一索引挡不住并发改同一个订单。 它只保证同一个 event.id 处理一次;order.succeeded 和另一笔 Order 的 order.failed 是两个不同事件,可以同时进来,各自读到旧快照再各自写回。所以要在动 attempts 之前先 SELECT ... FOR UPDATE 锁住商户订单那一行,把同一个订单的所有状态处理串行化。带状态条件的 UPDATE 可以作为第二层防御,但不能拿它替代行锁——它挡得住覆盖,挡不住基于旧快照算出的错误聚合结果。退款分支用同一把锁、同一个加锁顺序,否则两条路径交叉加锁会死锁。
顺序定不下来时不要猜。 succeeded Attempt 对聚合结果具有最高优先级;除此以外,Session 已确认的当前 Attempt 即使 attemptCreatedAt 为空也可以决定状态。如果这两个安全锚点都不存在:
  • 任一 Attempt 尚未收到 order.created 时,聚合状态回到 pending;除了事件自己的 reprocess_webhook,还要登记 reconcile_session,让 GET /checkout/session/{sessionId} 主动确认当前 Order,不无限等待创建时间
  • 两笔 Attempt 的已知、非空 attemptCreatedAt 相同,也登记 reconcile_session
新插入的未排序 Order 会让旧确认指针失效。后到的 order.created 只补该 Attempt 的创建时间,绝不回退已有状态。同一笔 Order 的两个状态事件 event.created 相同时同理,登记 reconcile_attempt,用 GET /order/{orderId}status 收敛。Session 查询持续无结果时保持 pending,并在重试策略耗尽后升级处理。received_at、数据库自增 ID、Webhook 到达顺序、Clink Order ID 字典序都不能用来打破平局——它们和真实创建顺序无关。
三层职责要分清楚,混在一起就会出现开头说的那些回退:
order.next_action 只代表当前有效尝试。 它不能由一笔已经被替代的旧 Order 写回商户订单——上面 recomputeMerchantOrder 里,action_required 只能来自 Session 已确认的 current,或创建时间唯一最新时的 ordered[0];未解决的平局保持 pending同理,某一笔尝试失败不代表订单失败。只要还有一笔成功,聚合结果就是成功。
事件顺序或字段不足以判定时,以查询结果为准。 event.created 只能用来比较同一笔 Order 的事件先后。如果两个事件时间戳相同、或者拿不到可靠的版本字段,就按 obj.orderIdGET /order/{orderId} 查一次,用返回的 status 收敛这笔尝试——服务端查询是消除乱序歧义最稳妥的依据,前端事件和推送顺序都不是。这次查询可以放进 Outbox 任务里做,别在 Webhook 请求线程里同步调。
dedupeKey 上建唯一索引,重复登记同一件事时靠数据库挡掉,不会堆出一堆同样的任务。
refund.succeeded 不等于全额退款。 部分退款成功也发这个事件,一律置成 refunded 会让只退了一部分的订单被当成全退。也不要在收到事件的当下立刻回查 Order。 退款服务是先更新退款记录并推事件,之后才异步去更新 Order 状态的。这一刻查 GET /order/{orderId},很可能读到的还是退款前的旧状态。另外,这个事件说的是退款在 Clink 侧处理成功,不代表钱已经回到客户账上。原路退回银行卡通常还要几个工作日,客服话术别写成”已到账”。

Outbox Worker

Webhook 只负责在一个事务里记账并登记待办,真正干活的是一个独立的 Worker。 领取和执行必须分开:领取用一个短事务,外部调用在事务提交之后做。外部 API 慢的时候,长时间持着数据库行锁会拖垮整个连接池。 完整的表结构如下。默认值都给全了,INSERT 只写业务字段就能跑:
登记任务时只写业务字段,statusattemptnext_retry_at 由默认值补齐:
ON CONFLICT DO NOTHING 让重复登记变成静默跳过,而不是抛异常。放在 Webhook 事务里时这点很重要——重复登记不该让整个事务回滚。
两个 reconcile 任务不需要额外字段:
  • reconcile_attemptclink_order_id 就能调 GET /order/{clinkOrderId}
  • reconcile_sessionorder_id 反查商户订单,再从订单行上取 clink_session_id
  • refund_reconcilepayload 读取 refundId;累计 refundedAmount 要在商户订单锁内重新读取最新值
  • apply_refund_policypayload 读取 refundIdorderStatusrefundedAmountpaymentCurrency
  • manual_reconciliationpayload 读取稳定的 caseKey,加载不可变证据行,再以该 key 幂等创建或更新运营工单和告警
  • page_outbox_failure 只携带失败任务 ID/类型以及白名单化的错误名称/代码;它持久化重试告警,且绝不再创建另一条告警任务
如果后续要传更多参数(比如指定重试策略、带上期望状态),放进 payload 这个 JSONB 列,不要为每种任务单独加列。
一条任务的状态是这样走的:
重试耗尽和告警任务必须原子提交。 带 claim token 条件的原任务 failed 更新,与去重的 page_outbox_failure 插入放在同一个数据库事务。告警 payload 只能包含 failedTaskIdfailedTaskerrorNameerrorCode;不得复制异常消息、Webhook/API 对象、HTTP body、客户数据或支付详情。page_outbox_failure 必须等待告警适配器完成,投递成功后才能标为 succeeded。投递失败时,同一任务按带上限的退避回到 pending,不得递归创建另一条告警任务。告警适配器以 outbox_failure_alert:{failedTaskId} 作为外部幂等键。基础设施仍要监控:任何 status = 'failed' 的任务、长时间停在 pending/processing 的任务,以及长期未成功的 page_outbox_failure。持久化重试能防止进程崩溃后静默丢告警,但不能替代队列健康监控。
SKIP LOCKED 本身不是幂等保证。 它只做一件事:让并发的 SELECT 跳过别人已经锁住的行,避免互相阻塞。真正保证”一条任务只被一个 Worker 领走”的,是在同一条语句里把状态改成 processing 并写入新的 claim_token。上面用的是 UPDATE ... RETURNING,锁定和标记一次完成,中间没有别的 Worker 能插进来。写成先 SELECT ... FOR UPDATE SKIP LOCKED、事务提交后再 UPDATE 的两段式,就会留出窗口。真要分两步,也必须把两步放进同一个短事务里。
每次更新任务状态都要带 claim_token 这一条防的是租约过期后的状态覆盖:
  1. Worker A 领到任务,拿到 tokenA
  2. A 卡住了,租约到期
  3. Worker B 用 tokenB 重新领走同一条任务
  4. A 恢复过来,上报成功——但它的 WHERE claim_token = tokenA 匹配不到任何行,影响行数为 0
  5. B 正常完成,状态不会被 A 覆盖
影响行数为 0 就表示”我的租约已经没了”,此时只记日志,不要重试写入、不要改状态。成功、失败、续租三种更新都必须带这个条件。重新领取时必须生成新 token,复用旧 token 会让这套判断直接失效。
Worker 崩了怎么办。 进程如果在领取之后、更新状态之前挂掉,任务会一直卡在 processinglease_until 就是为这个准备的:领取条件里带上 status = 'processing' AND lease_until < NOW(),租约到期的任务会被下一个 Worker 重新领走。本示例每次只领取一条任务。启动任何外部副作用前先续租一次,执行期间每隔 LEASE_MS / 3 心跳续租。续租失败会把本地标记为失租,取消支持 AbortSignal 的 HTTP 调用,跳过所有后续步骤,也不再调用 finishTask / failTask。人工对账路径在建工单和发告警之间还会再次确认租约。业务幂等仍然必须保留,但它只是最后一道保险,绝不能成为明知租约已失仍启动任务的理由。worker_idlease_until 也是排查用的——能看出哪台机器卡住了、卡了多久。
两个 reconcile 任务都必须真的发出查询,不能空跑。reconcile_attemptGET /order/{clinkOrderId},把返回的 status 映射成尝试状态;reconcile_sessionGET /checkout/session/{sessionId},用返回的 orderId 认定当前尝试。两者的查询都在事务外执行。查询前先保存本地版本:reconcile_attempt 保存 attempt owner、Session ID、statuslastEventCreatedattemptCreatedAtreconcile_session 保存 Session ID、当前指针/确认标记和全部 attempts 的归属及状态指纹。拿到结果后才进事务锁商户订单——和 Webhook 处理器同一把锁、同一加锁顺序——然后重读并比较快照。任一字段不一致都说明 API 结果可能已过期,必须回滚重试,不能写回。结果还不稳定时要抛错走退避重试,绝不能因为”这次没查出来”就把任务标成 succeededGET /order 返回的状态不在映射表里、或 GET /checkout/session 还没有 orderId,都属于这种情况。当前尝试落在 merchant_orders.current_clink_order_idcurrent_attempt_confirmed 则记录它是否来自 Session 查询。由 order.created 推导出的指针不能打破时间平局。不要去改 attempt_created_at——那是 Clink 给的真实创建时间,改了就污染了原始数据,而且「调成组内最大值」也未必真能解除相等。写入前要在锁内按全局 orderId 查询 Session 返回的 Order。查不到 attempt,说明那笔 Order 还没落到本地,应抛错重试;如果 attempt 已存在却属于另一商户或 Session,这是确定性冲突,应先持久化人工对账证据、把事件置 processed,再停止自动重试。
租约机制保证的是”不会永久卡死”,不是”只执行一次”。 崩溃或租约过期都会导致任务被重新领取并再跑一遍,所以 fulfill()reconcileRefund()applyRefundPolicy()reprocessWebhook() 自身必须用业务幂等键保护——发货按订单号、退款记账和退款策略都按 refundId、事件处理按 event.id。退款策略还必须比较累计 refundedAmount(或单调退款版本),迟到的旧任务不能把权益状态回退。外部接口若支持幂等键,调用时一并带上。
clinkGet 是前面共享服务端 helper 的无 body GET 包装。它只接受受控相对路径,每次生成新的毫秒 X-Timestamp,透传 AbortSignal,同时要求 HTTP 成功和 { code: 200, data: object } 信封;失败时只抛脱敏错误,不复制 Secret Key 或响应 body。 不想做退款对账的话,还有个更简单的口径:自己按 refundId 汇总成功退款金额,用 Decimalrefundable_paid_amount,退满了就是全退。Clink 没有”该订单累计已退金额”这个字段,也没有 order.refunded 事件,所以这个汇总本来就要自行维护。
别在 Webhook 请求线程里 await 这个 Worker。一旦在返回 200 之前执行外部动作,发货失败就会导致返回非 2xx,Clink 重投——而重投会被 event.id 去重直接挡回 200,这单货就永远不会再发了。事务提交即返回 200,外部动作全部交给 Worker 重试,才不会出现这个死角。
Outbox 表至少要有 statusattemptnext_retry_atlast_error 四个字段。Worker 幂等键用 eventId 或订单号,因为它一定会被重跑。发货、发邮件、调第三方接口这些动作出了数据库事务就回滚不了,所以不能写在事务里;反过来,enqueueXxx() 这种在事务里调外部队列的写法同样不行——事务回滚了任务却已经发出去,就成了没人认领的孤儿任务。待办必须和业务状态写进同一个事务。
权益要不要收回,不该由支付状态直接决定——退一半钱是停服、按比例缩短还是保持不变,取决于所售商品的性质。把这个判断收在 applyRefundPolicy 这类地方,别散在 Webhook 处理器里。Order 状态只在 rank 上升时推进,但每个成功的 refundId 都要在对账事务内登记自己的 apply_refund_policy 任务。提交后再由 Worker 执行;持有商户订单行锁时绝不能直接调用。
匹配订单、终态保护、登记履约这三件事要对所有 order.* 事件生效,不能只写在成功分支里。常见的写法是成功事件做了完整校验,失败事件却直接按订单号改成失败——一个迟到的 order.failed 就能把已经收到的钱标记成失败,货也退了。
退款事件要单独处理,因为它的结构不一样:refund.* 的对象里没有 merchantReferenceId,只有 orderIdrefundMerchantOrderId。所以要用付款时存下的 clinkOrderId 反查本地订单。

这五件事一件都不能少

1

验签

用原始请求体算 HMAC SHA-256,比对 X-Clink-Signature。签名对不上就返回 401。
2

按事件 ID 去重

投递失败 Clink 会重试,同一个事件会收到多遍。在 event.id 上建唯一索引,把这条记录和业务写入放进同一个事务,靠数据库挡住并发。先查再写挡不住同时到达的两份。
3

双重匹配订单

同时校验 merchantReferenceIdsessionId。只对上一个就当异常处理,别更新状态。
4

容忍乱序

Clink 不保证事件按发生顺序到达,要分两种情况处理。旧事件后到:已经是 paidrefundedpartial_refunded 的订单,不能被后到的旧事件改回 pending。同一笔 Clink Order 内部,用 event.created 和该尝试的 last_event_created 比较,旧的直接丢弃。依赖事件还没到:比如 refund.succeeded 早于 order.succeeded 到达,此时本地订单还不存在。这种事件要保持 pending 并登记重处理任务,绝不能标成 processed——一旦标了,Clink 不会再推,这笔退款就永远丢了。
5

写库成功后再返回 2xx

返回 200 表示”我处理完了”。还没落库就返回 200,这个事件就永远丢了。

这套机制要验的场景

上线前把这几种情况跑一遍,光看正常顺序是测不出问题的。 支付尝试顺序 Payment Attempt 归属与首次插入裁决 并发写同一订单 乱序与多个 Order Session 生命周期 乱序退款 Outbox 租约竞态 外部调用可能刚好在心跳发现失租时完成,所以仍要保留业务幂等:履约按商户订单 ID,退款对账/策略按 refundId,人工操作按 caseKey,Webhook 重放按 event.id Outbox 耗尽与告警

订阅哪些事件

一次性支付至少订阅这几个: 做订阅业务的话,订阅和账单事件另有一套,见 订阅支付。完整事件列表见 Webhook 参考,也可以调 GET /webhook/events 查当前支持的事件名。
订阅时要写完整的事件名。subscription.* 这类通配符不是能提交给 API 的值。

本地怎么调

Webhook 地址必须是公网可达的 HTTPS,localhost、回环地址和内网 IP 都会被拒绝。 已经有可用的公网地址(预览环境、Vercel/Netlify 之类的部署地址、自有域名)的话,直接用那个。纯本地开发才需要隧道:
如果 QUIC 连不上,加 --protocol http2 重试。 隧道地址每次重启都会变,换了地址记得回后台更新 Webhook 端点。

不刷卡也能调验签

隧道加真实付款能验证端到端,但反复调验签逻辑时,每次都刷一遍卡太慢。下面这些事件是商户自己构造的本地 fixture,不是 Clink 服务端发出的真实事件;对应商户订单和 clinkSessionId 必须已经存在于本地数据库。
预期本地结果:

谁负责什么

常见错误

在回跳页面发货。 successUrl 是个普通地址,客户手动敲一遍也能打开。发货只能由验过签的 Webhook 触发。 Webhook 收到就发货,不去重。 Clink 会重试,同一个事件会收到好几遍。不去重的话客户下单一次收到三件货。 pending 当成失败,让客户重新付。 pending 是”还不知道”。这时候重新扣款,很可能扣两次。等 Webhook,或者查 GET /order/{id} express.json() 解析 Webhook 请求。 解析过的 body 再序列化回去,签名对不上,所有事件都会验签失败。 Secret Key 出现在前端代码里。 拿到它的人可以用该账户发起收款和退款。前端只能拿 Publishable Key。

接下来

密钥与 Webhook 配置

密钥轮换、IP 限制、投递重试规则。

上线检查

切生产前要验的场景清单。