入门教程

Webhook 验签失败或重复处理怎么办?原始请求体、时间窗口与幂等排查

从原始请求体、HMAC 拼接、时间戳容差和重放保护四层定位 API 回调问题,配本机模拟代码,区分签名通过与业务只执行一次,避免把 2xx 当成处理完成。

发布:2026年11月8日
更新:2026/11/8

Webhook 已经返回 2xx,却在接收端报“验签失败”;或者重试后同一笔事件被业务处理两次,这两种现象经常被混在一起。验签只回答“这组原始字节是否由持有密钥的一方生成”,不能代替重放防护、幂等处理或业务状态校验。

本文用一个明确标注的 HMAC-SHA256 示例说明排查路径:签名覆盖 Unix 时间戳、点号和原始请求体,签名放在十六进制请求头中。这个格式只是教程约定,不是任何服务商的通用协议;接入真实平台时必须以其官方文档为准。示例只使用本机模拟请求,不调用真实付费 API。

1. 先把“请求到了”与“请求可信”分开

收到 HTTP 请求、通过 TLS、返回 2xx、验签成功和业务落库是五个不同阶段。网关可能已经把请求转发到应用,但应用仍应在解析 JSON 前核对签名。验签失败时不要把错误正文、密钥或完整请求体写入日志;记录请求编号、状态、时间窗口结果和脱敏摘要即可。

阶段要回答的问题失败时先查什么
传输HTTP 消息是否完整到达状态、超时、代理和长度
取原文参与签名的字节是否保持不变压缩、字符集、换行和中间件
验签算法、密钥、拼接顺序是否一致官方协议、版本和密钥范围
防重放时间戳和事件是否已处理时钟、nonce、事件 ID 和缓存
业务处理事件是否只产生一次效果数据库唯一约束和状态机

不要因为发送方看到 200 就认为业务一定完成。接收端可以先验签并持久化事件,再异步执行业务;这时 2xx 只说明接收阶段的结果,不能证明后续任务成功。

2. 验签必须使用原始请求体

最常见的错误是先把 JSON 解析成对象,再重新序列化后验签。空格、键顺序、转义、Unicode 规范化和末尾换行任何一个变化,都会让字节串不同。压缩或网关重写也可能发生在验签前后;必须确认签名覆盖的是哪一层表示。

正确顺序通常是:

  1. 在框架第一次解析正文之前读取原始 bytes,并按平台限制设置请求体上限。
  2. 保存一份受访问控制保护的原始字节或其加密/脱敏摘要,供排查使用。
  3. 按官方协议拼接时间戳、版本前缀或 nonce;不要自行猜分隔符。
  4. 使用恒定时间比较函数比较摘要,避免普通字符串比较的时序差异。
  5. 验签成功后才解析 JSON,并检查事件类型、事件 ID 和版本字段。

下面的函数使用 X-Webhook-Timestamp、X-Webhook-Signature 两个教程头。签名原文是 timestamp + "." + raw_body,摘要是 HMAC-SHA256 的原始字节再转十六进制。真实平台可能使用多个签名、前缀或不同编码;请替换为它的正式规范。

import hashlib
import hmac
import time
from collections.abc import Mapping

class WebhookVerificationError(ValueError):
    pass

def verify_webhook(
    raw_body: bytes,
    headers: Mapping[str, str],
    secret: bytes,
    *,
    now: int | None = None,
    tolerance: int = 300,
) -> int:
    if not isinstance(raw_body, bytes):
        raise TypeError("raw_body must be bytes")
    if not isinstance(secret, bytes) or not secret:
        raise ValueError("secret must be non-empty bytes")
    if type(tolerance) is not int or tolerance < 0:
        raise ValueError("tolerance must be a non-negative integer")
    timestamp_text = headers.get("X-Webhook-Timestamp", "").strip()
    signature_text = headers.get("X-Webhook-Signature", "").strip()
    try:
        timestamp = int(timestamp_text)
    except ValueError:
        raise WebhookVerificationError("INVALID_TIMESTAMP") from None
    if timestamp < 0:
        raise WebhookVerificationError("INVALID_TIMESTAMP")
    current = int(time.time()) if now is None else now
    if type(current) is not int:
        raise TypeError("now must be an integer timestamp")
    if abs(current - timestamp) > tolerance:
        raise WebhookVerificationError("STALE_TIMESTAMP")
    try:
        provided = bytes.fromhex(signature_text)
    except ValueError:
        raise WebhookVerificationError("INVALID_SIGNATURE_FORMAT") from None
    signed = str(timestamp).encode("ascii") + b"." + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).digest()
    if not hmac.compare_digest(provided, expected):
        raise WebhookVerificationError("BAD_SIGNATURE")
    return timestamp

这个函数只验证身份材料,不解析业务 JSON,也不负责保存事件。tolerance=300 表示时间戳与接收机时钟相差超过 300 秒就拒绝;生产值应按平台重试窗口、网络延迟和时钟同步情况调整,不能盲目放大。

3. 按错误阶段定位,而不是盲目换密钥

现象优先检查不要直接得出的结论
所有事件都 BAD_SIGNATURE密钥环境、原始体读取位置、拼接格式和十六进制/文本转换发送方一定改了协议
只有带中文或换行的事件失败是否先解码、规范化或重排 JSONUTF-8 本身不支持中文
偶发 STALE_TIMESTAMPNTP、容器时区和代理排队时间事件一定是攻击
重试时验签成功但效果重复事件 ID 是否落库、唯一键和事务边界验签函数失效
2xx 后仍不断重试发送方要求的响应时间、状态码范围和网络可达性业务处理越快越好
反向代理后全部失败代理是否改了正文、压缩或大小写敏感的头只需把签名校验关闭

把一次失败拆成四份证据:脱敏后的原始字节长度和哈希、签名头解析结果、使用的密钥版本标识、接收时间与发送时间差。不要把 HMAC 密钥、Authorization、Cookie 或完整载荷放进普通日志。若还涉及客户端代理,参见 HTTPX 代理环境排查;若需要向服务商提交材料,使用 API 调用失败的最小复现模板。

4. 时间戳只能缩短重放窗口

签名正确的请求也可能是旧请求的复制品。时间窗口只能阻止过期消息,不能阻止攻击者在窗口内重复发送同一事件。因此需要同时使用:

  • 事件 ID 或 nonce:在验签后、执行业务前检查是否已见过。
  • 短期去重存储:用带过期时间的键记录 provider + event_id;过期时间至少覆盖发送方最大重试窗口。
  • 原子写入:用数据库唯一约束或缓存的“只写一次”操作,避免两个并发请求同时通过检查。
  • 时钟同步:容器和主机使用可靠的时间同步;日志同时记录 UTC 和本地展示时间。
  • 有界重试:业务失败时返回符合协议的状态,让发送方重试;不要因为重复事件再次执行不可逆操作。

事件的“已收到”和“已完成”是两个状态。可以先插入 received 记录并返回 2xx,再由队列消费者把它推进到 processed、failed 或 dead_letter。消费者必须在事务中检查状态,重复投递时返回已有结果。涉及付款、充值或额度变更时,还要按请求编号与账单记录对账,参考 API 账单核对教程。

5. 在本机构造四类测试

不接真实服务也能验证接收端的边界。为同一份原始 bytes 计算正确签名,然后分别测试:

  1. 原文完全一致:应通过。
  2. 只改变 JSON 空格或末尾换行:应失败。
  3. 时间戳超出容差:应返回 STALE_TIMESTAMP。
  4. 相同事件 ID 并发两次:验签都可通过,但只有一次业务副作用。

测试时使用固定的测试密钥和人工构造的时间,不要把生产密钥写进示例仓库。除了单元测试,再增加一次真实框架的“读取原文”集成测试,确认 body parser 没有先消费或重写内容。若启用了 gzip,分别记录签名覆盖压缩前还是压缩后的表示,不能凭 Content-Encoding 猜测;相关边界可参考 gzip 响应排查。

6. 生产上线检查清单

  • 从官方文档逐项抄录签名头、版本、编码、拼接顺序和密钥轮换规则。
  • 明确原始 body 的读取位置与最大大小,拒绝超限请求。
  • 验签使用恒定时间比较,失败响应不泄露哪一部分不匹配。
  • 时间窗口、时钟同步和事件 ID 去重都有监控。
  • 数据库对事件 ID 或业务幂等键设置唯一约束,并定义重复投递的返回结果。
  • 记录脱敏诊断字段:事件 ID、请求 ID、版本、时间差、body 长度和哈希。
  • 轮换密钥时支持旧、新版本的短暂重叠,验证后再撤销旧密钥。
  • 用本机样例覆盖正文改写、重试、并发和下游失败,不发送真实付费请求。

签名验证解决的是“消息是否符合协议”,幂等解决的是“重复消息只产生一次效果”,业务状态机解决的是“效果是否完成”。三层都通过,才可以把回调视为可用。

7. 一手资料与适用范围

资料于 2026-11-08 核对。本文的 HMAC 拼接格式是教学协议,不能冒充任何平台的签名规范。

本文没有测试任何特定服务商的 webhook、TLS、HTTP/2、签名轮换后台或计费系统;本机测试只验证已写明的 HMAC 协议和接收端边界。

标签:WebhookHMACAPI 排错幂等Python
Webhook 验签失败或重复处理怎么办?原始请求体、时间窗口与幂等排查 - API选