Webhook 让提供方在状态变化时通知已注册的接收端。它改变了谁主动发起 HTTP 请求,但单凭这个方向,不能保证必达、即时、有序或只处理一次。真正的运维契约来自提供方的投递规则,以及接收方如何确认和保存工作。理解这层区别,才能知道故障后应该重投、补查还是人工处理。
投递策略由提供方定义
不同服务并不相同。GitHub 不会自动重新投递失败的 webhook,需要合适的重投或对账流程。Stripe 在线上模式下会按指数退避重试最长三天,沙箱的规则则不同。两者都不意味着接收端可以无限期离线而不丢工作。应记录真实重试窗口,以及投递耗尽后由谁发现、如何补回,不能仅凭“用了 webhook”就宣称至少一次保证已经成立。
事件也未必包含完整的当前对象。载荷可能是历史快照,或只提供需要另行查询的标识。事件可能乱序,不同事件编号也可能涉及同一个业务变化。执行不可逆操作之前,应决定是直接应用事件、获取当前状态,还是核对版本后再处理。投递去重与业务操作幂等解决相关但不同的问题,保存一个事件编号不自动等于发货只能发生一次。
先认证、可靠保存,再确认
验签必须遵循提供方的准确格式;要求原始正文时,就使用收到的原始字节。把 JSON 解析后重新编码,可能改变空白或字段顺序,让真实签名失效。TLS 保护到端点的传输,普通服务端认证的 HTTPS 并不证明请求一定来自指定提供方。签名密钥不能写入日志,比较应采用适当方式,轮换也必须遵守提供方流程。
时间戳保护同样是特定协议能力。Stripe 会签入投递时间戳,其库可以检查容差;GitHub 文档定义的正文 HMAC 没有同样的已签名时间戳,因此不能要求所有服务都提供对应头。一次重试可能生成新的投递签名,却仍然代表同一个旧事件。新鲜度按协议检查,重复性按稳定事件身份判断,两者不能相互替代。
新工作返回成功之前,应先可靠地保存到队列或事务性收件表。如果先确认再入队,中间崩溃就会造成发送方停止重试、接收方却丢掉事件的空窗。请求路径要短,但不能为追求短而跳过认证或可靠交接。对于已经保存的合法重复事件,可以返回提供方认可的成功响应,并避免重复副作用;它不应该仅因重复就被当作恶意请求。
坏掉的样子
内存里的已见集合会在重启后消失,也不能协调多个 worker。先记录编号再执行副作用,如果没有可恢复的待处理状态,可能丢工作;先执行副作用再记编号,则可能在崩溃后重复。能使用数据库唯一约束和事务的地方应使用它们,下游操作也需要稳定操作标识支持安全重试。一笔数据库事务本身,不能原子覆盖无关外部系统的付款或发货请求。
密钥轮换只更新部分验签实例,会让真请求间歇性失败。只在计划中的过渡期允许新旧密钥重叠,监控拒绝原因,并按政策移除退役密钥。还要限制正文大小和队列增长。事件虽然都快速收到二百响应,却一直积压不处理,同样是业务故障;投递健康与业务完成必须分别观察,不能让好看的 HTTP 成功率掩盖停滞。
快速验证
保存为 webhook_verify_demo.py,运行 python3 webhook_verify_demo.py。程序使用明确自定义的 HMAC 格式、公开测试密钥和固定时钟,只依赖标准库与内存状态。它不会连接支付服务,也不是可以直接上线的接收器。这里故意把签名、时效与重复判定的分支隔离,方便检查每一步实际证明了什么。
import hashlib
import hmac
import re
SECRET = b"public-demo-key-not-a-real-credential"
OLD_SECRET = b"public-retired-demo-key"
TOLERANCE_SECONDS = 300
seen_ids = set()
def sign(event_id, timestamp, payload, key=SECRET):
message = f"{event_id}.{timestamp}.".encode("ascii") + payload
return hmac.new(key, message, hashlib.sha256).hexdigest()
def accept(event_id, timestamp, payload, signature, now):
# This is an invented wire format, not a Stripe or GitHub verifier.
if not re.fullmatch(r"evt_[0-9]+", event_id):
return "rejected"
if abs(now - timestamp) > TOLERANCE_SECONDS:
return "rejected"
if not re.fullmatch(r"[0-9a-f]{64}", signature):
return "rejected"
expected = sign(event_id, timestamp, payload)
if not hmac.compare_digest(expected, signature):
return "rejected"
if event_id in seen_ids:
return "duplicate"
seen_ids.add(event_id) # Process-local demonstration, NOT durable acceptance.
return "accepted"
now = 1_800_000_000
payload = b'{"type":"order.paid","order":"42"}'
fresh = sign("evt_1", now, payload)
assert accept("evt_1", now, payload, fresh, now) == "accepted"
assert accept("evt_1", now, payload, fresh, now) == "duplicate"
# Authenticate even a claimed duplicate before calling it harmless.
assert accept("evt_1", now, payload, "0" * 64, now) == "rejected"
# Change only the body: id and timestamp still match the signed message.
second = sign("evt_2", now, payload)
assert accept("evt_2", now, payload + b" ", second, now) == "rejected"
# Change only the id, leaving body and timestamp intact.
assert accept("evt_3", now, payload, second, now) == "rejected"
for timestamp in (now - 301, now + 301):
assert accept("evt_4", timestamp, payload,
sign("evt_4", timestamp, payload), now) == "rejected"
assert accept("evt_5", now, payload,
sign("evt_5", now, payload, OLD_SECRET), now) == "rejected"
assert accept("evt_6", now, payload, "not-a-digest", now) == "rejected"
assert seen_ids == {"evt_1"}
assert accept("evt_2", now, payload, second, now) == "accepted"
assert seen_ids == {"evt_1", "evt_2"}
print("PASS: authenticated duplicate, independent body/id tampering, stale/future, retired key, malformed digest")
完整断言在 Python 3.12.14 通过。真实重复投递返回 duplicate;冒用同一编号却签名错误的请求仍被拒绝。正文与编号篡改分别测试,避免一次同时改两个字段,掩盖某一项根本没有被保护的问题。超出容差的过去和未来时间、退役密钥以及格式错误的摘要也都被拒绝,全部拒绝路径不会添加已见编号。
只有通过认证的新事件进入集合;一份损坏投递被拒绝,不会阻止同一事件稍后的合法投递。这个结果只证明本地判定逻辑,不证明持久化接收、跨进程唯一性、HTTP 状态、队列恢复、提供方兼容性或端到端恰好一次副作用。上线前需要在选定服务和真实存储边界上验证这些性质,不能把这组内存断言当成完整支付或订单管线的验收。
延伸阅读
来源
- GitHub:失败的 webhook 投递,不会自动重投,已核验 2026-10-15。
- GitHub:验证 webhook 投递,正文 HMAC 与签名比较,已核验 2026-10-15。
- Stripe webhooks,重试窗口、原始正文、时间戳、重复与顺序,已核验 2026-10-15。