Webhook 与回调

本页目录

Webhook 是一种 HTTP 回调集成方式:事件来源向接收者配置的地址发送通知,接收者依据内容触发处理。它减少主动查询的等待,但不是可靠消息协议的统一标准;重试、签名、顺序、历史查询与成功状态码都由具体平台契约决定。

以订单状态通知为例,来源创建事件 E42,通过 HTTP 发给接收端;接收端先把事件可靠保存,再由 worker 更新本地视图。即使来源随后重发 E42,或者 E43 比 E42 先到,处理也必须保持正确。

问题对应设计
谁发来的,正文有无改动原始字节验签与密钥管理
何时可以返回成功可靠接收边界
一件事通知两次怎么办稳定事件 ID 与业务幂等
顺序不一致怎么办资源版本、状态条件或查询当前状态
平台重试结束后漏了怎么办投递历史、补偿与对账
回调地址或跳转链接能否信任地址来源、权限与出口约束

接收端:把 HTTP 应答与业务完成分开

先可靠接收,再异步处理;每个确认都对应明确的责任 事件来源 按平台契约投递 接收端 验签 / 校验 / Inbox Worker 执行业务并记录结果 Inbox 持久化成功 → 返回 2xx 此时承诺的是已接收,不是业务全部完成 业务事务成功 → 标记完成 失败保留待重试或人工处理状态 返回 2xx 后再只写内存队列,会留下进程崩溃导致事件丢失的窗口。

接收路径可以限制请求大小和类型,验证签名与必要时效,解析并校验事件,再将原始内容和处理状态写入持久 Inbox。成功提交后快速返回平台认可的 2xx,worker 异步消费。这样既避免在 HTTP 超时内执行所有复杂工作,也保留崩溃后的恢复入口。

若先返回成功,再尝试向内存队列放事件,进程在中间退出就可能永久漏掉事件。若必须依赖数据库或队列持久化才能恢复,提交失败时不能假装接收成功。另一方面,已经可靠入队的重复事件可以返回成功,避免来源继续无意义重试。

GitHub 文档将超过 10 秒无响应视为一次投递失败,并明确不会自动重投失败的 webhook;可通过人工或自建程序重投。Stripe 有自己的自动重试与补发规则。不能写成“所有非 2xx 都按 5、25、125 秒退避,失败多次自动禁用”。GitHub 失败投递、Stripe Webhooks

验签:先处理原始字节,再理解 JSON

HMAC 以共享 secret 为 key,以平台规定的待签字节为 message。GitHub 的 X-Hub-Signature-256 使用 sha256= 加十六进制摘要;另一些平台会把时间戳、版本或其他字段一起签入。因此不能把所有平台统一实现为 HMAC(body)。GitHub 验签

同一个 JSON 对象可以有不同空格、键顺序和转义表示。若框架先解析再序列化,即使对象内容相同,字节也可能不同;验签时应保留协议要求的原始 request body。下面以 GitHub 公布的测试向量验证 HMAC 算法,再检查正文被改动时拒绝。

import hashlib, hmac, json
secret = b"It's a Secret to Everybody"
body = b'Hello, World!'
expected = '757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17'
assert hmac.new(secret, body, hashlib.sha256).hexdigest() == expected

def verify(raw, signature):
    if not signature.startswith('sha256='):
        return False
    value = signature[7:]
    if len(value) != 64 or any(ch not in '0123456789abcdef' for ch in value):
        return False
    calculated = hmac.new(secret, raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(calculated, value)

assert verify(body, 'sha256=' + expected)
assert not verify(body + b' ', 'sha256=' + expected)
a, b = b'{"a":1,"b":2}', b'{ "b": 2, "a": 1 }'
assert json.loads(a) == json.loads(b)
assert hmac.digest(secret, a, 'sha256') != hmac.digest(secret, b, 'sha256')
print('公开 HMAC 向量、篡改拒绝和 JSON 字节差异通过')

这段代码只演示该签名格式,不是所有 provider 的通用 SDK。真实接收器还要处理重复签名头、轮换期间的多个有效密钥,以及平台具体的签名版本;比较时避免使用会泄漏前缀匹配时间的简单循环。

签名有效为什么还需要防重放

攻击者若得到一份有效旧请求,HMAC 校验仍可能成功。平台若把投递时间签入,应根据可信时钟检查时间窗口;若协议没有签名时间戳,不能自创一个未被签名的头作为证明。消息事件发生时间与本次投递时间也不同,合法补发可能携带很早发生的事件。

重试窗口、人工重放窗口与去重记录保留期要配合。只保留五分钟的去重键,却允许管理员一个月后补发同一事件,就不能指望仍然阻止重复业务效果。

字节真实性、事件去重与业务版本处理,三项检查各司其职 验签与时效 确认原始字节来自可信密钥,拒绝不合约请求 Inbox 去重 同一个来源 / 租户 / 事件 ID 不重复入队 业务状态条件 旧版本不能覆盖新版本,外部副作用需要幂等契约 HMAC 本身不防重放;投递 ID 也不一定等于稳定的业务事件 ID。

三种 ID:业务事件、投递尝试与资源

事件 ID 表示一次事件事实,投递尝试 ID 用来追踪 HTTP 发送,资源 ID 表示被影响的订单或告警。同一事件可能有多次尝试;同一资源又会产生多个不同事件。具体字段是否重投时保持不变要看平台,不能仅凭名称含 delivery 就作判断。

GitHub 的 X-GitHub-Delivery 可用于识别同一次投递及其重投;Stripe 建议记录已处理 event ID,并指出某些场景可能出现不同 Event 对象对应相关业务变化。幂等键应依据平台事件语义和业务动作确定,而不是把所有来源拼成一个无作用域的 UUID 集合。GitHub 最佳实践、Stripe 重复事件

GitHub 的这类正文 HMAC 不覆盖任意 HTTP 头,因此 Delivery 头有助处理正常重复投递,但不能独自阻止持有有效旧正文的攻击者改头重放。敏感动作还要依据受签正文中的业务身份与操作条件去重;不要给未签名头赋予它没有的密码学保证。

去重与业务提交:必须覆盖崩溃窗口

在本地数据库内更新视图时,可以把 Inbox 状态变更与业务修改放在同一个事务中。以下局部模型用唯一键去重,并在注入崩溃时确认“事件已处理”不会先于业务效果独立提交。示例是内存 SQLite 的事务语义验证,不模拟磁盘故障或实际并发部署。

import sqlite3
con = sqlite3.connect(':memory:')
con.execute('CREATE TABLE inbox(source TEXT, event TEXT, PRIMARY KEY(source,event))')
con.execute('CREATE TABLE counter(value INTEGER NOT NULL)')
con.execute('INSERT INTO counter VALUES (0)')
con.commit()

def apply(source, event, crash=False):
    with con:
        cursor = con.execute('INSERT OR IGNORE INTO inbox VALUES (?,?)', (source, event))
        if cursor.rowcount == 0:
            return 'duplicate'
        con.execute('UPDATE counter SET value=value+1')
        if crash:
            raise RuntimeError('提交前崩溃模型')
    return 'applied'

try:
    apply('provider:tenant-a', 'E42', crash=True)
except RuntimeError:
    pass
assert con.execute('SELECT count(*) FROM inbox').fetchone()[0] == 0
assert con.execute('SELECT value FROM counter').fetchone()[0] == 0
assert apply('provider:tenant-a', 'E42') == 'applied'
assert apply('provider:tenant-a', 'E42') == 'duplicate'
assert con.execute('SELECT value FROM counter').fetchone()[0] == 1
print('去重与业务效果共同回滚、重试一次生效通过')

生产中的异步 Inbox 通常有 pending/processing/done 等状态及重试次数,不能仅因查到一行就跳过未完成任务。并发 worker 还需要领取任务、租约或行锁等机制,避免同时执行同一条消息。

调用外部扣款、发短信或控制设备时,对方副作用不在本地数据库事务内。需要使用对方支持的幂等键、操作查询与补偿;Outbox 可以避免本地状态更新后漏发命令,却不会让任意外部 HTTP 操作自动 exactly-once。

崩溃发生在两次写入之间,会重做还是漏做?

三种策略都遇到同一个崩溃点,但恢复后分别可能得到两次效果、零次效果和一次效果。重点不是把去重表放在“前面”还是“后面”,而是两项持久状态有没有共同的提交边界。

正在呈现知识画面
崩溃发生在两次写入之间,会重做还是漏做?

两项状态必须共同提交或共同回滚。业务先写可能重复,标记先写可能吞掉未执行操作;事务只覆盖同一本地数据库。

乱序:先决定事件代表快照还是变更

如果 E42 表示订单版本 8、E43 表示版本 9,E43 先到时,本地已记录版本 9,再收到版本 8 不应直接覆盖成旧状态。可采用带条件版本更新;若来源只提供“资源有变更”通知,则依据可信资源 ID 查询当前状态再更新。

事件时间戳不总是严格递增版本号,不同资源的顺序也未必有关。删除、取消、恢复和重建的语义应由业务状态机明确,不能把所有事件简单按“最后收到者胜出”处理。Stripe 明确不保证事件按生成顺序送达。Stripe 事件顺序

Webhook 之外仍应保留修复路径:定期对照来源状态或历史事件,找出缺口并重放。轮询不是必然低效,低频对账可以弥补 webhook 的重试耗尽、配置错误和临时停机。

发送端:事件创建、退避与地址边界

自己设计发送服务时,应让业务变更与待发送事件共同持久化,再由发送器发 HTTP。超时只表示没有看到确认,目标可能已完成;每次尝试记录事件 ID、attempt、响应与下次时间,使用有上限并带抖动的退避,避免故障恢复后同时重试。

可配置 callback URL 是服务器的出站访问能力,需要按照产品允许的目标范围验证 scheme、解析结果和重定向后的实际地址,并通过出口约束执行。只在注册时检查一次字符串,不足以处理 DNS 地址变化;回调响应也不应被当作任意新 URL 继续访问。

Grafana 到消息机器人:保留告警语义,再转换卡片

告警通知可能是一组 alerts,包含 firing 与 resolved、标签、注释和来源链接。将它映射成机器人卡片时,需要决定聚合方式、恢复通知、按钮目标与正文长度,而不是只取数组第一项就当作整个告警组。Grafana 通知模板

如果目标是把按钮指向 Loki 查询,应将可控的日志查询链接放在明确字段中,再由模板或适配服务编码为目标 JSON。当前 Grafana 文档支持 webhook Custom Payload 模板,具体可用性取决于版本与部署;模板序列化函数比手工拼接引号更可靠。机器人还可能要求独立的 token、时间戳签名或业务响应码,不能认为 Grafana 的 HMAC 配置自动兼容另一平台。自定义 JSON Payload

原有 DingDing 按钮行为属于具体集成实现,升级后应按实际版本检查,不能把“singleURL 永远硬编码”写成协议性质。若内置接入点无法满足所需 URL 或签名,可以由适配服务完成字段映射,并将机器人 API 的非成功业务结果纳入重试状态;HTTP 200 也可能装着应用级错误。

排查一条通知时,沿事件 ID 追踪来源创建、HTTP 投递、Inbox 提交、worker 状态和最终外部结果。只看接收端返回 200,无法判断用户最终看到的卡片是否正确。