概述
Webhook 在事件发生时向您的服务器发送实时通知 — 订单、支付、订阅和退款。http 渠道使用本页所述的 JSON 信封和签名验证 — 本指南大部分内容覆盖该渠道。对于 IM 类渠道,载荷使用各平台原生格式,认证由 URL 中的 token 完成;无需验证签名。
配置步骤
1
选择渠道并准备 URL
- HTTP — 构建一个接受 POST 请求并返回
200的服务端端点。 - 飞书 / Discord / Telegram / Slack — 在目标平台创建机器人或入站 webhook,复制其 URL。Telegram 还需记录接收消息的 chat ID。
2
(仅 HTTP)复制验签公钥
Waffo 每个环境(Test / Production)使用一对固定密钥,所有门店共用。注册 Webhook 时不会返回公钥给你,你需要直接到 Dashboard 里复制。打开 控制台 → 进入任意门店 → 设置 → Webhooks,复制你要对接的环境(Test 或 Production)对应的 Webhook Public Key,存到你的服务端配置里 —— 之后所有 HTTP webhook 都用它来验签。
所有门店在 Dashboard 看到的 Test 公钥相同、Production 公钥相同 —— 这是平台级密钥。新增、修改、删除 Webhook URL 都不会改变它。
3
注册 Webhook
在 控制台 → 设置 → Webhooks 中添加 webhook,或调用
POST /v1/actions/store/add-webhook。每条 webhook 记录指定一个渠道、一个 URL、订阅的事件以及目标环境(Test 设 testMode: true,Production 设 false)。每个门店可注册多条 webhook。4
发送测试事件
使用 Dashboard 的 “Send Test Event” 按钮,将示例事件投递到您注册的某一条或全部 webhook。
5
验签并处理事件
对于 HTTP 渠道,使用下方代码示例在处理事件前验证签名。IM 渠道投递的是预渲染消息,无需在您侧处理。
环境隔离
每条 webhook 通过testMode 标志注册到单一环境。Test 和 Production 完全独立:
每个 HTTP 载荷中的
mode 字段表示来源环境:"test" 或 "prod"。
载荷格式
请求头
请求体
顶层字段
data 字段
下表定义每个 data 字段的类型、出现条件与含义。某个事件的确切字段集合,请看下方「事件详情」中该事件的完整 body。
出现条件列中,“支付事件”指 order.completed 与 subscription.payment_succeeded;“订阅事件”指所有 subscription.* 事件。
金额为显示格式字符串,已从最小单位转换。例如,USD
"29.00" = 2900 美分;JPY "4500" = 4500 日元。有值时可使用 subtotal 和 total 进行明细展示。webhook 负载中的
taxRate 是百分数 —— 10 表示 10%。该单位口径正在全 API 面统一,其他位置(preview-tax 的示例响应、TypeScript SDK 类型定义)目前仍按小数描述。税费收取尚未启用,因此当前所有订单的 taxRate 都是 0 —— 请读取负载中的实际值,不要在代码里写死单位假设。eventId 映射
eventId 标识触发事件的业务实体:
subscription.past_due 会在 eventId 后追加 -YYYY-MM。同一订阅每个自然月最多触发一次 past_due 事件。若下月仍逾期,将触发新事件。事件类型
概览
subscription.updated 事件模板已就绪,将在订阅产品变更功能上线后激活。事件详情
order.completed
order.completed
触发条件:一次性订单首次支付成功。Body:此事件中
amount 与 total 相等,不含订阅与退款字段。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、paymentMethod、paymentLast4。建议操作:- 交付数字商品(许可证密钥、下载链接、激活码)
- 更新您的订单管理系统
- 向 customer 发送确认(如果未使用 Waffo 内置邮件)
order.completed。退款通过 refund.succeeded / refund.failed 通知。subscription.activated
subscription.activated
触发条件:新订阅的首次支付成功(此事件以订单为主体而非某笔扣款 — 不含
pending → active)。Body:paymentId 及其他支付字段。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd。建议操作:- 为订阅者开通账户和授予访问权限
- 记录订阅开始日期
pending 转为 active 时触发。同一笔首次支付还会针对扣款本身发出 subscription.payment_succeeded — 见 订阅生命周期。subscription.payment_succeeded
subscription.payment_succeeded
触发条件:订阅扣款成功 — 含首次支付、每次续期,以及补缴逾期周期的那笔扣款。Body:与其他
subscription.* 事件不同,此事件以扣款为主体,因此携带支付字段。amount 是本期实际扣款金额,与 total 相等。即便订阅已设置取消,此事件也不会出现 canceledAt。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、paymentMethod、paymentLast4、currentPeriodStart、currentPeriodEnd。建议操作:- 延长服务期限
- 为本计费周期生成发票
- 如果订阅之前处于
past_due状态,恢复完整访问权限
subscription.canceling
subscription.canceling
触发条件:customer 或商户请求取消。订阅在当前付费期结束前仍保持有效。Body:此事件不含支付字段。
amount 是订阅的每期价格,不是某笔扣款。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd。建议操作:- 显示”订阅将于 [日期] 到期”提示
- 提供挽留流程(例如折扣续费)
- 不要撤回访问权限 — customer 已为当前周期付费
subscription.uncanceled)。subscription.uncanceled
subscription.uncanceled
触发条件:在当前周期结束前撤回取消。Body:此事件不含支付字段。撤回取消时
canceledAt 会被清除,因此不会出现。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd。建议操作:- 移除”即将到期”提示
- 恢复自动续费状态
subscription.updated
subscription.updated
触发条件:订阅产品变更(升级或降级)。Body:所有字段描述的都是变更之后的状态 — 载荷不含变更前后对照,因此无法从中得知订阅原本是哪个套餐。需要区分升级与降级,请自行记录变更前的套餐。此事件不含支付字段。无值时省略:
productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd。建议操作:- 更新 customer 的访问级别(添加/移除功能)
- 更新计费记录
subscription.canceled
subscription.canceled
触发条件:订阅已终止 — 付费期结束,不会再有续费。Body:此事件不含支付字段。
canceledAt 是请求取消的时间,早于本事件 — 订阅在此之前一直有效至 currentPeriodEnd。amount 是每期价格,不是最后一笔扣款。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、canceledAt。建议操作:- 撤回访问权限(或降级到免费版)
- 保留数据一段宽限期(以防 customer 重新订阅)
- 发送”订阅已结束”确认
subscription.past_due
subscription.past_due
触发条件:续期支付失败,订阅进入逾期状态。Body:
eventId 带 -YYYY-MM 后缀 — 这是唯一 eventId 与 id 不同的事件,按月去重正是靠它实现。此事件不含支付字段,因此载荷不会说明扣款失败的原因;amount 是应付金额。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd。建议操作:- 通知 customer 更新支付方式
- 可选择降级服务(限制功能而非完全撤回)
- 不要立即撤回访问权限 — PSP 可能会自动重试扣款
past_due 事件。若下月仍逾期,将触发新事件。refund.succeeded
refund.succeeded
触发条件:退款已完成,资金已退回。Body(部分退款示例:原始支付 29.00,本次退款 10.00):
subtotal / total / taxRate / taxName 是原始支付口径,保留供对比。taxAmount 恒为 "0",退款不提供税拆分,税务明细请查原始订单。paymentId 指向被退款的那笔扣款 — 订阅场景下即具体某一期,据此可判断退的是哪一期。载荷不含该笔支付的累计退款额,若一笔支付可能被多次退款,请自行记录。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、refundTicketMerchantExternalId、taxRate、taxName、subtotal、total、paymentMethod、paymentLast4、refundReason。建议操作:- 撤销已交付的数字商品(吊销许可证、禁用下载)
- 将订单状态更新为”已退款”
refund.failed
refund.failed
触发条件:退款处理失败。Body:其余结构与
refund.succeeded 相同。两个事件的唯一区分字段是 refundStatus,请据此分支,而不要依赖某个字段是否存在。载荷不含失败原因,请在 Dashboard 查看退款工单。无值时省略:同 refund.succeeded。建议操作:- 记录失败信息以供人工审核
- 不要撤销商品(退款未完成)
生命周期与触发时机
事件在底层业务事实落定时触发 — 而非买家点击时。支付类事件在支付服务商确认扣款后发出,订阅状态类事件在服务商确认状态变更后发出。发出之后,投递到您的端点通常在数秒内完成。timestamp 字段记录事件的创建时刻。重试沿用同一时间戳,不随投递尝试改变。
投递时序
请以事件而非收银台跳转作为收款确认的依据 — 买家可能在跳转前关闭浏览器,事件照常送达。一次性订单生命周期
支付被拒时订单停留在
pending,买家可以在同一订单上再次支付。order.completed 每个订单只发一次 — 由最终成功的那次支付发出。
订阅生命周期
取消分为两个时刻。
subscription.canceling 在取消请求发出的瞬间触发;终止本身安排在已付费周期结束时,subscription.canceled 在支付服务商确认终止后触发 — 时间点在 currentPeriodEnd 前后,而非取消请求当时。两者之间订阅仍然有效,customer 可以撤回取消。
逾期订阅由下一次续期失败终止:第一次被拒使订单从 active 转入 past_due 并发出 subscription.past_due;在 past_due 状态下再次被拒则终止订阅并发出 subscription.canceled。
退款生命周期
退款事件只在最后一步触发 — 支付服务商给出最终结果时。创建、批准、驳回、重新提交退款工单都不产生 webhook,因此审核中的工单在落定前对您的端点不可见。通过 API 发起的退款自动批准并直接进入processing;工单状态与查询方式见 退款端点。
不触发事件的情形
签名验证
在生产环境中务必验证签名。 不验证签名的话,任何人都可以向您的端点发送伪造请求。算法
使用 SDK(推荐)
SDK 内置公钥,自动检测环境,并处理格式标准化:手动验证
如果您不使用 TypeScript SDK,请手动实现签名验证。响应要求
- 返回 2xx 状态码(推荐
200) - 在 10 秒内响应
- 响应体内容无要求
重试策略
投递失败时自动使用指数退避重试:投递状态
在 Dashboard Webhook 日志中查看投递历史,包括状态、HTTP 响应码和响应体(截断至 1000 字符)。
处理重复事件
网络问题可能导致同一事件被多次投递。确保您的事件处理逻辑是幂等的。 使用eventType + eventId 组合(系统中有唯一约束)进行去重:
同一业务事件(相同的
eventType + eventId)只会创建一条投递记录 — 不会重复创建。但是,由于重试机制,单次投递可能多次到达您的端点。最佳实践
始终验证签名
始终验证签名
始终验证
X-Waffo-Signature。不验证签名的话,任何人都可以向您的端点发送伪造请求。使用 HTTPS
使用 HTTPS
生产环境的 Webhook URL 必须使用 HTTPS 以保护传输中的数据。
快速响应,异步处理
快速响应,异步处理
立即返回
200,在后台处理业务逻辑。响应过慢会导致不必要的重试。使用 eventType + eventId 去重
使用 eventType + eventId 去重
使用
eventType + eventId 组合进行去重。确保同一投递被多次处理不会产生副作用。检查时间戳
检查时间戳
验证
t 时间戳在当前时间的 5 分钟以内,以防止重放攻击。使用正确环境的密钥
使用正确环境的密钥
Test 和 Production 使用不同的密钥对。将公钥与载荷中的
mode 字段匹配。记录接收到的载荷
记录接收到的载荷
存储接收到的载荷用于调试。Dashboard 也提供投递日志查询功能。
处理所有已订阅的事件
处理所有已订阅的事件
为您订阅的每个事件添加处理分支,即使暂时不需要处理。对未处理的事件返回
200 — 返回错误会触发不必要的重试。测试
发送测试事件(推荐)
使用 Dashboard “Send Test Event” 按钮发送测试事件,无需触发真实交易。测试事件使用固定的示例数据(amount 0、taxAmount 0、product “[TEST] Webhook Verification”),始终使用 Test 密钥签名。 支持所有 10 种事件类型 — 逐一测试以验证您的处理程序。使用测试模式
- 在 Dashboard 中配置 Test 环境的 Webhook URL 和事件
- 在 Test 模式下执行真实操作(创建订单、处理支付)
- 事件将发送到您的 Test Webhook URL,使用 Test 签名密钥
本地开发
使用隧道工具暴露您的本地服务器:投递日志
在 Dashboard 中查看 Webhook 投递历史:- 状态:pending / success / failed
- HTTP 状态码:您的服务器的响应码
- 响应体:您的服务器的响应(截断至 1000 字符)
- 时间戳:最后一次投递尝试
常见问题
没有收到 Webhook
- 确认 Dashboard 中已配置 Webhook URL 且可公开访问
- 确认已订阅正确的事件类型
- 确认使用了正确的环境(Test / Production)
- 检查防火墙是否允许来自 Waffo 的请求
- 尝试 Dashboard “Send Test Event” 来定位问题
签名验证失败
- 确认使用了正确环境的公钥(Test 与 Production)
- 确认使用的是原始请求体 — 而非解析后的 JSON 对象
- 检查是否有中间件或代理修改了请求体
- 确认签名输入格式为
${t}.${rawBody}(时间戳 + 点 + 原始请求体) - 如果使用 TypeScript,建议切换到 @waffo/pancake-ts SDK — 它自动处理密钥选择和格式规范化
收到重复事件
这是正常的重试行为。如果您的端点返回非 2xx 或超时,系统会重试。确保您的处理逻辑是幂等的 — 使用eventType + eventId 组合进行去重。
为什么订阅首次支付会产生两个事件?
首笔扣款同时落定两个事实:订阅进入激活状态(subscription.activated,以订单为主体)与收到一笔款项(subscription.payment_succeeded,以扣款为主体)。二者独立投递,顺序与间隔都不保证。在 activated 上开通权限、在 payment_succeeded 上记录收据,首次支付的处理就只会跑一次。
subscription.canceled 具体在什么时候到达?
在 currentPeriodEnd 前后 — 而非取消请求当时。请求取消会立即发出 subscription.canceling,并把终止安排在已付费周期结束时;支付服务商确认终止后才发出 subscription.canceled。请用 canceling 载荷中的 currentPeriodEnd 判断权限应当在何时结束。
subscription.canceling 和 subscription.canceled 的区别
canceling:已请求取消,但当前付费期尚未结束。订阅仍然有效,customer 可以撤回取消(触发uncanceled)。不要撤回访问权限。canceled:订阅已终止。这是不可逆的 — 撤回访问权限或降级权限。
不同事件中 data.amount 的含义
所有事件:data.amount 是该特定事件的交易金额(含税):
order.completed/subscription.activated— 支付金额subscription.payment_succeeded— 本期续费金额subscription.past_due— 本期应付金额refund.succeeded/refund.failed— 退款金额subscription.canceling/subscription.canceled/subscription.uncanceled— 订阅每期金额