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.completed 与 subscription.payment_succeeded;“订阅事件”指 subscription.payment_succeeded 以外的所有 subscription.* 事件 —— 后者是纯支付事件,只描述这一笔扣款,不携带订阅的周期与状态。

已弃用的金额字段

amount / total / subtotal / taxAmount / taxRate / taxName 一个名字描述三件事:支付事件上的这笔扣款、退款事件上的这笔退款、订阅状态事件上的方案标价。现在每个字段都有一个主体明确的替代字段。
支付事件的 amount 自 2026-09-21 起变值。 自该日起,order.completed 与 subscription.payment_succeeded 的 amount 是通道实际扣款额(与 chargedAmount 同值),此前它是下单时记录的标价。两者只在按比例抵扣或零额验卡时不等,其余扣款取值完全一致。若该笔通道未回传金额,amount 回落标价合计,且 chargedAmount 整键省略。如果你用 amount == total 做一致性断言,自该日起它在支付事件上会开始失败。请改读 chargedAmount(实际扣了多少)与 listPrice.total(标价是多少)。其余取值一概不变。 refund.* 与订阅事件的 amount 与此前逐字相同,全部事件的 total / subtotal / taxAmount / taxRate / taxName 同样不变。
移除不早于 2027-09-20(自本公告起 12 个月),且随下一个 major 版本发布。 在此之前六个字段照常投递,类型与触发时机不变。新接入方请直接读替代字段;既有接入方不改代码也能继续工作。 弃用不在运行时表达:载荷不携带任何弃用元数据,也不发送 Deprecation / Sunset 响应头。它只写在本页与 SDK 类型定义里(TypeScript 的 @deprecated、Go 的 // Deprecated:)。
periodNumber 是通道自己的计费周期序号,原样透传 —— Waffo 不计数、不推算。
  • 首期为 1,第 N 次续费为 N。
  • 扣款失败同样占用一期。 它不是成功扣款次数 —— 请用它与你从通道对账的期数对齐,不要当成扣款笔数。
  • 0 表示通道已授权但尚未扣款,出现在预约生效的换方案单切换时刻之前。
  • 在 subscription.payment_succeeded 上,它是这笔扣款所属的周期,也是该事件唯一携带的订阅维度字段。
  • 在 refund.* 上,它是被退那笔扣款所属的周期,而不是退款发生时的当期。
  • 在 subscription.canceling、subscription.uncanceled、subscription.plan_change_failed 上,通道不发自己的通知,因此取值是通道最近一次报出的当期序号。
  • 不可作为去重键。 同一周期内的多个事件携带相同的序号,去重请用 id 或 eventId。
  • 一次性订单及其退款不带该字段;本字段上线前创建的订阅与支付同样不带。
同一个概念有三个名字,各对应一个锚点。它们不是三个不同的数,而是同一个序号在不同位置上的读数:GraphQL 订阅订单侧特意用了不同的名字:一笔续费过三次的订阅当前在第 3 期,而它的首期扣款 永远是 periodNumber: 1。把两者读混正是这两个名字要防的错。
金额为显示格式字符串,已从最小单位转换。例如,USD "29.00" = 2900 美分;JPY "4500" = 4500 日元。零是合法取值(零额验卡),按同一格式输出 —— USD 为 "0.00"、JPY 为 "0"。明细展示请取本事件对应的那个块:listPrice、originalPayment 或 planPrice。
webhook 负载中的 taxRate 是百分数 —— 10 表示 10%。该单位口径正在全 API 面统一,其他位置(TypeScript SDK 类型定义)目前仍按小数描述。税费收取尚未启用,因此当前所有订单的 taxRate 都是 0 —— 请读取负载中的实际值,不要在代码里写死单位假设。

GraphQL 的支付金额

这是缺陷修复,不是契约变更。 GraphQL 的 Payment.amount 一直被定义为「买家被扣的总额」,但实现取的是下单时的标价快照。现在它取通道实际扣款额 —— 金额筛选、金额排序与「是否已全额退款」的判定同样如此。四处读同一个来源,不会再出现「列表按一个数排序、详情显示另一个数」。哪些支付的取值会变。 只有两个数不等的那些,起点是 2026-09-07 上线的升降级换方案 —— 在那之前,任何一笔的标价与实扣都没有分歧。至今是 live 侧 7 笔、test 侧 111 笔。其余支付逐值不变。若你在 2026-09-07 之后按 GraphQL 对过这几笔的账,它们的金额会变成通道实扣的那个数;已经退完的那几笔现在会被判为已全额退款。通道未回传某笔的金额时,该字段回落标价合计,并在服务端留下一条日志。筛选与排序按同一规则回落,因此这类支付不会从筛选或排序结果里被静默漏掉。Webhook 载荷不受影响。 本页没有任何字段变值;唯一的新增是退款事件上的 originalChargedAmount。
部分 Dashboard 与 Admin 页面仍显示标价。 它们直读标价快照而不是这个字段,本次尚未跟进:消费者发票页、Admin 支付列表、Admin PSP 对账页、商户收入页、买家门户、侧栏,以及分析页的兜底取值。在它们跟进之前,同一笔支付可能显示两个数 —— GraphQL API 与 webhook 载荷给的是实扣,那些页面给的是标价。对账请以 API 为准,不要以页面为准。
实际没收到钱的支付会被判为「已全额退款」。 「是否已全额退款」的判据是「成功退款累计 ≥ 该笔收到的钱」,因此一笔实收为 0 的支付在完全没有发生退款的情况下也满足该判据:isFullyRefunded 为 true,并出现在「已全额退款」的筛选结果里。这类支付本来就不打算收钱 —— 零额验卡、免费试用的首期扣款,或被抵扣券 / 优惠券全额抵扣的订单。它的含义是买家实际未被扣款,不是发生过退款。要区分两者,请看是否真有退款:refundedAmount 大于零、refunds 列表非空,或 refundStatus 为 refunded —— 最后这个只由「是否存在成功退款」决定,不受本次修复影响。这条规则本身不是新的:标价本身为 0 的订单一直就是这样呈现的。本次修复扩大的是它的命中范围 —— 实收为 0 而标价非零的支付现在也会进入该集合,即 live 侧受影响的 7 笔里有 3 笔(test 侧 111 笔里有 39 笔)。本次修复不会让任何支付离开该集合。

eventId 映射

eventId 标识触发事件的业务实体:
有些事件在同一笔订阅上可以发生多次,它们的 eventId 带后缀,每一次发生各自成为一条投递。四个事件追加事件时刻(完整 ISO 8601):subscription.canceling、subscription.uncanceled、subscription.past_due、subscription.recovered —— 取消与撤销取消、逾期与恢复都可以反复发生。subscription.renewed 改为追加顺延后的当期结束,连续多次续费各自成键。该后缀是天粒度日期(YYYY-MM-DD),因此同一自然日内发生两次顺延会合并成一条投递 —— 只有用压缩计费周期做测试才够得到,真实的周付、月付不会遇到。其余事件直接用实体 ID —— 它们在同一实体上最多发生一次。

把付款关联到账期

一次续费会产生两条相互独立的投递:subscription.payment_succeeded(以这笔扣款为键)和 subscription.renewed(以订单为键)。它们同批发出,但先后顺序和间隔都不保证,所以不要在收到的瞬间强关联。两类事件各自落库,再按 data.orderId + data.periodNumber 配对:第 N 期的扣款和进入第 N 期的续费通常带同一对值,乱序、延迟、重试都不影响配对。两个值都是通道自己的期号原样透传,一致性来自通道编号而不是 Waffo 的保证 —— 下面的 timestamp 兜底请保留,以应付极少数不一致的情况。同一对值也把 subscription.activated 关联到首期扣款(periodNumber: 1),把 subscription.recovered 关联到补上逾期账期的那笔扣款。 该字段上线前创建的订阅没有 periodNumber。这类订单退回到 data.orderId 加顶层 timestamp(事件生成时间,不随重试变化)在几分钟内就近匹配。两种情况都请先按 id 或 eventId 对每类事件去重,再做关联。

事件类型

概览

subscription.plan_changed 在订阅切换到另一个方案时发出。direction 取值为 upgrade / downgrade / same_price;planChange.chargedAmount 是本次实收,按天折抵或商户指定实收时与方案标价不等。subscription.plan_change_scheduled 在变更已确认但要到下周期才生效时发出,subscription.plan_change_failed 在变更未完成时发出。三者均已在生产环境上线 —— 三个都要处理,否则会漏掉结果。具体收到哪些,取决于生效档位:也就是说下周期生效的一次变更会产生两个事件,且只有第二个才代表方案真正生效。不要在 plan_change_scheduled 上就放开新方案的权益。
各事件在订单、订阅、退款生命周期中的位置,以及哪些状态迁移不发事件,见 生命周期与触发时机。

事件详情

触发条件:一次性订单首次支付成功。Body:
此事件中 amount 与 chargedAmount 相等 —— 即通道实际扣了多少。listPrice 是下单时的标价;本示例中两者不等,因为发生了按比例抵扣。不含订阅与退款字段。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、paymentMethod、paymentLast4、chargedAmount、listPrice。建议操作:
  • 交付数字商品(许可证密钥、下载链接、激活码)
  • 更新您的订单管理系统
  • 向 customer 发送确认(如果未使用 Waffo 内置邮件)
同一订单只会触发一次 order.completed。退款通过 refund.succeeded / refund.failed 通知。
触发条件:新订阅的首次支付成功(pending → active)。Body:
amount 是订阅的标准期价格,不是实际扣款额。有试用或首期折扣时,首次扣款与之不同 — 需要实收金额请用 subscription.payment_succeeded。
此事件以订单为主体而非某笔扣款 — 不含 paymentId 及其他支付字段。周期落库与事件发布在同一次请求内完成,因此 billingPeriod、currentPeriodStart、currentPeriodEnd 一定出现。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、planPrice。建议操作:
  • 为订阅者开通账户和授予访问权限
  • 记录订阅开始日期
仅在订阅首次从 pending 转为 active 时触发。同一笔首次支付还会针对扣款本身发出 subscription.payment_succeeded — 见 订阅生命周期。
触发条件:订阅扣款成功 — 含首次支付、每次续期,以及补缴逾期周期的那笔扣款。Body:
与其他 subscription.* 事件不同,此事件以扣款为主体,因此携带支付字段。amount 是本期实际扣款金额,与 chargedAmount 相等;listPrice 是本期的方案标价,按比例抵扣或零额验卡会让它高于实扣。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、paymentMethod、paymentLast4、chargedAmount、listPrice。
这是纯支付事件。 它不携带 billingPeriod、currentPeriodStart、currentPeriodEnd、canceledAt、orderStatus —— 这五项描述的是订阅而不是扣款,现在只由订阅域事件承载;那些事件的落库与发布在同一次请求内完成,字段不会再空缺。如果你的处理器从本事件读过这五项中的任何一项:periodNumber 是例外:它描述的是这笔扣款,因此保留在本事件上,告诉你刚付的是第几期。只用支付字段与 orderId 的处理器无需改动。
建议操作:
  • 记录本次扣款的收据
  • 为本计费周期生成发票
  • 延长服务期限改在 subscription.renewed,恢复完整访问权限改在 subscription.recovered
触发条件:当期计费周期顺延 —— 已付费的那一期刚结束、下一期开始。订阅的首期不算续期,不发本事件。Body:
本事件不含支付字段 —— 它报告的是新周期,不是那笔扣款。amount 是订阅的标准期价格。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、planPrice。建议操作:
  • 把服务期限延长到新的 currentPeriodEnd
去重:eventId 带上顺延后的当期结束,因此同一笔订阅连续多次续期各自成为一条投递,而通道通知的重复投递不会产生第二条。
触发条件:逾期订阅上的一次重试扣款成功,把它救回 active(past_due → active),与 subscription.past_due 形成闭环。首付与常规续期不发本事件。Body:
本事件不含支付字段 —— 扣款本身由同一次重试独立投递的 subscription.payment_succeeded 报告。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、planPrice。建议操作:
  • 恢复你在 subscription.past_due 时降级的访问权限
去重:eventId 带上事件时刻,因此同一笔订阅反复逾期与恢复时,每次恢复各自成为一条投递。
触发条件:customer 或商户请求取消。订阅在当前付费期结束前仍保持有效。Body:
canceledAt 是请求取消的时间,不是权限到期时间。customer 已付费至 currentPeriodEnd — 在 canceledAt 收回权限会切断其已付费的服务。
此事件不含支付字段。amount 是订阅的每期价格,不是某笔扣款。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、planPrice。建议操作:
  • 显示”订阅将于 [日期] 到期”提示
  • 提供挽留流程(例如折扣续费)
  • 不要撤回访问权限 — customer 已为当前周期付费
customer 可以在周期结束前撤回取消(触发 subscription.uncanceled)。
触发条件:在当前周期结束前撤回取消。Body:
此事件不含支付字段。撤回取消时 canceledAt 会被清除,因此不会出现。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、planPrice。建议操作:
  • 移除”即将到期”提示
  • 恢复自动续费状态
触发条件:订阅产品变更(升级或降级)。Body:
顶层字段描述的是变更之后的状态;data.planChange 携带变更前后的对照,因此无需自行记录变更前的套餐。direction 取值为 upgrade / downgrade / same_price。amount 不是买家实付的金额。 顶层的 amount / total / subtotal / taxAmount 是新方案的标价,planChange.chargedAmount 才是本次实收。换方案时二者必然不等 —— 要么按天折抵,要么商户指定实收。对账请以 chargedAmount 为准。planChange 只出现在换方案事件上,其他事件的 data 里不含该键。其内部每个字段在缺值时均为 null:尚无成功支付时 chargedAmount 为 null,立即生效档 effectiveDate 可能为 null,direction 遇到非约定取值时落 null 而不透传。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、planPrice。建议操作:
  • 更新 customer 的访问级别(添加/移除功能)
  • 更新计费记录
触发条件:换方案已确认,但要到下一个计费周期开始时才生效。在那之前 customer 仍在当前方案上。Body:
此时变更尚未生效。 effectiveDate 是新方案开始的那个未来时刻,orderStatus 为 pending,currentPeriodStart / currentPeriodEnd 整个不出现 —— 在那之前新订阅没有计费周期。chargedAmount 为 null:下周期生效档在切换时刻才扣款,确认时不扣。不要在本事件上就放开新方案的权益,等 subscription.plan_changed。id 与 eventId 是新订阅单,也是切换时刻那条 subscription.plan_changed 将携带的同一张单。customer 当前活跃的那笔订阅是另一张单,本载荷里不出现。那张单此时已被标记为待切换,但不会为它发出 subscription.canceling —— 本事件是你唯一能拿到的信号。切换前若有多条确认到达,你只会收到一条投递 —— 它们按 eventType + eventId 收敛。之后那条 plan_changed 事件类型不同,永远不会被它吞掉。推荐处理:
  • 记录这笔待生效的变更及其 effectiveDate
  • 当前方案的权益不做任何改动
  • 可选:告知 customer 新方案何时开始
触发条件:换方案未完成 —— 支付未获授权,或支付渠道返回失败。原方案继续有效。Body:
本事件的 planChange 大部分为 null,这是设计如此。 什么都没生效,因此没有变更方向、没有生效时刻、也没有可报的金额:只有 oldPlanName 与 newPlanName 有值,另外五个字段恒为 null。照 subscription.plan_changed 的 body 写的处理器在这里会读到 null —— 动这些字段前先按 eventType 分支。
orderStatus 是 canceled,但 customer 并没有失去订阅。 被取消的是那张开不起来的新单;原订阅完全不动,仍然活跃。在本事件上撤销权益是 bug。真正的取消一定以 subscription.canceled 到达。
重试会新建一张订阅单,因此第二次失败会带着不同的 id / eventId 到达,不会被收敛进这一条。推荐处理:
  • customer 的权益保持原样,不做任何改动
  • 清掉此前从 subscription.plan_change_scheduled 记下的待生效变更
  • 可选:提示 customer 重新尝试变更
触发条件:订阅已终止 — 付费期结束,不会再有续费。Body:
此事件不含支付字段。canceledAt 是请求取消的时间,早于本事件 — 订阅在此之前一直有效至 currentPeriodEnd。amount 是每期价格,不是最后一笔扣款。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、canceledAt、planPrice。建议操作:
  • 撤回访问权限(或降级到免费版)
  • 保留数据一段宽限期(以防 customer 重新订阅)
  • 发送”订阅已结束”确认
这是终态。订阅已不可逆地结束。
触发条件:续期支付失败,订阅进入逾期状态。Body:
eventId 以事件时刻作后缀,因此每一次进入逾期状态各自成为一条投递。此事件不含支付字段,因此载荷不会说明扣款失败的原因;amount 是应付金额。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、planPrice。建议操作:
  • 通知 customer 更新支付方式
  • 可选择降级服务(限制功能而非完全撤回)
  • 不要立即撤回访问权限 — PSP 可能会自动重试扣款
去重:每进入一次逾期状态投递一条。订阅只有在一次成功扣款把它救回 active(并发出 subscription.recovered)之后才会再次进入 past_due,因此同一段逾期期间的多次重试失败不会扇出成多条事件。
触发条件:退款已完成,资金已退回。Body(部分退款示例:原始支付 29.00,本次退款 10.00):
amount 是本次退款金额,total 是原始支付金额。部分退款时两者不等 — 入账请取 refundedAmount(或已弃用的 amount),切勿取 total。
subtotal / total / taxRate / taxName 是原始支付口径,保留供对比。taxAmount 恒为 "0",退款不提供税拆分 —— 原单的真实税额在 originalPayment.taxAmount。退款额请取 refundedAmount,被退那笔原单请取 originalPayment。该笔还能退多少,请按 originalChargedAmount 算,不要按 originalPayment.total —— 前者是买家真被扣的钱,两者不等时按标价去退必然被通道拒绝(标价 50.00、实扣 5.00 的那笔,最多只能退 5.00)。paymentId 指向被退款的那笔扣款 — 订阅场景下即具体某一期,据此可判断退的是哪一期。载荷不含该笔支付的累计退款额,若一笔支付可能被多次退款,请自行记录。无值时省略:productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、refundTicketMerchantExternalId、taxRate、taxName、subtotal、total、paymentMethod、paymentLast4、refundReason、refundedAmount、originalChargedAmount、originalPayment。建议操作:
  • 撤销已交付的数字商品(吊销许可证、禁用下载)
  • 将订单状态更新为”已退款”
触发条件:退款处理失败。Body:
资金并未发生变动。refundedAmount(以及已弃用的 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 最多 45 分钟 —— 不是 5 分钟:重试原样重放最初的签名头,t 不会重新签发,而最后一次重试会在首次投递 31+ 分钟后到达,5 分钟窗口会把你自己故障恢复期间的合法重试全部拒成 401。防重放靠 payload 的 id 幂等去重(每事件唯一、重试不变),不靠收紧窗口。
Test 和 Production 使用不同的密钥对。将公钥与载荷中的 mode 字段匹配。
存储接收到的载荷用于调试。Dashboard 也提供投递日志查询功能。
为您订阅的每个事件添加处理分支,即使暂时不需要处理。对未处理的事件返回 200 — 返回错误会触发不必要的重试。

测试

发送测试事件(推荐)

使用 Dashboard “Send Test Event” 按钮发送测试事件,无需触发真实交易。测试事件使用固定的示例数据(amount 0、taxAmount 0、product “[TEST] Webhook Verification”),始终使用 Test 密钥签名。 支持所有 10 种事件类型 — 逐一测试以验证您的处理程序。
periodNumber 在测试事件与生产环境中的行为不同。测试发送背后没有真实订阅,因此退款样本无法说明它退的是哪一期。生产环境中,订阅扣款的退款事件是带 periodNumber 的 —— 取被退那笔扣款所属周期。请不要据测试事件断定订阅退款没有该字段; 处理程序应在该字段出现时读取它。

使用测试模式

  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.canceling 和 subscription.canceled 的区别

  • canceling:已请求取消,但当前付费期尚未结束。订阅仍然有效,customer 可以撤回取消(触发 uncanceled)。不要撤回访问权限。
  • canceled:订阅已终止。这是不可逆的 — 撤回访问权限或降级权限。

不同事件中 data.amount 的含义

所有事件:data.amount 是该特定事件的交易金额(含税):
  • order.completed / subscription.activated — 支付金额
  • subscription.payment_succeeded — 本期实际扣款金额
  • subscription.renewed / subscription.recovered — 订阅每期金额
  • subscription.past_due — 本期应付金额
  • refund.succeeded / refund.failed — 退款金额
  • subscription.canceling / subscription.canceled / subscription.uncanceled — 订阅每期金额