Hono 生产实战 04:Webhook 签名校验、幂等和重试 Webhook 是生产系统里很常见,也很容易写错的接口。
支付回调、GitHub 事件、Stripe 事件、飞书回调、AI 平台任务回调,本质上都是:
很多人第一次写 Webhook,只做了一件事:
1 2 3 4 5 app.post ('/webhook' , async (c) => { const body = await c.req .json () await handleEvent (body) return c.json ({ ok : true }) })
这在 demo 里能跑。
生产里不够。
一个可靠的 Webhook 至少要考虑:
1 2 3 4 5 6 7 8 签名校验 原始 body 时间戳防重放 幂等处理 快速响应 异步任务 失败重试 事件日志
一、不要相信裸请求 Webhook 接口通常暴露在公网。
任何人都可以请求:
1 curl -X POST https://api.example.com/webhooks/payment
所以第一件事不是解析业务,而是确认:
常见方式是签名校验:
1 2 3 第三方用 secret + body 生成签名 你的服务用同一个 secret 重新计算 两者一致才接受
不同平台签名规则不同。一定要按对方官方文档实现。
二、签名校验要用原始 body 很多签名规则要求使用原始 body。
如果你先 await c.req.json(),再 JSON.stringify(),可能已经改变了空格、字段顺序或格式。
更稳妥:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 app.post ('/webhooks/demo' , async (c) => { const rawBody = await c.req .text () const signature = c.req .header ('X-Signature' ) if (!signature) { return c.json ({ error : { code : 'SIGNATURE_REQUIRED' } }, 401 ) } const valid = await verifySignature ({ rawBody, signature, secret : c.env .WEBHOOK_SECRET , }) if (!valid) { return c.json ({ error : { code : 'INVALID_SIGNATURE' } }, 401 ) } const event = JSON .parse (rawBody) await handleWebhookEvent (event) return c.json ({ ok : true }) })
重点是:
1 2 3 先拿 raw body 先验签 验签通过后再 JSON.parse
三、HMAC 校验示例 一个通用 HMAC SHA-256 示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 async function hmacSha256 (secret : string , payload : string ) { const key = await crypto.subtle .importKey ( 'raw' , new TextEncoder ().encode (secret), { name : 'HMAC' , hash : 'SHA-256' }, false , ['sign' ] ) const signature = await crypto.subtle .sign ( 'HMAC' , key, new TextEncoder ().encode (payload) ) return [...new Uint8Array (signature)] .map ((b ) => b.toString (16 ).padStart (2 , '0' )) .join ('' ) } async function verifySignature (input : { rawBody: string signature: string secret: string } ) { const expected = await hmacSha256 (input.secret , input.rawBody ) return timingSafeEqual (expected, input.signature ) }
timingSafeEqual 不要直接用普通 === 敷衍:
1 2 3 4 5 6 7 8 9 10 function timingSafeEqual (a : string , b : string ) { if (a.length !== b.length ) return false let result = 0 for (let i = 0 ; i < a.length ; i++) { result |= a.charCodeAt (i) ^ b.charCodeAt (i) } return result === 0 }
实际平台可能会加前缀、时间戳、版本号。不要照搬这个例子给所有平台。
四、时间戳防重放 签名正确,不代表请求一定新鲜。
攻击者可能复制一条旧请求重复发送。
所以很多平台会带时间戳:
你可以限制窗口:
1 2 3 4 5 6 7 8 9 10 11 const timestamp = Number (c.req .header ('X-Timestamp' ))const now = Math .floor (Date .now () / 1000 )if (!timestamp || Math .abs (now - timestamp) > 300 ) { return c.json ({ error : { code : 'WEBHOOK_EXPIRED' , message : 'Webhook 已过期' , }, }, 401 ) }
一般 5 分钟窗口就够。
具体看第三方平台文档。
五、幂等处理 Webhook 一定要幂等。
第三方平台通常会重试。
你不能假设同一个事件只来一次。
常见做法是记录 event id:
1 2 3 4 5 6 7 8 CREATE TABLE webhook_events ( id TEXT PRIMARY KEY , source TEXT NOT NULL , event_type TEXT NOT NULL , status TEXT NOT NULL , payload TEXT NOT NULL , created_at TEXT NOT NULL );
处理前先插入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 async function recordWebhookEvent (event : WebhookEvent ) { try { await db.insert (webhookEvents).values ({ id : event.id , source : 'payment' , eventType : event.type , status : 'received' , payload : JSON .stringify (event), }) return true } catch (err) { if (isUniqueConstraintError (err)) { return false } throw err } }
如果已经处理过:
1 2 3 4 5 const firstTime = await recordWebhookEvent (event)if (!firstTime) { return c.json ({ ok : true , duplicated : true }) }
返回成功很重要。
否则第三方会继续重试。
六、快速响应,异步处理 Webhook 不适合在请求里做很多慢操作。
比如:
更稳的方式是:
1 2 3 4 5 验签 记录事件 推入队列 立即返回 200 后台慢慢处理
Cloudflare Workers 可以配合 Queues:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 type Bindings = { WEBHOOK_QUEUE : Queue } app.post ('/webhooks/payment' , async (c) => { const event = await verifyAndParseWebhook (c) const firstTime = await recordWebhookEvent (event) if (firstTime) { await c.env .WEBHOOK_QUEUE .send (event) } return c.json ({ ok : true }) })
这样第三方不会因为你内部处理慢而超时。
七、失败重试 Webhook 处理失败很正常。
比如数据库短暂不可用、第三方 API 超时、邮件服务失败。
队列消费者要支持重试:
1 2 3 4 5 6 7 8 9 10 11 12 13 export default { async queue (batch, env ) { for (const message of batch.messages ) { try { await processWebhook (message.body , env) message.ack () } catch (err) { console .error ('webhook_process_failed' , err) message.retry () } } }, }
不要在 webhook HTTP 请求里无限重试。
那只会让第三方请求超时,然后它再重试,你的系统更乱。
八、事件状态 建议记录状态:
1 2 3 4 5 received processing processed failed ignored
这样你能回答:
1 2 3 4 5 这个事件有没有收到? 有没有重复? 处理成功了吗? 失败原因是什么? 要不要手动重放?
生产系统里,Webhook 没有事件日志,排查会很痛。
九、最后的建议 可靠 Webhook 的基本结构:
1 2 3 4 5 6 7 8 raw body 签名校验 时间戳校验 event id 幂等 事件入库 队列异步处理 失败重试 状态记录
Hono 只是帮你很轻地接住 HTTP 请求。
真正让 Webhook 可靠的,是这些生产细节。
不要把 Webhook 当普通 POST 表单。
它是外部系统和你业务系统之间的契约入口。
参考资料