> ## Documentation Index
> Fetch the complete documentation index at: https://waffo.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook 通知投递契约 FAQ

> Waffo 投递Webhook 通知时应用的确切规则：您的接口有多长时间应答、哪些响应体算成功、每种事件的回调 URL 来自哪里、为什么同一事件可能到达两次或乱序，以及未支付通知长什么样。

已发布的页面给出了Webhook 通知的结构和重试计划：
[Webhook 通知概览](/docs/zh/developer-docs/webhook/overview)、
[Webhook 通知重试](/docs/zh/developer-docs/webhook/retry)、
[Webhook 通知事件类型列表](/docs/zh/developer-docs/webhook/event-types)和
[处理最佳实践](/docs/zh/developer-docs/webhook/best-practices)。

**本页是边缘情况的契约**——当投递没有按主路径所示行为时
出现的问题。事后检查某一次投递，见
[Webhook 通知事件日志与手动重发](/docs/zh/support/portal-webhook-log-faq)；
事件词典和批量重放，见
[Webhook 通知事件词典与批量重放](/docs/zh/support/webhook-events-and-resend-faq)。

## 我的接口有多长时间响应？

\*\*15 秒。\*\*这是投递调用的读超时；3 秒内
未建立的连接会更早失败。超过任一项都算作
投递失败，该记录回到重试计划。

所以不要在请求内做您的业务处理。验证签名、
持久化事件、应答，然后异步处理——这是
[最佳实践页面](/docs/zh/developer-docs/webhook/best-practices)推荐的模式。
一个在应答前调用您自己三个服务的处理器，是写得正确的集成
显示为 `FAILED` 的最常见原因。

## 我返回了 HTTP 200，为什么投递仍算失败？

因为状态码不是决定因素。判定读取的是您的
**响应体**，只有以下两条同时成立时投递才算
成功：

1. 响应是 **2xx**——任何 4xx 或 5xx 在读取响应体之前就失败。
2. 响应体是**带 `message` 字段且其值为 `success` 的 JSON 对象**。

其他一切都算失败：响应体为 `failed` 或 `unknown`（两者都表示
"请重试我"）、空响应体、不同的字段名、HTML 错误页，或
完全无法解析为 JSON 的响应体。

三个具体点解释了大部分意外：

| 问题 | 答案 |
| - | - |
| 裸字符串响应体 `success` 接受吗？ | \*\*不接受。\*\*必须是 JSON 对象——`{"message":"success"}`。纯文本 `success` 会被拒绝。 |
| 值区分大小写吗？ | 不。`success`、`Success` 和 `SUCCESS` 都接受。 |
| 字段名区分大小写吗？ | **是。**`message` 必须小写；`Message` 不会被读取。 |
| 多余字段会破坏它吗？ | 不会。额外字段被忽略。 |

## 响应的 Content-Type 必须是 application/json 吗？

实际上是——请发送 `Content-Type: application/json`。响应按
JSON 读取，所以以 `text/plain` 或 `text/html` 提供的响应体无法解析，
即便字节本身是 `{"message":"success"}`，
投递也会被记为失败。已发布的
[概览](/docs/zh/developer-docs/webhook/overview)正因此在商户
响应上展示了该请求头。

## 如果签名验证失败，我应该拒绝请求还是照常处理？

绝不处理。先验证，只在验证通过后才对事件
采取行动——这也是一项
[验收要求](/docs/zh/developer-docs/tools-and-references/references/acceptance-criteria)：
验证失败时不得更新本地状态，必须改为从查询 API 读取真实
状态。

该*返回*什么由此得出：应答 `{"message":"failed"}`，这让
记录留在重试计划中，然后去查明原因。无法
验证的签名是某一方的配置问题，不会
因为重试而自行修复——所以把重试当作缓冲时间，而不是修复手段，
同时通过查询 API 对账。

如果您的签名验证只在重新序列化报文后才失败，
那就是原因：请对**原始请求体字节**验证，绝不要对
解析后再字符串化的副本验证。字段顺序、空白和数字格式在
一次往返后都会改变，而签名是对原始
字节计算的。见
[Webhook 通知签名验证](/docs/zh/developer-docs/webhook/signature-verification)。

## "8 次重试"到底是什么意思？间隔有保证吗？

**包括第一次**在内共八次尝试，间隔逐渐增大——计划见
[Webhook 通知重试](/docs/zh/developer-docs/webhook/retry)，端到端约
20 小时。

计划本身不会告诉您的两点限定：

* \*\*间隔是最小值，不是约定时刻。\*\*重试在其计划时间*变为可执行*，
  并在投递工作进程下次拾取时发出。
  积压时某次尝试可能比表格显示的更晚到达。永远不要编写
  假定重试在精确时间到达的逻辑。
* **预算用尽后记录最终失败**，不再有进一步
  自动尝试。Portal 显示为没有下次重试时间的 `FAILED`
  记录。

## 为什么"下次重试时间"有时是空的？

因为它只在自动重试确实仍在计划中时才填充。
失败*且*还有重试预算的记录会填充该字段；
已投递的记录和重试耗尽的记录为空。

因此两列的组合才是真正的解读，而不是单看状态：

| 状态 | 下次重试 | 解读 |
| - | - | - |
| Failed | 有时间戳 | 仍在计划中；不要动 |
| Failed | 空 | 预算耗尽；只有手动重发才能推动 |
| Successful | 空 | 已投递并被接受 |

## 所有尝试都失败后，我该怎么做？

从该对象自己的查询 API 读取当前状态并对账——
权威状态始终在对象上，不在通知中。
[故障恢复指引](/docs/zh/developer-docs/webhook/retry)列出了同样的
三个步骤：查询对象、定期批量对账、如果投递从未到达
则联系支持团队。

修好接口后您也可以从 Portal 重放，支持单条、
多选或按日期范围模式，每次请求最多 **50 条**。注意对
仍然有故障的接口重放会消耗同一预算中的一次尝试——
所以先修复，再重放。详情见
[Webhook 通知事件词典与批量重放](/docs/zh/support/webhook-events-and-resend-faq)。

## 为什么某条记录上的 Resend 是灰的？

记录无法重发恰好有三个原因：

| 原因 | 含义 |
| - | - |
| 记录没有存储回调 URL | 没有可发送的目标；重发无法凭空造出目的地 |
| 记录仍在首次投递中 | 进行中的尝试不可重发；等它落定 |
| 记录不属于您 | 它不属于您会话的商户 ID |

注意**不在**该列表上的：已成功投递的记录
**可以**重发。这是有意的——Waffo 记为已接受的投递
在您这边可能仍未处理，所以允许重放。

重发还需要您 Portal 角色具有Webhook 通知重发权限。没有
该权限时，操作和行选择根本不会渲染，而不是显示为
禁用——所以完全看不到重发控件的操作人面对的是
权限问题，不是记录资格问题。请联系您的 Portal Admin。

## 每种事件的回调 URL 来自哪里？

这是"没有收到通知"的最常见单一原因：没有解析出 URL，
所以什么都没发，也没有写任何记录。

| 事件 | URL 来源 | 可在 Portal 配置？ |
| - | - | - |
| `PAYMENT_NOTIFICATION` | 创建订单请求上的 `notifyUrl`——**必填**，最多 1024 个字符 | 否 |
| `REFUND_NOTIFICATION` | 退款请求上的 `refundNotifyUrl`；缺失时使用 Portal 中配置的 Refund Notify URL | 是，作为回退 |
| `CHARGEBACK_NOTIFICATION` | 仅 Portal 中配置的 Chargeback Notify URL | 是，仅此一途 |
| `TOKENIZATION_NOTIFICATION` | tokenization generate 请求上的 `notifyUrl`；对于支付过程中绑卡（绑定以供后续使用）的卡，使用 Portal 中配置的 Tokenization Notify URL | 是，针对支付时的绑卡 |
| 订阅事件 | 订阅上持有的 `notifyUrl`，来自创建订阅请求 | 否 |
| `PAYOUT_NOTIFICATION` | Payout 请求上的 `notifyUrl`——API 创建的 Payout **必填** | 否 |
| `WITHDRAW_NOTIFICATION` | 提现请求上的 `notifyUrl`——商户发起的提现必填 | 否 |

两个值得据此设计的后果：

* \*\*没有全局支付通知 URL。\*\*支付通知只发往
  您在创建订单时传的 `notifyUrl`，该字段之所以必填，
  正是因为没有回退。您不能在 Portal 配置一个地址
  然后不再发送该参数。
* \*\*拒付通知没有请求级参数。\*\*如果 Portal
  URL 为空，这些事件就不会投递，也不留下日志记录。
  Webhook 通知日志页上拒付结果为空，几乎总是意味着
  Chargeback Notify URL 从未配置。支付过程中绑卡的
  绑卡通知同理；没有 `notifyUrl` 的独立绑卡
  请求同样不会被通知。

三个 Portal 侧 URL 的配置见
[Portal 自助安全配置](/docs/zh/support/portal-security-config-faq)。

## 我改了通知 URL，已在重试队列中的通知会发到哪里？

\*\*发到旧 URL。\*\*回调 URL 在每条通知记录创建时
存储在该记录上，每次重试——以及每次手动重发——都发到
存储的值。编辑 Portal 配置，或在新订单上传入不同的
`notifyUrl`，只影响之后创建的记录。

请相应地规划 URL 迁移：保持旧接口继续接受流量，直到
在途窗口耗尽，而不是在新 URL 保存的那一刻
就切换。

## 通知 URL 字段接受什么？必须是 HTTPS 吗？

保存时的校验接受：

* 以 `http://` 或 `https://` 开头的 URL
* 仅 ASCII 字符，且无空白
* 带主机的可解析地址——**端口、路径和查询字符串
  都允许**
* Refund 和 Chargeback Notify URL 最多 512 个字符
* 空值，即清除配置

**无论如何请用 HTTPS。**`http://` 能通过校验，但已发布的
[配置要求](/docs/zh/developer-docs/webhook/overview)规定 HTTPS
且 TLS 1.2 或以上，并且Webhook 通知携带支付数据。

一个从校验规则看不出来的硬约束：\*\*您的 TLS
证书必须被公共证书颁发机构信任。\*\*投递
客户端使用默认信任库且没有例外路径，所以自签名或
其他不受信任的证书会导致连接失败，每次尝试都失败。
私有 CA 在这里不可用。

## 同一事件会到达多次吗？我应该按什么去重？

会，重复是可能的，您的处理器必须幂等——
验收标准正是测试这一点。重复的来源包括
内部重投、手动重发，以及同一订单上授权阶段的通知
后跟最终通知。

\*\*报文中没有事件 id 或投递 id。\*\*报文体是
`{"eventType": ..., "result": {...}}`，唯一值得注意的请求头是
签名。所以请按 **`eventType` 加 `result` 内的业务标识符**去重——
事件所涉及的支付订单、退款订单、订阅、Payout 订单、
提现订单或 token——再结合您正在
处理的状态。仅按标识符去重会让您丢掉
已记录授权的订单的最终状态。

## 到达顺序有保证吗？我会先收到"failed"再收到"succeeded"吗？

不，顺序没有保证，而且是的——您可能乱序收到通知。
重试、排队和网络延迟都会重排投递。

让这一点安全的规则在
[最佳实践页面](/docs/zh/developer-docs/webhook/best-practices)：\*\*不要用
到达顺序驱动您的状态机。\*\*把每条通知当作触发器，
从对象获取终态——要么从报文的状态字段
并加上防止回退的保护，要么在任何通知后调用查询 API。
对于订阅这是文档化的模式：在状态通知或周期变更通知之后，
调用一次订阅查询，并对结果运行
单一的统一流程。

## 先授权后请款的订单，在授权阶段我会收到通知吗？

会，它以**同一订单上的 `PAYMENT_NOTIFICATION`** 到达，与最终
通知相同——不是独立的事件类型。您通过报文中的订单状态
区分它们，而不是通过事件：

| `orderStatus` | 含义 |
| - | - |
| `AUTHORIZATION_REQUIRED` | 等待客户授权 |
| `PAY_IN_PROGRESS` | 支付进行中 |
| `AUTHED_WAITING_CAPTURE` | 已授权；等待您的请款调用 |
| `CAPTURE_IN_PROGRESS` | 请款进行中 |
| `PAY_SUCCESS` | 终态——已支付 |
| `ORDER_CLOSE` | 终态——未支付 |

这也是为什么仅按订单 id 去重是错的：这里一个订单收到
两条通知是预期的，且只有一条是终态。见
[授权与请款](/docs/zh/essentials/auth-capture)。

## 通知会携带非终态状态吗？我需要重新查询吗？

会——如上表所示，订单可能在仍处理中时被通知。
对终态状态采取行动；对非终态状态，记录并等待。
如果您当时需要确定性，请调用查询 API 而不是推断。

唯一绝不能让您自行关闭订单的状态是未知
结果：遇到 `E0001` 时，保持订单开放，用相同参数重试，并等待
查询结果或通知。自行关闭是文档化的
验收失败——见
[集成验收标准](/docs/zh/developer-docs/tools-and-references/references/acceptance-criteria)。

## 订单被取消、超时或失败时我会收到通知吗？

会——三者都产生 `PAYMENT_NOTIFICATION`，且三者都落在同一个
终态：**`ORDER_CLOSE`**。没有单独的 `CANCELLED`、`EXPIRED`
或 `FAILED` 状态值。

要区分它们，请读取报文中 `orderFailedReason` 携带的失败详情，
它包含 `orderFailedCode` 和
`orderFailedDescription`。所以正确的处理形态是：`ORDER_CLOSE` 表示
"不要履约"；`orderFailedReason` 告诉您对客户说什么以及
重试是否有意义。

## 我需要订阅事件吗，还是所有事件都会自动触发？

没有订阅注册表，也没有按事件的选择加入。只要按上表
能为某事件解析出回调 URL，它就会被投递——这意味着您
对接收内容的控制恰好等于您对哪些 URL 存在的控制。

对最小集成的实际解读：在创建订单时传 `notifyUrl`
并处理支付通知；对您尚未编写处理器的事件族，
把 Portal 配置的 URL 留空，改从
查询 API 获取那些状态。轮询对每一种您能枚举的对象都可行：
拒付通过 `/api/v1/chargeback/list` 和 `/api/v1/chargeback/inquiry`，
token 状态通过 `/api/v1/tokenization/inquiry`。代价是延迟——而
对拒付而言，举证截止期从案件创建起算，所以如果您处理
争议，请配置 Chargeback Notify URL 而不是依赖
轮询。

## 我能在 Portal Webhook 通知页面找到 Payout 和提现通知吗？

通过该页面不能。Webhook 通知日志页搜索固定的七个事件
族——支付、退款、绑卡、拒付和三个订阅
事件——Payout 和提现通知不在其中，所以无论怎么筛选都
不会出现。

对于这些，请从 Payout 和提现查询 API 读取状态。并注意
对应的配置不对称：两者都没有 Portal 配置的 URL，所以其
通知地址就是您在创建它们的请求上传的值。

## 我怎么知道某条通知从未投递成功？

目前是靠自己看，而不是被告知。投递失败时没有面向商户的告警，
也没有查询通知记录的商户 API。您的
两条路径是：

1. **Portal Webhook 通知日志**，在其 90 天窗口内——筛选失败，
   并用下次重试列区分"仍在重试"与"最终
   失败"。
2. \*\*对照查询 API 对账。\*\*定期查询您期待终态的对象，
   并与您自己的记录比较。
   这是推荐的安全网，也是唯一同时覆盖
   Portal 页面不跟踪的事件的手段。

请建好第二条。它能捕获第一条无法展示的情形：
因为没有解析出 URL 而从未被创建的事件。

## 你们从固定的 IP 地址集合投递吗？我能加白名单吗？

没有公布的Webhook 通知投递出口 IP 段，也没有配置静态出口
地址——所以没有什么可以固定。按 IP 为 Waffo 的
回调来源加白名单不是可以据此设计的做法。

替代做法：认证回调本身而不是其来源。每条
Webhook 通知都携带对原始报文体的签名，用 Waffo 私钥签署，
可用**商户 Portal → Integration** 中的 Waffo 公钥验证。
经过验证的签名比来源 IP 匹配是更强的保证，且
不会因基础设施变化而失效。

## 开发时我怎么在本机接收回调？

您的 `notifyUrl` 必须能从公网访问——`localhost` 和
私有地址无法访问。请使用隧道工具，并把隧道的公网 URL
注册为您的 `notifyUrl`；已发布的
[概览](/docs/zh/developer-docs/webhook/overview)展示了两种常见
工具的命令。

两个实际问题：隧道主机名通常每次重启都会变化，
所以请按订单传 URL 而不是保存在 Portal 配置中，且
所呈现的证书必须被公共信任——托管隧道
能提供，而本地自签名服务器不能。

***

Sandbox 投递遵循同一契约，包括重试计划——sandbox 首先能
做到和不能做到什么，见
[Sandbox 模拟限制 FAQ](/docs/zh/support/sandbox-simulation-limits-faq)。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.