Skip to main content

概述

Webhook 在事件发生时向您的服务器发送实时通知 — 订单、支付、订阅和退款。
单个门店可以配置多个 webhook,每个投递到不同的渠道。可用渠道如下: http 渠道使用本页所述的 JSON 信封和签名验证 — 本指南大部分内容覆盖该渠道。对于 IM 类渠道,载荷使用各平台原生格式,认证由 URL 中的 token 完成;无需验证签名。
使用 TypeScript? @waffo/pancake-ts SDK 内置了公钥并自动检测环境 — 一行代码即可完成 http 渠道的验签。

配置步骤

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"
始终使用与事件 mode 匹配的公钥。Test 密钥无法验证 Production 事件,反之亦然。

载荷格式

请求头

请求体

顶层字段

data 字段

下表定义每个 data 字段的类型、出现条件与含义。某个事件的确切字段集合,请看下方「事件详情」中该事件的完整 body。 出现条件列中,“支付事件”指 order.completedsubscription.payment_succeeded;“订阅事件”指所有 subscription.* 事件。
金额为显示格式字符串,已从最小单位转换。例如,USD "29.00" = 2900 美分;JPY "4500" = 4500 日元。有值时可使用 subtotaltotal 进行明细展示。
webhook 负载中的 taxRate百分数 —— 10 表示 10%。该单位口径正在全 API 面统一,其他位置(preview-tax 的示例响应、TypeScript SDK 类型定义)目前仍按小数描述。税费收取尚未启用,因此当前所有订单的 taxRate 都是 0 —— 请读取负载中的实际值,不要在代码里写死单位假设。

eventId 映射

eventId 标识触发事件的业务实体:
subscription.past_due 会在 eventId 后追加 -YYYY-MM。同一订阅每个自然月最多触发一次 past_due 事件。若下月仍逾期,将触发新事件。

事件类型

概览

subscription.updated 事件模板已就绪,将在订阅产品变更功能上线后激活。
各事件在订单、订阅、退款生命周期中的位置,以及哪些状态迁移不发事件,见 生命周期与触发时机

事件详情

触发条件:一次性订单首次支付成功。Body
此事件中 amounttotal 相等,不含订阅与退款字段。无值时省略:productDescriptionmerchantProvidedBuyerIdentitybillingDetailorderMerchantExternalIdtaxRatetaxNamesubtotaltotalpaymentMethodpaymentLast4建议操作
  • 交付数字商品(许可证密钥、下载链接、激活码)
  • 更新您的订单管理系统
  • 向 customer 发送确认(如果未使用 Waffo 内置邮件)
同一订单只会触发一次 order.completed。退款通过 refund.succeeded / refund.failed 通知。
触发条件:新订阅的首次支付成功(pendingactive)。Body
amount 是订阅的标准期价格,不是实际扣款额。有试用或首期折扣时,首次扣款与之不同 — 需要实收金额请用 subscription.payment_succeeded
此事件以订单为主体而非某笔扣款 — 不含 paymentId 及其他支付字段。无值时省略:productDescriptionmerchantProvidedBuyerIdentitybillingDetailorderMerchantExternalIdtaxRatetaxNamesubtotaltotalcurrentPeriodStartcurrentPeriodEnd建议操作
  • 为订阅者开通账户和授予访问权限
  • 记录订阅开始日期
仅在订阅首次从 pending 转为 active 时触发。同一笔首次支付还会针对扣款本身发出 subscription.payment_succeeded — 见 订阅生命周期
触发条件:订阅扣款成功 — 含首次支付、每次续期,以及补缴逾期周期的那笔扣款。Body
与其他 subscription.* 事件不同,此事件以扣款为主体,因此携带支付字段。amount 是本期实际扣款金额,与 total 相等。即便订阅已设置取消,此事件也不会出现 canceledAt。无值时省略:productDescriptionmerchantProvidedBuyerIdentitybillingDetailorderMerchantExternalIdtaxRatetaxNamesubtotaltotalpaymentMethodpaymentLast4currentPeriodStartcurrentPeriodEnd建议操作
  • 延长服务期限
  • 为本计费周期生成发票
  • 如果订阅之前处于 past_due 状态,恢复完整访问权限
触发条件:customer 或商户请求取消。订阅在当前付费期结束前仍保持有效。Body
canceledAt 是请求取消的时间,不是权限到期时间。customer 已付费至 currentPeriodEnd — 在 canceledAt 收回权限会切断其已付费的服务。
此事件不含支付字段。amount 是订阅的每期价格,不是某笔扣款。无值时省略:productDescriptionmerchantProvidedBuyerIdentitybillingDetailorderMerchantExternalIdtaxRatetaxNamesubtotaltotalcurrentPeriodStartcurrentPeriodEnd建议操作
  • 显示”订阅将于 [日期] 到期”提示
  • 提供挽留流程(例如折扣续费)
  • 不要撤回访问权限 — customer 已为当前周期付费
customer 可以在周期结束前撤回取消(触发 subscription.uncanceled)。
触发条件:在当前周期结束前撤回取消。Body
此事件不含支付字段。撤回取消时 canceledAt 会被清除,因此不会出现。无值时省略:productDescriptionmerchantProvidedBuyerIdentitybillingDetailorderMerchantExternalIdtaxRatetaxNamesubtotaltotalcurrentPeriodStartcurrentPeriodEnd建议操作
  • 移除”即将到期”提示
  • 恢复自动续费状态
触发条件:订阅产品变更(升级或降级)。Body
所有字段描述的都是变更之后的状态 — 载荷不含变更前后对照,因此无法从中得知订阅原本是哪个套餐。需要区分升级与降级,请自行记录变更前的套餐。此事件不含支付字段。无值时省略:productDescriptionmerchantProvidedBuyerIdentitybillingDetailorderMerchantExternalIdtaxRatetaxNamesubtotaltotalcurrentPeriodStartcurrentPeriodEnd建议操作
  • 更新 customer 的访问级别(添加/移除功能)
  • 更新计费记录
触发条件:订阅已终止 — 付费期结束,不会再有续费。Body
此事件不含支付字段。canceledAt 是请求取消的时间,早于本事件 — 订阅在此之前一直有效至 currentPeriodEndamount 是每期价格,不是最后一笔扣款。无值时省略:productDescriptionmerchantProvidedBuyerIdentitybillingDetailorderMerchantExternalIdtaxRatetaxNamesubtotaltotalcurrentPeriodStartcurrentPeriodEndcanceledAt建议操作
  • 撤回访问权限(或降级到免费版)
  • 保留数据一段宽限期(以防 customer 重新订阅)
  • 发送”订阅已结束”确认
这是终态。订阅已不可逆地结束。
触发条件:续期支付失败,订阅进入逾期状态。Body
eventId-YYYY-MM 后缀 — 这是唯一 eventIdid 不同的事件,按月去重正是靠它实现。此事件不含支付字段,因此载荷不会说明扣款失败的原因;amount 是应付金额。无值时省略:productDescriptionmerchantProvidedBuyerIdentitybillingDetailorderMerchantExternalIdtaxRatetaxNamesubtotaltotalcurrentPeriodStartcurrentPeriodEnd建议操作
  • 通知 customer 更新支付方式
  • 可选择降级服务(限制功能而非完全撤回)
  • 不要立即撤回访问权限 — PSP 可能会自动重试扣款
去重:每个订阅每自然月最多一次 past_due 事件。若下月仍逾期,将触发新事件。
触发条件:退款已完成,资金已退回。Body(部分退款示例:原始支付 29.00,本次退款 10.00):
amount 是本次退款金额,total 是原始支付金额。部分退款时两者不等 — 入账请取 amount,切勿取 total
subtotal / total / taxRate / taxName 是原始支付口径,保留供对比。taxAmount 恒为 "0",退款不提供税拆分,税务明细请查原始订单。paymentId 指向被退款的那笔扣款 — 订阅场景下即具体某一期,据此可判断退的是哪一期。载荷不含该笔支付的累计退款额,若一笔支付可能被多次退款,请自行记录。无值时省略:productDescriptionmerchantProvidedBuyerIdentitybillingDetailorderMerchantExternalIdrefundTicketMerchantExternalIdtaxRatetaxNamesubtotaltotalpaymentMethodpaymentLast4refundReason建议操作
  • 撤销已交付的数字商品(吊销许可证、禁用下载)
  • 将订单状态更新为”已退款”
触发条件:退款处理失败。Body
资金并未发生变动。amount尝试退款的金额 — 请勿据此记退款账。
其余结构与 refund.succeeded 相同。两个事件的唯一区分字段是 refundStatus,请据此分支,而不要依赖某个字段是否存在。载荷不含失败原因,请在 Dashboard 查看退款工单。无值时省略:同 refund.succeeded建议操作
  • 记录失败信息以供人工审核
  • 不要撤销商品(退款未完成)

生命周期与触发时机

事件在底层业务事实落定时触发 — 而非买家点击时。支付类事件在支付服务商确认扣款后发出,订阅状态类事件在服务商确认状态变更后发出。发出之后,投递到您的端点通常在数秒内完成。 timestamp 字段记录事件的创建时刻。重试沿用同一时间戳,不随投递尝试改变。

投递时序

请以事件而非收银台跳转作为收款确认的依据 — 买家可能在跳转前关闭浏览器,事件照常送达。

一次性订单生命周期

支付被拒时订单停留在 pending,买家可以在同一订单上再次支付。order.completed 每个订单只发一次 — 由最终成功的那次支付发出。

订阅生命周期

首次支付会发出两个事件:subscription.activated(以订单为主体)与 subscription.payment_succeeded(以扣款为主体)。二者独立投递,顺序与间隔都不保证。请在 activated 上开通权限、在 payment_succeeded 上记录收据 — 把两者都当作「新订阅者」会让开通流程跑两次。
取消分为两个时刻。subscription.canceling 在取消请求发出的瞬间触发;终止本身安排在已付费周期结束时,subscription.canceled 在支付服务商确认终止后触发 — 时间点在 currentPeriodEnd 前后,而非取消请求当时。两者之间订阅仍然有效,customer 可以撤回取消。 逾期订阅由下一次续期失败终止:第一次被拒使订单从 active 转入 past_due 并发出 subscription.past_due;在 past_due 状态下再次被拒则终止订阅并发出 subscription.canceled

退款生命周期

退款事件只在最后一步触发 — 支付服务商给出最终结果时。创建、批准、驳回、重新提交退款工单都不产生 webhook,因此审核中的工单在落定前对您的端点不可见。通过 API 发起的退款自动批准并直接进入 processing;工单状态与查询方式见 退款端点

不触发事件的情形


签名验证

在生产环境中务必验证签名。 不验证签名的话,任何人都可以向您的端点发送伪造请求。

算法

使用 SDK(推荐)

SDK 内置公钥,自动检测环境,并处理格式标准化:
查看完整的 SDK Webhook 文档

手动验证

如果您不使用 TypeScript SDK,请手动实现签名验证。
您必须使用原始请求体进行签名验证。 如果您的框架自动解析了 JSON,签名检查将失败。确保在验证前获取未修改的原始字符串。

响应要求

  • 返回 2xx 状态码(推荐 200
  • 10 秒内响应
  • 响应体内容无要求
非 2xx 响应或超时将触发重试。

重试策略

投递失败时自动使用指数退避重试:

投递状态

在 Dashboard Webhook 日志中查看投递历史,包括状态、HTTP 响应码和响应体(截断至 1000 字符)。

处理重复事件

网络问题可能导致同一事件被多次投递。确保您的事件处理逻辑是幂等的。 使用 eventType + eventId 组合(系统中有唯一约束)进行去重:
同一业务事件(相同的 eventType + eventId)只会创建一条投递记录 — 不会重复创建。但是,由于重试机制,单次投递可能多次到达您的端点。

最佳实践

始终验证 X-Waffo-Signature。不验证签名的话,任何人都可以向您的端点发送伪造请求。
生产环境的 Webhook URL 必须使用 HTTPS 以保护传输中的数据。
立即返回 200,在后台处理业务逻辑。响应过慢会导致不必要的重试。
使用 eventType + eventId 组合进行去重。确保同一投递被多次处理不会产生副作用。
验证 t 时间戳在当前时间的 5 分钟以内,以防止重放攻击。
Test 和 Production 使用不同的密钥对。将公钥与载荷中的 mode 字段匹配。
存储接收到的载荷用于调试。Dashboard 也提供投递日志查询功能。
为您订阅的每个事件添加处理分支,即使暂时不需要处理。对未处理的事件返回 200 — 返回错误会触发不必要的重试。

测试

发送测试事件(推荐)

使用 Dashboard “Send Test Event” 按钮发送测试事件,无需触发真实交易。测试事件使用固定的示例数据(amount 0、taxAmount 0、product “[TEST] Webhook Verification”),始终使用 Test 密钥签名。 支持所有 10 种事件类型 — 逐一测试以验证您的处理程序。

使用测试模式

  1. 在 Dashboard 中配置 Test 环境的 Webhook URL 和事件
  2. 在 Test 模式下执行真实操作(创建订单、处理支付)
  3. 事件将发送到您的 Test Webhook URL,使用 Test 签名密钥

本地开发

使用隧道工具暴露您的本地服务器:

投递日志

在 Dashboard 中查看 Webhook 投递历史:
  • 状态:pending / success / failed
  • HTTP 状态码:您的服务器的响应码
  • 响应体:您的服务器的响应(截断至 1000 字符)
  • 时间戳:最后一次投递尝试

常见问题

没有收到 Webhook

  1. 确认 Dashboard 中已配置 Webhook URL 且可公开访问
  2. 确认已订阅正确的事件类型
  3. 确认使用了正确的环境(Test / Production)
  4. 检查防火墙是否允许来自 Waffo 的请求
  5. 尝试 Dashboard “Send Test Event” 来定位问题

签名验证失败

  1. 确认使用了正确环境的公钥(Test 与 Production)
  2. 确认使用的是原始请求体 — 而非解析后的 JSON 对象
  3. 检查是否有中间件或代理修改了请求体
  4. 确认签名输入格式为 ${t}.${rawBody}(时间戳 + 点 + 原始请求体)
  5. 如果使用 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.cancelingsubscription.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 — 订阅每期金额