入门指南

Webhook 上线前:签名覆盖了什么、时间戳挡什么、重复投递怎么去重

验签只证明请求来自对方,不证明只来一次。把原始请求体、时间戳窗口、幂等键与对方的重试窗口拆成四件可核对的事,再给一份上线验收单。

验签证明来源,幂等键才证明只处理一次。

作者
AOPC 编辑部
发布
AOPC 编辑部
发布时间
更新
2026/10/28
核查
已复核
一个人在白板前把验签、时间戳、幂等键与重试窗口画成四层,并逐层标注证据
经营者责任与 AI 执行层的关系

先看答案

适合谁
  • 刚接完支付、邮件或代码托管 webhook,只写了「收到就处理」的人
  • 验签代码能跑,但说不清它到底挡了什么的人
  • 被重复扣款、重复发信或事件乱序坑过一次的人
你会获得
先把签名覆盖范围与去重键定下来,再谈重试。
发布时间
实质更新
发布
AOPC 编辑部

60 秒结论

一分钟看懂这篇文章

验签证明来源,幂等键才证明只处理一次。

人负责

  • 决定哪些事件必须去重
  • 安排失败事件的人工兜底

AI 负责

  • 核对签名覆盖范围
  • 生成幂等键与验收记录
  1. 验签
  2. 查时间
  3. 去重
  4. 对账

直接答案

「验签通过」这句话里藏了四件事,它们由四段不同的机制负责,任何一段缺失都不会报错:

层 要回答的问题 靠什么 失效时的表现
① 来源 这个请求是对方发的吗? 签名 + 预共享密钥 任何人都能伪造事件
② 新鲜度 这是刚发的,还是三天前被录下来重放的? 签名内的时间戳 + 容忍窗口 旧请求被反复执行
③ 唯一性 同一件事我是不是已经处理过了? 幂等键(事件 ID) 重复扣款、重复发信
④ 完整性 对方重试期间我漏掉的那几条在哪? 补拉接口 / 对账 状态永久不一致

最常见的错误是把 ① 当成全部:写好了验签,就认为「进来的都是真的、且只来一次」。验签只证明来源,不证明次数。 第一层与第三层之间没有因果关系——这是本文要拆开的核心。

本文以 Stripe、GitHub 与 Standard Webhooks 规范的公开文档为样本,因为它们恰好代表三种不同的签名拼法。它不是「webhook 服务选型」,也不假设你接的是哪一家;不同厂商的默认值不同,但需要问的问题一样。

一、签名覆盖了什么:三种拼法,三种边界

同样是 HMAC-SHA256,三家签的内容并不一样:

来源 签名覆盖的内容 时间戳是否在签名内
Stripe 请求体 + 时间戳(时间戳在 Stripe-Signature 头里) 是
Standard Webhooks msg_id.timestamp.payload 三者拼接 是
GitHub 请求体(payload) 否

Stripe 的原文把因果关系写得很直接:

Because this timestamp is part of the signed payload, it’s also verified by the signature, so an attacker can’t change the timestamp without invalidating the signature.

Standard Webhooks 更彻底,把消息 ID 也一起签进去:

The content to be signed is therefore: msg_id.timestamp.payload.

并且明确要求这两个字段不能被用户控制,至少不能包含 .——否则拼接结果会有歧义,攻击者可以用带点的 ID 构造出另一组字段组合。

GitHub 这一侧,签名页从始至终只描述「用 secret 和 payload 计算 HMAC」,没有任何时间戳参与签名。由此可以推出一条(编辑判断,非原文):同一份请求体被原样重发时,这个签名依然有效。 所以 GitHub 的 X-Hub-Signature-256 能证明「这份 payload 出自持有 secret 的一方」,但单独用它挡不住重放,也挡不住重复投递——这两件事必须交给下面的时间判断与幂等键。

一个容易忽略的细节:GitHub 同时还在发 X-Hub-Signature(SHA-1),文档写它「provided for compatibility with existing integrations」,并建议改用 X-Hub-Signature-256。新接入没有理由再用旧头。

二、原始请求体:最常见的验签失败不是密钥错

三家文档里唯一被反复强调的操作细节,是「不要动请求体」:

  • Stripe:Stripe requires the raw body of the request to perform signature verification. If you’re using a framework, make sure it doesn’t manipulate the raw body. Any manipulation to the raw body of the request causes the verification to fail.
  • Standard Webhooks:Cryptographic signatures are sensitive to even the smallest changes, and even a stray space can cause the signature to be invalid.,并点名了最常见的翻车方式——先把 body 解析成 JSON,再序列化回去,两次序列化的空格与键序不一定相同。
  • GitHub:除了「不要用普通 ==、要用常量时间比较」之外,还提醒 if you use a proxy or load balancer, make sure that the proxy or load balancer does not modify the payload or headers。

所以正确的处理顺序是固定的(编辑判断,但三家文档共同支持):

  1. 先拿到原始字节,不要经过任何框架的自动解析;
  2. 用原始字节 + 签名头 + secret 验签;
  3. 验签通过之后,才把字节解析成对象。

如果你在框架里拿不到原始字节(很多 body parser 默认会消费掉它),那不是配置问题,是顺序问题:把验签放在解析之前。

两个附带要求:GitHub 要求按 UTF-8 处理 payload(Webhook payloads can contain unicode characters);secret 用高熵随机串、存安全位置,Never hardcode a token into an application or push a token to any repository。GitHub 还给了一组固定的测试向量(secret It's a Secret to Everybody、payload Hello, World!,结果应为 sha256=757107ea…),可以拿来验证你自己的实现,而不是等到线上再试。

三、时间戳窗口:挡重放,不挡迟到

时间戳的用途只有一个:让「录制下来以后再发一遍」这个攻击失效。它不能、也不该用来判断事件的新旧或先后。

Stripe 的做法是给库一个默认容忍窗口:

Our libraries have a default tolerance of 5 minutes between the timestamp and the current time.

并且有一条明确的禁止项:Don’t use a tolerance value of 0. Using a tolerance value of 0 disables the recency check entirely. 也就是说,把窗口设成 0 不是「最严格」,而是「关掉新鲜度检查」。另外它建议用 NTP 校准服务器时钟——时钟漂移会让窗口变相收窄,表现为间歇性验签失败。

Standard Webhooks 只写「some allowable tolerance」,规范本身没有给具体秒数。凡是在文章里看到「webhook 时间戳窗口就是 5 分钟」的说法,那把 Stripe 库的默认值当成了行业标准。这两个数不该互相搬运。

更重要的是区分两种时间戳,规范里写得很清楚:

时间戳 含义 重试时是否变化
事件时间 这件事什么时候发生 不变
尝试时间(webhook-timestamp) 这一次投递什么时候发出 每次重试都更新

Stripe 同样如此,而且说得更直白:

If Stripe retries an event (for example, your endpoint previously replied with a non-2xx status code), then we generate a new signature and timestamp for the new delivery attempt.

于是本文最重要的一条分界出现了:时间戳窗口挡的是「原样重放的旧请求」,挡不住「同一事件的重复投递」——后者每次都带新鲜的签名与时间戳,在窗口内看起来完全合法。想靠收紧窗口来解决重复处理,方向就错了;那是幂等键的职责。

四、幂等键:谁提供、重投时稳不稳

判断一个字段能不能当去重键,只看一件事:同一件事的多次投递,这个值是否保持不变。

来源 可用的键 重投时是否稳定 证据强度
Standard Webhooks webhook-id 规范明写保持不变 强(规范原文)
Stripe 事件 ID 官方让用事件 ID 识别重复投递 强(文档原文)
GitHub X-GitHub-Delivery(GUID) 该页未说明 弱(不可假设)

Standard Webhooks 的定义是本文见过最清楚的:

The unique identifier is a unique identifier associated with a specific event triggered, and it remains the same no matter how many times a webhook that has failed is retried.

Stripe 则从反面给了操作建议:

Don’t use created to determine event order or whether you’ve already processed an event. Track event IDs to identify duplicate deliveries instead.

注意这里有两层意思:created 只精确到秒,不同事件可能共享同一个时间戳,所以它既不能排序也不能当去重键;能用的是事件 ID。

GitHub 的 X-GitHub-Delivery 需要打一个问号。文档的原话是 A globally unique identifier (GUID) to identify the event——只说了它唯一标识事件,没有说明重投时是否复用同一个 GUID。所以在没有实测之前,不要把它当成跨重试的去重键;真要用,先手动重投一次,比对两次的 GUID 是否相同(编辑判断)。这一条我宁可写成「未核实」,也不替文档补一句它没写的话。

落地时通常有三层,强度依次上升(编辑判断):

  1. 数据库唯一约束:把事件 ID 作为业务表主键或唯一索引,重复插入直接失败——最省事,也最难绕过。
  2. 处理前写「已见」记录:写不进去就跳过。要求写入与业务在同一个事务里,否则中间崩溃会留下「标记了但没做」。
  3. 把幂等键交给下游:如果下游 API 支持幂等键,让它替你拒绝重复,自己不用维护状态。

这里有一个去重记录的保留期问题,文档不会提醒你。Standard Webhooks 的示例是「把 ID 存进 redis 5 分钟」,但那只是示例:如果对方的自动重试能跨三天(见下一节),5 分钟的去重表在第 6 分钟就已经忘了这件事。保留期至少要不短于对方的重试窗口,否则去重在最需要它的时候失效。

五、对方会重试多久:三天、三次与 75 小时

这一节决定你的处理窗口该开多大。

Stripe 把 live 与 sandbox 分得很开,两句话不能混写:

Stripe attempts to deliver events to your destination for up to three days with an exponential back off in live mode.

We retry event deliveries created in a sandbox three times over the course of a few hours.

手动补发则有两个不同的时限:控制台的 Resend 只在事件创建后 15 天内可用,CLI 的 stripe events resend 是 30 天。而且有一个反直觉的行为:

Manually resending an event that failed to reach a webhook endpoint doesn’t cancel Stripe’s automatic retry behavior, even if it results in a 2xx status code.

也就是说,「我手动补了一次、成功了」并不代表它不会再自动来一次。这正好又落回第三节的结论:重复是常态,去重是必需品。

还有一个静默开关:如果在重试期间把目标禁用或删除,未来重试会被阻止;但在下一次重试发生前重新启用,之前的重试仍会继续。把端点关掉再打开,不是「暂停」。

Standard Webhooks 给的是建议节奏而非强制值,它示例的累计时间到 75 小时 35 分:

间隔 距开始
立即 00:00:00
5 秒 00:00:05
5 分钟 00:05:05
30 分钟 00:35:05
2 小时 02:35:05
5 小时 07:35:05
10 小时 17:35:05
14 小时 31:35:05
20 小时 51:35:05
24 小时 75:35:05

并建议加重试抖动(add some level of random jitter),避免失败本身就是由重试流量造成的;长期持续失败时,要用其它渠道(例如邮件)通知消费者,并考虑停用该端点。

对你的系统来说,这张表意味着:一条三小时前的事件,可能在今天下午突然被合法地投递进来。 你的处理逻辑要能接受「旧事件」,去重记录要活到那时候,状态机也不能假设「新事件一定后到」——Stripe 明写 doesn’t guarantee the delivery of events in the order that they’re generated,并建议端点不要依赖特定顺序。

六、什么算送达:返回码与超时

「对方觉得成功」和「你觉得成功」是两个判断,前者只看 HTTP 状态码。

Stripe 的要求是先回执、后处理:

Quickly returns a successful status code (2xx) before any complex logic that might cause a timeout. For example, you must return a 200 response before updating a customer’s invoice as paid in your accounting system.

Standard Webhooks 把状态码分类列全了:只有 2xx 算成功,其余一律失败;3xx 也算失败(跟随重定向会给收发双方都增加无谓负载,应该去改 webhook URL);410 Gone 表示对方不再想收,应停用端点;429、502、504 应限流;响应里如果带 retry-after,排下一次重试时要参考它。它给的请求超时建议是 15 到 30 秒之间。

由此带出一个必须自己补上的缺口(编辑判断):返回 2xx 之后处理失败,对方不会再投。 先把事件落库、立刻回 200、再异步处理,是标准做法;但这样一来,「落库成功、处理失败」的消息就只存在于你自己的系统里,必须有本地重试与死信队列兜底——这部分机制与队列产品无关,可参考后台任务的重试与死信设计。

七、上线前的一份验收单

# 检查项 通过标准
1 原始字节 验签在 body 解析之前;框架不会提前消费请求体
2 签名头 用 X-Hub-Signature-256 / Stripe-Signature / webhook-signature 中对应的那一个,不用旧头
3 比较方式 常量时间比较,不用 ==;十六进制大小写与 sha256= 前缀处理正确
4 时钟 服务器用 NTP 校准;容忍窗口非 0 且有明确数值
5 重放 知道时间戳在不在签名内;不在的话,重放防护另有机制
6 幂等键 明确用哪个字段;已验证重投时该字段不变
7 去重存储 唯一约束或事务内标记;保留期 ≥ 对方重试窗口
8 顺序 处理逻辑不依赖事件到达顺序;乱序到达不会写坏状态
9 回执 复杂逻辑之前返回 2xx;处理失败有本地重试与死信
10 端点状态 知道禁用端点会停止重试、重新启用会恢复
11 观测 能看到验签失败数、去重命中数、最老待处理事件年龄

第 11 项最容易被省,但它决定了前十项是否真的在工作:验签失败数突然上升,通常是时钟漂移或密钥轮换没跟上;去重命中数为 0,可能是幂等键取错了字段。

八、四种常见误读

误读一:「验签通过,就说明这个事件可信、且只来一次。」 验签只回答来源。重复投递带的是新鲜签名,一样能通过;顺序也不由它保证。

误读二:「时间戳窗口 5 分钟是行业标准。」 那是 Stripe 官方库的默认值。Standard Webhooks 规范只写「some allowable tolerance」,没有给秒数。可调,但别设 0——那等于关掉检查。

误读三:「时间戳越新,事件越新。」 webhook-timestamp 是尝试发出的时间,每次重试都会刷新;事件本身的时间另有一个字段。用尝试时间去排序,会把一条三天前的旧事件当成刚发生的。

误读四:「live 重试三天,sandbox 重试三次,是一回事。」 一个是时间窗,一个是次数,写混了会让测试结论与线上行为对不上。手动补发也不会取消自动重试。

九、本周就能做的五件事

  1. 打开验签代码,确认它拿到的是原始字节,而不是被框架解析后又序列化的对象。
  2. 写下你的容忍窗口数值,并检查它不是 0;顺手确认服务器时钟有 NTP。
  3. 确认幂等键来自哪个字段,然后手动重投一次,看这个字段是否保持不变。
  4. 把去重记录的保留期与对方的重试窗口对齐:对方能重试三天,你的记录就不能只留五分钟。
  5. 手动投一条坏消息,看它是否走完「失败 → 重试 → 你去哪里看」这条路。

如果只能记住一件事:签名解决「谁发的」,幂等键解决「做几次」,时间戳解决「是不是旧的」——三个问题,三种机制,缺一个都不会报错。


边界说明:本文所有机制性陈述均来自 Stripe、GitHub 与 Standard Webhooks 的官方文档(2026-10-28 访问,四个页面均 HTTP 200)。不写「验签通过=事件可信且唯一」、不写「5 分钟是行业标准」、不把 live 的三天与 sandbox 的三次混写、不写「用了标准就不会重复」。GitHub 签名不含时间戳、因此挡不住原样重放,是由其文档只描述 payload HMAC 推出的结论,已标注为编辑判断;X-GitHub-Delivery 在重投时是否复用,该页未说明,本文按「未核实」处理,未替文档补充。去重记录的保留期、先落库后异步、三层幂等实现与验收单顺序均属编辑判断,已逐处标注。文中不含任何送达率、重复率或丢失率数字。

来源

以下页面均于 2026-10-28 逐条访问并验证为 HTTP 200;字节数为当日抓取的实际大小。

  1. Receive Stripe events in your webhook endpoint · Stripe Docs核查于 2026/10/28(200,1 142 604 B):验签必须用原始请求体、框架不得改写;时间戳在签名覆盖范围内;官方库默认容忍 5 分钟、0 会关掉新鲜度检查;每次重试生成新的签名与时间戳;live 最多投递三天并指数退避、sandbox 重试三次、历时数小时;手动补发控制台 15 天、CLI 30 天且不取消自动重试;禁用端点会阻止未来重试、重投前重新启用仍会重试;轮换 secret 可延迟过期至多 24 小时、期间多个 secret 同时有效且每个各生成一个签名;事件不保证按生成顺序投递;created 只到秒、不同事件可能同秒、应改用事件 ID 识别重复投递;需在复杂逻辑前快速返回 2xx;最多注册 16 个事件目标。
  2. Validating webhook deliveries · GitHub Docs核查于 2026/10/28(200,158 280 B):用 secret 对 payload 计算 HMAC hex digest;签名在 X-Hub-Signature-256 头、固定以 sha256= 开头;必须在处理前验证;payload 按 UTF-8 处理;不要用普通 ==,改用 secure_compare/crypto.timingSafeEqual 常量时间比较;secret 要低可预测、高熵、存安全位置、不硬编码不提交;代理或负载均衡不得改写 payload 与头;提供 secret/payload/签名测试向量。
  3. Standard Webhooks Specification v1.0.0核查于 2026/10/28(200,28 403 B):三个头 webhook-id/webhook-timestamp/webhook-signature;签名内容为 msg_id.timestamp.payload;对称 HMAC-SHA256 序列化为 v1,<base64>、非对称为 v1a;签名头是空格分隔列表以支持零停机轮换、逐个验到匹配为止;验签用常量时间比较;webhook-timestamp 需在「some allowable tolerance」内(未给具体秒数);webhook-id 在重试间保持不变、可作幂等键(示例存 redis 5 分钟);尝试时间戳每次重试更新而事件时间不变;只有 2xx 算成功、3xx 亦算失败、410 应停用、429/502/504 应限流并参考 retry-after;请求超时建议 15–30 秒;含累计 75 小时 35 分的重试节奏示例与抖动建议;payload 建议小于 20 KB;消息 ID 与时间戳不得用户可控、不得含点号。
  4. Webhook events and payloads · GitHub Docs核查于 2026/10/28(200,923 888 B):投递头清单——X-GitHub-Hook-ID(webhook 唯一标识)、X-GitHub-Event(触发事件名)、X-GitHub-Delivery(A globally unique identifier (GUID) to identify the event)、X-Hub-Signature(SHA-1,仅为兼容旧集成保留)、X-Hub-Signature-256(SHA-256 HMAC hex digest)、User-Agent 固定前缀与安装目标头。

唯一主行动

把下一步变成可执行动作

后台任务失败之后的四种去向