Foreshock
EN 订阅
文档

接入

Webhook 投递与签名校验

三个事件、至少一次、对原始字节签名——以及一条不依赖这一切正常工作的对账路径。

更新于
2026-08-24

三个事件

事件何时发送
exposure.match涉及你自有地址的一条匹配被创建,或其 stage / severity 变化。
exposure.revoked一条已投递的匹配从你的活跃匹配集中移除。绝不静默。
coverage.health_changed你账户作用域内某权威变为不可观测、重新可观测、超过滞后阈值,或落在核验窗口之外。

每次投递都带公共信封里的 event_id、event_type、occurred_at。

先验签,再解析

Foreshock-Signature 的格式是 t=<unix_seconds>,v1=<hex>,其中 v1 是 HMAC-SHA256(signing_secret, "<t>.<raw_request_body>") 的小写十六进制。

  1. 在请求体的原始字节上计算 HMAC,不要在重新序列化后的对象上算。任何把解析后对象交给你的框架,都已经把你需要的那批字节毁掉了——要在 body parser 运行之前把它们截下来。
  2. 用常数时间比较。
  3. 若 |now - t| > 300 秒则拒绝,用以限定重放窗口。

密钥轮换期间,一次投递可能携带多个 v1= 值。其中任意一个校验通过即接受该次投递。

raw   := read_raw_body(request)
sig   := parse(request.header["Foreshock-Signature"])   # t, [v1...]

if abs(now() - sig.t) > 300:
    return 400

expected := hmac_sha256(signing_secret, sig.t + "." + raw)
if none of sig.v1 matches expected in constant time:
    return 401

event := parse_json(raw)                                 # 到这一步才解析

未验签的响应体是不可信输入。在 HMAC 通过之前,不要记日志、不要看 event_type、不要根据里面的任何东西分支。

投递请求头

请求头用途
Foreshock-Signature按上面校验。
Foreshock-Signature-Timestamp签名时的 Unix 秒。与 t 同值。
Foreshock-Delivery-Id每次投递尝试唯一。按它记日志。
Foreshock-Event-Type与响应体里的 event_type 一致。
Foreshock-Webhook-Attempt从 1 起的尝试序号。每次重试递增。

按 event_id 去重,按 Foreshock-Delivery-Id 记日志。同一事件的重试带同一个 event_id 与不同的 delivery id;把两者混为一谈,要么产生重复处理,要么丢掉审计链。

投递语义

至少一次。 同一个 event_id 可能到达多次,且相对其他事件可能乱序。按 event_id 去重,按 occurred_at 排你自己的状态。

5 秒内确认。 只有在 5 秒内返回 2xx 才算投递成功。先返回 2xx,处理异步做——把处理放在同步路径里的接收端,会在你数据库变慢的那一刻开始丢投递。

重试最长 24 小时。 不是及时 2xx 的一切,都会以指数退避加抖动重试,从 occurred_at 起最长 24 小时。之后事件进入死信队列,你的账户上会产生告警,事件仍可从控制台重放。

漏收一条 webhook 不等于没事

这一段是最常被跳过的。

Webhook 流是 GET /v1/exposure 之上的延迟优化。变更流才是权威的对账路径。不论 webhook 是否健康,都按计划跑游标循环;接收端有过宕机、配置错误,或证书过期之后,也再跑一遍。

如果你对暴露面的唯一视角是 webhook 流,那么「接收端安静」和「世界安静」在你系统内部长得一模一样。

处理 exposure.revoked

一条撤销带 customer_address_ref、取值为 REVOKED / EXPIRED / STALE 的 stage,以及一个 evidence_ref。

移除被显式宣告,正是为了让你的本地名单不会与我们的静默分叉。这三个状态没有一个是「清白证明」——REVOKED 说事件被撤回,EXPIRED 说它没在窗口内执行,STALE 说底层数据已不可信。记录这次状态变化。不要把它读成许可。