接入
Webhook 投递与签名校验
三个事件、至少一次、对原始字节签名——以及一条不依赖这一切正常工作的对账路径。
三个事件
| 事件 | 何时发送 |
|---|---|
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>") 的小写十六进制。
- 在请求体的原始字节上计算 HMAC,不要在重新序列化后的对象上算。任何把解析后对象交给你的框架,都已经把你需要的那批字节毁掉了——要在 body parser 运行之前把它们截下来。
- 用常数时间比较。
- 若
|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 说底层数据已不可信。记录这次状态变化。不要把它读成许可。