Webhook 概览
Webhook 是权威信号。浏览器回调 (onSuccess) 和仪表板视图只是
便利信息;Webhook 才是真相之源。
投递保证
- 至少一次。 如果你的服务器未在超时内返回 2xx,单条事件最多会被
投递 6 次。处理函数请保持幂等 — 用
X-Delivery去重。 - 每个 HTTP 请求只投一条事件。 不做批处理。
- 每个端点独立。 如果你注册了多个端点,每个端点都有独立的投递与 重试轨道。某个慢的商户 URL 不会拖累其他端点 — 每台主机都有自己的 断路器。
- 签名。 每个负载都带
X-Signature标头(在密钥轮换后的 24 小时 窗口期内,还会附带X-Signature-Prev)。处理 body 之前先验证。 详见签名验证。
可订阅的事件类型
| 事件 | 触发条件 |
|---|---|
payment.settled | 链上转账达到该链的确认数。用此事件标记订单为已支付。 |
payment.failed | 一笔法币支付被 provider 明确拒绝(目前是 Stripe Webhook 上报失败)。不为加密超时触发 — 那些以 checkout.expired 形式出现;短付的加密支付以 payment.underpaid 出现。 |
payment.underpaid | 资金已到达但低于订单总额(典型例:稳定币转账手续费从金额中扣除)。 |
payment.overpaid | 资金超过订单总额。多余部分被记录但不自动退款。 |
order.created | 新订单创建 — 通过你的 B2B API 调用或结账会话转换。 |
order.canceled | 订单转为取消。负载中的 data.reason 区分手动取消与 payment_timeout(worker 清扫的过期未付订单)。 |
order.resolved | 一个 PARTIAL_PAID 订单被解决为 PAID — 商户接受了缺额。 |
order.reopened | 之前自动取消(canceled_reason=payment_timeout)的订单被商户重新打开。 |
checkout.created | 买家打开了订单的结账。 |
checkout.completed | 买家侧流程完成(不代表链上结算 — 那是 payment.settled 的事)。 |
checkout.expired | 买家放弃,会话 TTL 已过。 |
payment.refund.requested | 创建了一条退款记录 — 来源可能是商户发起的 API 调用,或客户提交的退款申请表单。 |
payment.refund.approved | 待审退款通过了你的审批流程。 |
payment.refund.rejected | 待审退款被驳回。 |
payment.refund.executed | 退款的链上转账确认,记录进入终态 executed。 |
refund_request.created | 退款申请 token 被铸造。data.source 是 b2b / dashboard / renewal。订阅可选 — 适合追踪每个订单当前活跃 token 的审计管道。 |
refund_request.renewal_requested | 买家在 token 过期后点击了”申请新链接”。强烈建议订阅 — 这是商户续期组件有新条目要处理的信号。 |
refund_request.renewed | 续期已批准,新 token 取代旧 token。data.old_token / data.new_token 构成审计链。 |
refund_request.canceled | 商户从仪表板把 token 翻转为 CANCELED(例如驳回续期、终止活跃链接)。幂等 — 只在首次迁移时发出。data.reason 是商户可选的备注。 |
仪表板从 GET /v1/webhooks/event-types 获取此列表,所以端点创建与
编辑表单始终匹配平台实际发出的事件。订阅平台不发出的事件会在创建时
被拒绝并附带清晰的错误信息。
测试事件不可订阅。 仪表板上每端点的 Send Test 按钮会把一个
webhook.test.ping 信封同步 POST 到那一个端点(绕过重试管道);而传统
的商户级”发送测试事件”通道则无论过滤器如何,都会把 webhook.test
信封扇出到每个活跃端点。两者都不出现在上面的目录里 — 你是凭借注册了
端点才会收到它们,而不是靠订阅。
只订阅你会处理的事件。每个端点有独立的事件过滤器;通配符 "*"
表示”所有事件,包括未来新增的”。订阅更少的事件可以让你的处理函数
更整洁,并且减少我们需要重试的表面积。
负载 + 标头
HTTP body 直接就是事件专属的 data 对象。 没有 Stripe 风格的外层
信封 — 事件类型、投递 ID、发出时间等字段都放在标头里。
对 payment.settled,body 形态如下:
{
"receipt_id": "rcp_…",
"order_id": "ord_…",
"payment_intent_id": "pin_…",
"checkout_session_id": "cst_…",
"merchant_id": "mer_…",
"customer_id": "cus_…",
"total": "49.00",
"currency": "USD",
"payment_method": "crypto",
"token": "USDC",
"network": "polygon",
"tx_hash": "0x…",
"deposit_address": "0x…",
"treasury_address": "0x…",
"amount_received": "49.00",
"confirmations": 5,
"metadata": { /* 事件专属 */ }
}其他事件携带各自的字段集 — 在分事件文档落地之前,以
payment-service/internal/domain/events.go 里的发布器 struct 为准。
字段名稳定(lower snake_case);链上 tx hash 始终为 tx_hash
(不是 transaction_hash)。
入站请求的标头
Content-Type: application/json
X-Event: payment.settled
X-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Timestamp: 1729536000
X-Signature: sha256=9a8b7c…
X-Signature-Prev: sha256=fa31b2… (仅在轮换宽限窗口期内出现)| 标头 | 含义 |
|---|---|
X-Event | 事件类型(例如 payment.settled)。如果你想跳过 JSON 解析,可在代理层基于此路由。 |
X-Delivery | 标识投递行的 UUID。在同一 (event, endpoint) 对的所有重试间保持稳定 — 用作你的幂等键。 |
Idempotency-Key | 镜像 X-Delivery(相同的值)。每次投递都会设置 — 沿用 Stripe / GitHub 的约定。 |
X-Timestamp | 尝试发送时的 Unix 秒。签入负载,使得抓到的 (body, X-Signature) 对不能被无限重放 — 时间戳超出你容差窗口的投递请拒绝。 |
X-Signature | HMAC-SHA256(secret, X-Timestamp + "." + raw_body) 的 sha256=<hex>。详见签名验证。 |
X-Signature-Prev | 同算法但用前一个密钥计算。仅在轮换后 24 小时窗口期内出现 — 让运行任一密钥的验证器在切换期间都能继续接受投递。窗口关闭后,此标头不再发送。 |
重试时间表
如果你的端点未在超时内返回 2xx,我们按以下时间表重试(时间戳相对
首次尝试):
| 尝试 | 延迟 | 累计 |
|---|---|---|
| 1 | 0s | 0s |
| 2 | +1 分 | 1m |
| 3 | +5 分 | 6m |
| 4 | +15 分 | 21m |
| 5 | +1 小时 | 1h 21m |
| 6 | +6 小时 | 7h 21m |
第 6 次尝试失败后,投递移入死信,商户账户邮箱会收到通知。死信
事件可以从仪表板的 Developers → Webhooks → Delivery history 面板
自助重放,也可直接调用 POST /v1/webhooks/deliveries/:id/replay。
每次重放会创建一条新投递行,带自己的 X-Delivery —
审计链通过 parent_delivery_id 追溯回原事件,所以”重放的重试”
不会遮蔽源事件。
注册端点
在商户仪表板 中:
- Developers → Webhooks → + Add endpoint
- 粘贴你的 URL — 仅
https://…(纯 HTTP 会被拒绝;创建表单还会 阻止localhost、私有 IP 段、以及含用户信息的 URL) - 选择要订阅的事件(或
*表示全部) - 选择环境 — test 或 live(各自有独立的密钥,二者永不交叉)
- 保存 → 仪表板仅显示一次签名密钥(
whsec_…)。请保存到服务端; 下面两个功能会用到它。
每个商户每个环境最多可注册 10 个端点(例如:一个用于生产履约, 一个用于预生产镜像,一个用于 Slack 通知)。每个端点维护独立的重试 状态、密钥以及主机级断路器。
每个端点的生命周期操作
每张端点卡片的 ⋮ 菜单提供:
- Edit — 修改 URL、描述或订阅列表。新 URL 会以与创建时相同的
https:///SSRF 规则重新校验。 - Send Test — 用当前密钥同步地 POST 一个
webhook.test.ping信封。 仪表板展示 HTTP 状态、延迟和你响应的前 512 字节。绕过 RMQ 管道, 结果立刻返回。 - Rotate Secret — 生成新密钥。前一个密钥仍保持 24 小时 有效
(期间投递同时携带
X-Signature与X-Signature-Prev,这样无论 你的验证器使用哪个密钥,重新部署期间都能继续接受事件)。 - Reveal Secret — 重新展示当前密钥。需要新的 2FA 验证并被记录到 审计日志;仅在你丢失副本且不能接受轮换时使用。
- Enable / Disable — 在不丢失投递历史的前提下切换
is_active。 禁用端点仍留在仪表板,但不再接收新投递。 - Delete — 永久删除。如果你以后可能再启用,使用 Disable。
处理函数小贴士
- 快速返回 2xx。 在做重活前先用
200 OK应答 — 把履约推入 后台队列。每次尝试的超时是 10 秒;响应保持超过这个时间 会触发重试。该超时由平台侧设定,不可由商户配置 — 如果你的 处理函数确实需要更长时间,请联系支持团队。 - 用
X-Delivery去重(或Idempotency-Key— 同一值)。 即便你返回了 2xx,上游代理也可能断连并触发重试;投递 ID 在同一 投递行的每次重试中保持稳定,所以这是正确的键。 - 容忍未知事件类型。 可能会出现新事件;请返回 200 + no-op, 而不是 4xx,否则你的重试队列会被塞满。
- 在业务逻辑旁边记录
X-Delivery。 出问题时,这就是我们这边 与你那边的连接键。