Hono 生产实战 04:Webhook 签名校验、幂等和重试
wxk1991 Lv5

Hono 生产实战 04:Webhook 签名校验、幂等和重试

Webhook 是生产系统里很常见,也很容易写错的接口。

支付回调、GitHub 事件、Stripe 事件、飞书回调、AI 平台任务回调,本质上都是:

1
第三方系统主动请求你的 API

很多人第一次写 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
这个请求真的是第三方平台发来的

常见方式是签名校验:

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
X-Timestamp: 1782200000

你可以限制窗口:

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
更新订单
发邮件
生成报表
通知其他系统

更稳的方式是:

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 表单。

它是外部系统和你业务系统之间的契约入口。


参考资料