返回博客
Engineering
2026-07-137 分钟阅读作者:StableOps

稳定币支付 Webhook:如何防止重复履约

通过原始 body 验签、事件去重、幂等履约与可重放恢复,可靠处理稳定币支付 Webhook。

稳定币支付
Webhook
幂等性

可靠的加密支付 Webhook 接入,应把同一事件可能多次到达当作常态。至少一次投递是正常的可靠性约定:响应超时会触发重试,故障恢复时运维人员也可能手动重放投递。因此,端点必须让重复投递无害,而不能把它当成异常情况。

持久化模式并不复杂:对收到的精确请求字节验签,原子地记录事件与业务状态;只有最终事件才追加不可逆履约的下一步操作,随后快速确认,再由可恢复的 worker 执行该外部履约。这样能明确划分 StableOps 的事件投递与应用自身业务副作用的边界。

两道不同的保护共同防止重复履约

很容易想用一条数据库唯一规则解决所有问题,但这样会留下缺口。事件去重保护投递流;履约幂等保护真实世界中的业务副作用。

保护措施键与约束方式防止的问题单独使用为何仍不够
事件去重X-Event-Id 建立唯一约束同一 StableOps 事件因重试或重放而重复写入状态、重复入队两个不同的有效事件仍可能关联同一个内部订单
外部履约幂等以内部业务订单 ID 为键,由履约系统或商户侧持久化记录约束同一订单重复发货、重复开通权益或重复入账不能保留每个收到事件的完整、可审计记录

两者都需要。前者回答“这个事件是否已经接受过”,后者回答“这个订单是否已经产生过不可逆副作用”。更广泛的请求级模式可见幂等性

让 Webhook 处理器短小且可恢复

端点应在返回成功前完成数据库工作,但不应等待发货、开通服务或其他远程副作用。

收到 StableOps 投递
          |
          v
读取原始 body
          |
          v
验证 X-Product-Signature
          |
          v
在一个事务中:持久化唯一事件 ID + 业务状态;仅 `payment.finalized` 写入不可逆履约 durable outbox
          |
          v
返回 2xx
          |
          v
提交后 dispatcher/worker 仅领取 `payment.finalized` 的 outbox 记录,并按内部订单 ID 幂等履约

下面是一个精简的 Next.js Route Handler 示例。recordWebhookEventAndWriteOutbox 是商户自有的持久化函数,并非 StableOps API 调用。它在同一事务内通过唯一事件 ID 与 INSERT ... ON CONFLICT DO NOTHING(或等价机制)判断是否首次收到事件:每个首次收到且验签通过的相关事件都会持久化事件记录并更新业务状态;只有已验签、已去重的 payment.finalized 会额外写入不可逆履约的 durable outbox 记录。payment.detectedpayment.confirmed 及其他事件只更新状态,或在适用时创建可逆任务,绝不写入不可逆履约 outbox。重复的 X-Event-Id 是无副作用的 no-op,处理器仍返回 2xx。事务提交后,dispatcher 或 worker 只领取 payment.finalized 的 outbox 记录并执行外部副作用。

import { EVENT_ID_HEADER, SIGNATURE_HEADER, verifySignature } from '@stableops/api-sdk/webhooks'

export async function POST(req: Request) {
  const rawBody = await req.text()
  const secrets = [
    process.env.STABLEOPS_WEBHOOK_SECRET,
    process.env.STABLEOPS_WEBHOOK_SECRET_PREVIOUS,
  ].filter((secret): secret is string => Boolean(secret))
  const verification = verifySignature({
    secrets,
    header: req.headers.get(SIGNATURE_HEADER) ?? undefined,
    rawBody,
  })

  if (!verification.ok) {
    return new Response('invalid signature', { status: 400 })
  }

  const eventId = req.headers.get(EVENT_ID_HEADER)
  if (!eventId) {
    return new Response('missing event id', { status: 400 })
  }

  const event = JSON.parse(rawBody)
  // 商户侧持久化:所有首次事件写入状态;只有 payment.finalized
  // 写入不可逆履约 durable outbox。重复事件 ID 有意保持无副作用。
  await recordWebhookEventAndWriteOutbox({ eventId, event })

  return new Response(null, { status: 204 })
}

req.text() 恰好读取一次 body,并在解析之前验签。解析后重新序列化 JSON 可能改变签名所对应的表示形式。密钥轮换的 24 小时重叠窗口中,应如示例一样同时传入 current secret 与可选的 previous secret,并过滤空值,使任一有效签名都能通过。验签与签名行为详见Webhook 验签,端点运维细节见 Webhook 指南

为崩溃、重试和重放而设计

已确认的请求不等于履约已经完成。应为每个边界建模,让重试仍得到安全结果。

失败或恢复场景会发生什么安全的恢复行为
进程在事务提交前崩溃没有持久化事件记录、业务状态或(仅最终事件才有的)履约 outbox 任务投递重试会再次执行事务,并只成功提交一次
事务已提交,但响应在到达 StableOps 前超时事件与状态已持久化,最终事件的履约 outbox 也可能已写入;StableOps 仍可能重试将唯一 X-Event-Id 冲突视为已接受,返回 2xx,不再更新状态或创建任何任务
worker 在不可逆履约期间崩溃payment.finalized 的 outbox 任务可能在租约或尝试后仍未完成重试或对账该任务;按订单 ID 的幂等保护使外部操作安全
同一事件被重放重放会为原始事件新建一条 StableOps delivery audit row再次验签,由事件 ID 唯一约束走 no-op 路径
同一订单的不同事件并发到达每个事件 ID 不同,因此事件去重允许两者通过串行化或条件更新订单,并按内部订单 ID 保证履约幂等

StableOps 将任何 2xx 视为投递成功。非 2xx、网络错误,或 10 秒内没有响应都视为失败并重试;最终失败的投递会进入 DLQ。不要在持久化接受前返回成功,也不要为了执行慢任务而故意让请求一直挂起。通过控制台或重放 API 重放会创建新的 delivery audit row,因此事件级保护仍不可少。

将支付事件视为状态机

只有已验签且已去重的 payment.finalized 可以触发不可逆履约。payment.detectedpayment.confirmed 适合展示支付进度、发送通知或执行刻意设计为可逆的动作,但不是最终证明。收到 payment.reverted 时,应撤销或停止乐观处理;收到 payment.expired 时,应关闭未付款尝试并引导客户重新开始。payment_order.canceled 是在检测到转账前被取消订单的终态;若 Webhook 流是你的事实来源,应将订单标记为已取消并释放预留资源。

浏览器回跳 URL、交易哈希和未验签 Webhook 都可用于用户体验或排查,但都不是履约的最终凭据。确认和最终性取决于链与业务风险;不要用固定确认数替代已验证的最终事件。可阅读确认状态模型稳定币支付确认,了解这一生命周期背后的判断。

生产检查清单

  • 在解析 JSON 前,以原始请求 body 验证 X-Product-Signature;Webhook secret 只保存在服务端。
  • 密钥轮换的 24 小时重叠期内,同时使用 current 与 previous secret 验签;窗口结束后移除 previous secret。
  • 要求并持久化 X-Event-Id,在与业务状态变更相同的事务中施加数据库唯一约束;仅已验签、已去重的 payment.finalized 可在该事务中写入不可逆履约 outbox。
  • 仅在已持久化接受后返回任意 2xx;耗时或外部操作交给 worker。
  • 每个不可逆履约动作都按内部业务订单 ID 幂等,而非只按事件 ID。
  • 按状态机持久化各类支付事件;当 Webhook 状态为权威来源时,将 payment_order.canceled 标为取消并释放预留资源。
  • 监控失败投递和 DLQ;端点或下游依赖修复后再使用重放。
  • 保留事件 ID、delivery ID、支付订单引用、内部订单 ID 与交易哈希,以便对账。
  • 上线前演练重复投递、响应超时、事务崩溃、worker 崩溃、重放和同一订单并发事件。

FAQ

204 响应算 Webhook 成功吗?

算。StableOps 将任何 2xx 响应视为成功。当处理器已持久化接受事件且无需响应 body 时,204 很合适。

可以直接在 Webhook 请求中履约吗?

可以,但这会扩大外部副作用周围的超时与崩溃窗口。通常更安全的是在处理已验签、已去重的 payment.finalized 时,在事务中记录不可逆履约 outbox,再由 worker 执行;其他事件不进入该 outbox。持久化接受后即确认,随后通过按订单 ID 的幂等保护独立重试履约。

重放会创建新的 delivery row,还会重复履约吗?

不应如此。新的 delivery 是审计记录,原始事件 ID 才是去重键。即使两个有效事件都指向同一订单,按内部订单 ID 的履约幂等性也会阻止外部副作用被执行两次。

在真正需要前先建好恢复路径

Webhook 指南开始,接入 SDK 验签器,并在 Sandbox 测试一次重复投递和一次 worker 重启。当处理器能够安全地接受、恢复并对账这些场景时,稳定币支付流程就能在生产规模下可靠履约。

相关文章

对比托管网关、原始钱包转账和非托管支付基础设施,选择可靠的稳定币收款方案。

使用支付订单、链上监听、最终性和可靠 Webhook,将 USDC 收入商户控制的钱包。

2026-07-09 · 7 分钟阅读

解释确认数、reorg、finalized 状态与商户履约的关系。