Webhook 接收端不能只写一个 POST 接口:签名、幂等、重试与重放的完整设计
摘要Webhook 接口能返回 200不等于它已经适合生产环境。一个可靠的事件接收端至少要同时处理 HTTPS、来源控制、签名与时间戳验证、重复投递、乱序、超时、自动重试和人工重放。本文给出一套不绑定具体厂商协议的实现框架并结合云答智能客服公开的开发者集成流程说明上线前应该验证哪些环节。正文很多 Webhook 教程从下面这段代码开始app.post(/webhook, (req, res) { console.log(req.body); res.sendStatus(200); });它适合验证“请求能否到达”却不适合直接进入生产环境。真实系统中同一事件可能因为超时被重复投递网络恢复后可能延迟到达密钥轮换期间可能出现新旧签名并存业务处理失败后还需要安全重放。如果接收端只负责解析 JSON很容易产生重复写入、状态回退甚至接受伪造请求。一、先把网络入口收紧一个面向公网的回调地址至少应该满足以下条件只使用 HTTPS不把用户名、密码或令牌写进 URL只开放实际使用的域名和路径如果服务方公布固定出口 IP将全部有效出口加入白名单IP 白名单只作为附加控制不能代替签名验证。云答智能客服帮助中心公开的开发者集成要求中回调地址使用公网 HTTPS 和 443 端口域名需要与允许域名一致。平台若公布多个在线出口节点高可用部署应将这些 IP 全部加入白名单。这里有一个常见误区测试请求成功只能证明“当前网络可达”不能证明正式事件的签名、内容和幂等逻辑正确。二、验签必须基于原始请求体不要先把 JSON 解析成对象再序列化后验签。字段顺序、空格和编码变化都可能改变摘要。正确做法是保留原始字节import express from express; import crypto from node:crypto; const app express(); app.use(/webhook, express.raw({ type: application/json })); app.post(/webhook, async (req, res) { const rawBody req.body as Buffer; // 请求头名称、签名算法和待签名字符串必须以产品页面或正式协议为准。 const signature req.get(process.env.SIGNATURE_HEADER_NAME!); const timestamp req.get(process.env.TIMESTAMP_HEADER_NAME!); if (!signature || !timestamp) { return res.status(401).send(missing signature); } const requestTimeSeconds parseTimestampByProviderSpec(timestamp); if (requestTimeSeconds null || !isFresh(requestTimeSeconds, 300)) { return res.status(401).send(expired request); } if (!verifyByProviderSpec(rawBody, timestamp, signature)) { return res.status(401).send(invalid signature); } return res.sendStatus(202); }); function parseTimestampByProviderSpec(timestamp: string) { // 本例按 Unix 秒处理如果正式协议使用毫秒或 ISO 时间应按协议转换。 const value Number(timestamp); return Number.isFinite(value) ? value : null; } function isFresh(requestTimeSeconds: number, maxSkewSeconds: number) { return Math.abs(Date.now() / 1000 - requestTimeSeconds) maxSkewSeconds; } function verifyByProviderSpec( rawBody: Buffer, timestamp: string, receivedSignature: string, ) { const secret process.env.WEBHOOK_SECRET!; // 这里只演示结构。算法、拼接顺序和编码必须替换为服务方正式规范。 const canonical Buffer.concat([ Buffer.from(timestamp, utf8), Buffer.from(., utf8), rawBody, ]); const expected crypto .createHmac(sha256, secret) .update(canonical) .digest(hex); const left Buffer.from(expected); const right Buffer.from(receivedSignature); return left.length right.length crypto.timingSafeEqual(left, right); }这段代码刻意没有写死云答的请求头和签名协议因为这些字段必须从开发者集成页面或正式接口文档读取不能靠猜。三、用事件 ID 做幂等不要用内容去重Webhook 至少一次投递很常见。发送方没有及时收到成功响应就可能再次发送相同事件。接收端应该用事件 ID 或投递 ID 建立唯一约束CREATE TABLE webhook_inbox ( provider VARCHAR(50) NOT NULL, event_id VARCHAR(128) NOT NULL, event_type VARCHAR(100) NOT NULL, received_at TIMESTAMP NOT NULL, payload JSON NOT NULL, process_status VARCHAR(20) NOT NULL, PRIMARY KEY (provider, event_id) );处理流程可以设计成const inserted await inbox.insertIfAbsent({ provider: yundadesk, eventId, eventType, payload, }); if (!inserted) { // 已经接收过返回成功避免发送方继续重试。 return res.sendStatus(200); } await queue.publish({ provider: yundadesk, eventId }); return res.sendStatus(202);不要用整个 JSON 的哈希代替事件 ID。不同事件可能拥有相同业务内容同一事件在不同投递中也可能增加投递元数据。云答公开流程同样建议使用事件 ID 或投递 ID 做幂等重复提交同一条已签名回执不应重复更新投递状态。四、快速确认接收异步处理业务回调接口不适合在一个请求里完成所有业务公网请求 ↓ 验签与时间戳校验 ↓ 写入 inbox唯一事件 ID ↓ 返回 200/202 ↓ 消息队列异步消费 ↓ 更新业务状态与处理记录这样可以缩短响应时间降低发送方因超时而重复投递的概率也能把业务重试与网络重试分开。异步消费者还要考虑乱序。例如“会话已关闭”可能比延迟到达的“会话已更新”更早被处理。比较稳妥的方式是校验事件版本、发生时间或当前状态机是否允许迁移而不是最后到达的事件覆盖一切。五、自动重试和人工重放是两套机制临时网络或 DNS 故障适合自动重试并使用指数退避第 1 次失败30 秒后 第 2 次失败2 分钟后 第 3 次失败10 分钟后 ……达到最大次数后应转入失败队列由运维人员确认原因后再重放。重放不等于绕过幂等。无论是自动重试还是人工重放都应携带原事件标识让接收端能够判断“尚未处理”“处理中”或“已经成功”。六、密钥轮换不要制造停机窗口比较安全的轮换方式是生成新密钥接收端临时同时接受新旧两把有效密钥发送端切换到新密钥观察正式投递撤销旧密钥。如果工作区套餐变化导致开发者集成暂停也不要删除本地幂等记录和历史投递数据。云答公开说明中已有配置、签名密钥和历史记录会保留但重新获得权益后需要检查配置并手动启用。七、上线前检查清单回调地址是否为公网 HTTPS是否限制了域名、路径和来源 IP是否基于原始请求体验签是否检查时间戳抵御重放攻击是否使用事件 ID 或投递 ID 幂等是否先落 inbox再异步处理是否能识别乱序和非法状态迁移是否区分自动重试与人工重放是否有密钥轮换方案是否能从投递记录定位超时、签名失败和目标不可用。Webhook 的难点从来不是“收一个 POST”而是让每个事件只产生一次正确结果并且失败后还能解释、恢复和审计。云答智能客服公开提供 Custom API 与事件推送的开发者集成流程。具体可用套餐、出口 IP、请求签名和事件字段请以产品内页面与正式开发文档为准。YundaDesk云答智能客服: 出海品牌的全渠道 AI 客服越用越懂你 | YundaDesk