engineering

Webhook 可靠性:支付系统的生存指南

为什么你的 webhook 投递系统需要重试策略、签名密钥、死信队列和手动回放 UI——以及如何在不破坏 exactly-once 语义的前提下把这些都交付出去。

2026年4月1日12 min read作者:Kaadxpay 工程团队

Webhook 是任何支付平台调试得第二多的集成面(仅次于"为什么我的出款没结算?")。它看起来简单——出事时往客户给的 URL POST 一个 JSON——但生产现实充满讨厌的边界 case。

下面是经过实战检验的清单,能帮你交付一个能扛住真实客户集成的 webhook 系统。

为什么 webhook 难

基本问题:你在调用你不掌控的代码,跑在你不掌控的基础设施上,经过别人的网络。每个假设都得做防御性设计。

会出错的事情:

  • 客户的端点会在某次发布时挂 4 小时
  • 客户的端点会返回 200 但消息被丢了
  • 客户的端点响应太慢,你的客户端超时,你不知道对方收到没
  • 客户的端点先返回 503、重试时返回 200,客户端处理了同一个事件两次
  • 客户的端点被换掉,新端点不认你旧的签名密钥
  • 客户的 WAF 静默丢掉你的 IP
  • 客户的 TLS 在某个周六过期

朴素的"发出去就完事"webhook 实现,对上述大多数情况都束手无策。生产级实现能处理。

不可妥协的功能清单

任何支付级 webhook 系统都需要:

  1. at-least-once 投递,带明确重试策略
  2. 每个请求体都做 HMAC 签名
  3. 稳定的 event ID,让接收方能去重
  4. 永久失败投递的 死信队列(DLQ)
  5. 手动回放 UI
  6. 客户能看到的 每个端点健康指标
  7. 真实可用的 IP 白名单文档
  8. 合理的超时(我们用 5s,其他人用 10-30s)
  9. 零停机的密钥轮换
  10. 版本化的事件 schema,能演进

如果你的设计缺了任何一项,你会过得很糟。

重试策略

最容易出错的地方。两种失败模式:

  • 太激进。 每 5 秒重试一次持续 24 小时。客户端点恢复时被你的重试雷击式 DDoS。
  • 太温和。 1 分钟内重试 3 次就放弃。客户的发布要 10 分钟;事件丢了。

走得通的模式:带 jitter 的截断指数退避,限定保留时长

我们的时间表:

  • T+0s — 首次尝试
  • T+10s — 第一次重试(如首次失败)
  • T+30s
  • T+1m
  • T+5m
  • T+15m
  • T+1h
  • T+6h
  • T+24h — 最终尝试

24 小时内总共 9 次。每次重试有 ±25% 的 jitter。9 次失败后事件进 DLQ。

这能抓住大约 97% 可恢复的客户端点,并把对损坏端点的负载控制在 9 次 / 24 小时。

什么算"成功"?

我们把 2xx 范围内的任意 HTTP 状态码 视为成功。具体:

  • 200 — 显式成功
  • 201、202、204 — 同样视为成功
  • 任何 3xx — 我们最多跟 2 跳重定向,再之后视为失败
  • 4xx — 失败,重试(也许端点配置错了,但能修)
  • 5xx — 失败,重试
  • 连接错误 / 超时 — 失败,重试
为什么对 4xx 也重试?

传统说法是"4xx 是客户端的错,别重试"。现实是:401 可能意味着客户在轮换密钥并修复 bug;404 可能意味着代理配错;422 可能意味着 schema 校验有问题。把 4xx 当作终态,会因为本来几分钟就能修的瞬时错误惩罚客户。重试,但要打响亮的日志。

HMAC 签名

每一个 webhook 请求体 必须 签名。模式:

  1. 客户注册端点,我们生成一个密钥(32 字节随机,hex 编码)
  2. 每次投递,我们计算 HMAC-SHA256(secret, timestamp + "." + body),作为 X-Kxp-Signature: t=<ts>,v1=<hex> 头发出
  3. 客户处理函数:
    • 读出 timestamp 与 signature 头
    • 验证 timestamp 在 ±5 分钟内(防重放)
    • 重新计算 timestamp + "." + body 的 HMAC
    • 用常时间比较

我们在文档里发布了主流语言的验证示例。

我们见过的两个反模式:

  • 只签 body,不签 timestamp。 易被重放攻击。
  • 把 secret 写进源码。 客户会 commit 进仓库。文档里要响亮地说明放在 env 或 vault。

接收方的幂等性

和我们支付 API 幂等那篇同样的理念,镜像应用:

  • 我们对每个业务事件发布唯一 event_id(UUIDv4)
  • 不管投递尝试多少次,重试时都用 同一个 event_id
  • 接收方按 event_id 去重

如果你正在集成任何支付服务商的 webhook,处理函数骨架应是:

async function handleWebhook(req) {
  const sig = req.headers['x-kxp-signature'];
  const body = await req.text();

  if (!verifySig(sig, body, process.env.KXP_WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(body);

  // Dedup on event_id
  const existing = await db.events.findOne({ event_id: event.id });
  if (existing) return res.status(200).send('Already processed');

  await db.events.insert({ event_id: event.id, status: 'processing' });

  try {
    await processEvent(event);
    await db.events.update(event.id, { status: 'done' });
    return res.status(200).send('OK');
  } catch (e) {
    await db.events.update(event.id, { status: 'failed', error: e.message });
    return res.status(500).send('Retry me');
  }
}

处理前的去重检查至关重要。少了它,慢处理函数在仍在运行时被重试,就会双倍处理。

死信队列与回放

24 小时内 9 次失败之后,事件进 DLQ。DLQ 应该:

  • 能在商户控制台里查看 — 他们需要看到卡在哪
  • 可以一键手动回放
  • 可以批量回放:场景是"我们刚修好端点,把过去 6 小时全部重发"
  • 可按事件类型、时间范围、错误原因过滤

我们的 DLQ 还有 自动回放 功能:当客户端点从"down"恢复到"up"(我们用周期性 1px ping 监测),我们自动回放排队事件。这能覆盖最常见的场景:他们发布完成,我们就接回正轨,不需要人工介入。

端点级健康

我们投递到的每个端点,都在商户控制台有一个健康页面:

P50 响应时间
142 ms
客户端点,最近 1h
2xx 比例
99.4%
最近 24h
待重试数
2
当前重试队列

商户看到的和我们看到的一样。当他们说"你们的 webhook 坏了",我们能立刻指出:"实际上你的端点过去 22 分钟一直返回 503——这是 body。"

仅这一项功能,就让我们的 webhook 相关支持工单减少了约 60%。

IP 白名单的准确性

客户会把我们加到防火墙白名单里。他们需要一个稳定、准确、文档完善的源 IP 列表。

如果你的 webhook 投递跑在 Kubernetes 动态 IP 的 pod 上,那是个问题。修法:

  • 把所有出站 webhook 流量经过 专用 NAT 网关,使用预留 IP
  • 在控制台和 API 文档里显著地公布这些 IP
  • 绝不静默更换。 提前 90 天公告,发部署前邮件,新旧 IP 并行运行 30 天

我们公布 4 个 IP(覆盖 2 个区域,A/B 高可用)。自上线起就稳定,未来可见时间内也会保持。

密钥轮换

客户会轮换 webhook 密钥。可能因为泄漏,也可能只是卫生习惯。没有优雅的轮换流程,每次轮换都会中断投递。

模式:

  • 端点配置同时支持 两个有效密钥currentprevious
  • 我们用 current 签名。接收方对两个都验证。
  • 客户轮换:让我们把新值设为 current,旧值移到 previous;他们部署能接受两者的代码;等部署稳定后,让我们清空 previous

按正确顺序操作,停机时间为零。不按这个流程,轮换窗口期就是全量故障。

版本化

我们发布的每个事件都带 schema_version。客户在端点注册时选择一个 webhook schema 版本。我们永远按那个版本投递(除非他们迁移)。

当我们新增字段时,我们把它加到新 schema 版本中,对仍在旧版本的客户保持原样投递,让他们按自己的节奏迁移。

这本质上是 API 版本化的"邮寄地址"形式。它能跑。少这套机制,你不小心一次"加字段式"变更就会让客户 grumpy。

该监控什么

我们告警的五个 SLI:

  1. 投递成功率(24h 内应 > 99%)
  2. P99 投递延迟:从事件创建到首次投递尝试(应 < 30s)
  3. DLQ 累积速率(> 50 事件 / h 进 DLQ 时告警——多半是系统性问题)
  4. 每客户 5xx 率(告警发给客户,不是我们)
  5. 签名密钥年龄(密钥超过 6 个月未轮换告警客户——温和的卫生提醒)

如果重来一次会怎么做

事后回看,如果让我们从零开始:

  • 在上线前就把回放 UI 做出来,而不是事故之后。我们没这么做,结果三周里都在做"把丢失的 event_id 发我,我手动重投"这种支持工作。
  • 第一天就让密钥轮换是自助的。 手动轮换是个 footgun。
  • SDK 默认开启详细的接收方日志。 客户代码里的静默失败模式是最糟糕的。

TL;DR

把无聊的基础设施做扎实。带退避地重试。每个 body 都签名。用稳定的 event ID。有 DLQ。有回放按钮。把健康指标暴露给客户。不要静默换 IP。优雅支持轮换。给 schema 做版本化。

如果你正在集成我们,看看开发者文档的 webhook 章节,那是上面所有内容的生产版本。

作者
Kaadxpay 工程团队
平台工程

来自 Kaadxpay 工程团队的文章,覆盖 API 设计、webhook 可靠性、对账模式,以及运营一家跨境支付平台的真实工程现实。

相关文章

支付 API 的幂等性:工程实践 Playbook

支付 API 的幂等性:工程实践 Playbook

如何设计能扛住重试、网络分区和客户端各种创意行为的幂等键。Kaadxpay 在生产环境用的模式,写给真要去排查重复扣款工单的工程师。

2026年4月15日12 min read
深入解读 Labuan FSA PSO 牌照:为何它对 ASEAN 跨境支付如此关键

深入解读 Labuan FSA PSO 牌照:为何它对 ASEAN 跨境支付如此关键

纳闽金融服务管理局支付系统运营商(PSO)牌照的实操指南——授权范围、适用对象,以及与 BNM、MAS 及离岸方案的横向对比。

2026年4月28日10 min read
ASEAN 支付走廊 2026:市场现状盘点

ASEAN 支付走廊 2026:市场现状盘点

逐条走廊拆解 ASEAN 跨境支付如今真实流动的样子——从 MY-SG 的 QR 直连,到 IDR-PHP 这种被遗忘的支流。哪些走得通、哪些走不通、哪些值得接入。

2026年4月22日10 min read

订阅 Kaadxpay Insights

每月一封邮件,覆盖跨境支付走廊、监管动态与工程实践。