Webhook 签名怎么验证:Node、PHP、Go、Python 完整示例
接收 Webhook 的地址是公网上的,谁都能往上面发请求。这篇讲清 Webhook 签名验证的原理、四种语言的完整代码,以及签名老是对不上时最常见的几个原因。

Webhook 签名验证只有三步:拿你的签名密钥,对「时间戳 + 点 + 原始请求体」算一次 HMAC-SHA256,再把结果和请求头里的签名做常量时间比较;顺手拒掉时间戳偏差超过 5 分钟的请求。三步缺一个,你的接收地址就还是个谁都能写入的公开入口。
下面先说清为什么要验、签名是怎么算出来的,然后给 Node.js、Python、PHP、Go 四份能直接复制进项目的代码,最后是「代码照抄了但签名就是对不上」时该查的几个地方。

不验签会发生什么
Webhook 的接收地址必须是公网可访问的 HTTPS 地址,这是它能工作的前提,也意味着任何人都能对它发 POST 请求。不验签的话,有两类问题:
伪造。 别人照着你用的服务商的文档,拼一个一模一样的 JSON 发给你。如果这个事件会在你的系统里创建订单、给用户加额度、往 CRM 里写一条「已成交」,那就等于把写入权限公开了。接收地址不难猜:它常常出现在前端代码、公开仓库、浏览器开发者工具的网络面板里。
重放。 哪怕请求是真的,抓到一次之后反复重发同一条,你那边就会反复执行一次动作。所以签名里要带时间戳,并且你要检查它。
有两个常见的做法顶不上验签:
- 只靠 HTTPS:HTTPS 保证传输过程没被偷看和改动,但不证明发送方是谁。
- 只在网址里带一个 token(例如
/hooks?key=abc123):完整网址会被写进服务器访问日志、反向代理日志、各种监控面板,泄露面比你想的大,而且一旦泄露就得改地址。
签名解决的正是「这个请求是不是由知道密钥的人发出的,内容有没有被改过」。
签名长什么样
以 SealReply 的 Webhook 为例,每次投递都带这个请求头:
X-Signature: t=1793525957,v1=413882683ef3f416e353fe8c13a8358135e80f7630f92ef02b1e5edd37e655fd
t是投递时的 Unix 秒数。v1是用你的签名密钥(whsec_…)对字符串"<t>.<原始请求体>"做 HMAC-SHA256,再转成小写十六进制。
把时间戳也签进去,是为了让它不能被单独改掉:改了 t,v1 就对不上了。这样「拒收 5 分钟以外的请求」才真的能防重放。

HMAC 不是加密,是带密钥的摘要:同样的密钥和同样的内容,永远算出同样的结果;内容或密钥差一个字节,结果就完全不同。而且因为算的是摘要,别人拿到签名也推不回密钥。
Webhook 签名验证的三个要点
代码只有十几行,但每一行都有理由:
- 用原始请求体,不要用解析过的 JSON。 这是最常踩的坑,后面单独说。
- 用常量时间比较。
==会在第一个不同的字节上就返回,比较耗时的差异能被用来一位一位地猜签名。各语言都有专门的函数:Node 的crypto.timingSafeEqual、Python 的hmac.compare_digest、PHP 的hash_equals、Go 的hmac.Equal。 - 检查时间戳,给一点容差。 5 分钟是个合适的值:够覆盖服务器之间的时钟误差和重试,又不至于让一条旧请求永远有效。
四种语言的代码
下面四份是 SealReply 帮助文档里的示例,都只用标准库,没有第三方依赖。写完这篇文章时四份都实际跑过:用同样的方式签一个请求体,四个脚本都输出 ok;把签名改掉一个字节,四个都输出 invalid。
Node.js
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// header: X-Signature 请求头;rawBody: 收到的原始请求体(Buffer 或字符串)
export function verifySignature(secret, header, rawBody, now = Date.now() / 1000) {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!parts.v1 || !Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
注意 timingSafeEqual 要求两个 Buffer 长度相同,长度不同会直接抛异常,所以先比长度。
在 Express 里要让这个函数拿到原始请求体,路由上要单独用 express.raw:
app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySignature(process.env.SEALREPLY_WEBHOOK_SECRET, req.get("X-Signature"), req.body)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // 先回,再干活
});
Python
import hashlib, hmac, time
TOLERANCE_SECONDS = 5 * 60
def verify_signature(secret: str, header: str, raw_body: bytes, now: float | None = None) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
signature = parts["v1"]
except (KeyError, ValueError):
return False
if abs((now or time.time()) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Django 里原始请求体是 request.body,Flask 里是 request.get_data()(不要用 request.json),FastAPI 里是 await request.body()。
PHP
const SEALREPLY_TOLERANCE_SECONDS = 300;
function sealreply_verify_signature(string $secret, string $header, string $rawBody, ?int $now = null): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', $part, 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(($now ?? time()) - (int) $parts['t']) > SEALREPLY_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
原始请求体用 file_get_contents('php://input'),请求头在 $_SERVER['HTTP_X_SIGNATURE']。Laravel 里用 $request->getContent() 和 $request->header('X-Signature'),并且要把这个路由排除在 CSRF 校验之外。
Go
const tolerance = 5 * time.Minute
func VerifySignature(secret, header string, rawBody []byte, now time.Time) bool {
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(p, "="); ok {
parts[k] = v
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || parts["v1"] == "" {
return false
}
if d := now.Sub(time.Unix(t, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}
Go 里请求体是个只能读一次的流,所以要先 body, _ := io.ReadAll(r.Body),拿到 body 之后再验签、再 json.Unmarshal(body, &event)。Gin 里同理,用 c.GetRawData()。
签名老是不对?按这个顺序查

1. 请求体被框架解析过又重新序列化了。 十次里有八次是这个原因。JSON.parse 再 JSON.stringify 之后,空格、键的顺序、Unicode 转义、数字格式都可能变,哪怕数据完全一样,摘要也不一样了。签名必须对着收到的那串字节算。常见的坑是全局 app.use(express.json()) 把请求体吃掉了,或者 PHP 框架在你取 php://input 之前已经读完了流。
2. 中间有东西动了请求体。 反向代理或网关开了请求体改写、重新压缩、改字符集,或者你的日志中间件把 body 读走了。在验签的那一层把原始字节的长度和前几十个字节打到日志里,和服务商投递日志里的内容对一下,就能看出来是不是进门前就变了。
3. 密钥不对。 密钥只在创建地址时显示一次,复制时容易漏掉前缀或末尾字符;也可能有人点过「更换密钥」,旧密钥立即失效。另外检查一下环境变量里有没有带上换行或引号——whsec_abc\n 和 whsec_abc 是两个不同的密钥。
4. 服务器时间不准。 容差是 5 分钟,机器时钟偏了几分钟就会全部拒收。timedatectl 看一眼有没有在同步 NTP。排查时可以临时把时间戳检查单独打印出来,就知道是时间戳不过还是摘要不过。
5. 比较方式写错了。 把十六进制字符串和二进制摘要比、大小写不一致、或者 Node 里两个 Buffer 长度不同导致 timingSafeEqual 抛异常被外层吞掉变成「验签失败」。
调试的办法很简单:把一次真实投递的原始请求体存成文件,把 X-Signature 存成环境变量,然后在本地跑上面的函数。对得上就说明是线上那层的问题,对不上就说明是密钥或算法的问题。
验签之后还有三件事
验签只解决「这个请求可信吗」,一个能长期稳定运行的接收端还要做三件事:
- 先回 2xx,再干活。 SealReply 要求 10 秒内返回任意 2xx,否则算失败,之后按 1 分钟、5 分钟、30 分钟、2 小时、6 小时、24 小时重试;连续失败 3 天的地址会被自动停用。所以收到之后先存进队列或数据库,返回 200,慢活在后台做。
- 按事件 ID 去重。 同一个事件偶尔会到两次(重试、手动重发),
id在重试和重发时是同一个,拿它做幂等键。事件也可能不按顺序到,判断状态要看created_at和事件里的status,不要看到达顺序。 - 只处理你认得的事件类型。 对不认识的
type直接返回 200 忽略,这样服务商以后加新事件时你不会报错。
在 SealReply 里拿签名密钥
SealReply 的 Webhook 投递本身就带上面这个签名,不用额外开关。设置的地方是工作台的开发者 → Webhook → 添加地址:
- 填接收地址(公网 HTTPS,内网和
localhost会被拒绝)。 - 勾选要订阅的事件。目前有 8 个:
conversation.created、conversation.assigned、conversation.closed、message.created、offline_message.created、visitor.identified、lead.updated、visitor.deleted。 - 复制签名密钥(
whsec_…),它只显示这一次。
保存后用「发送测试事件」投一个 ping 过去,然后在「投递日志」里看结果:每次投递的状态码、耗时、下次重试时间和发出的内容都留 30 天,可以直接「重发」同一个事件(id 不变,正好用来验证你的去重逻辑)。Webhook 和开放 API 需要入门版及以上套餐。
常用的接法有这些:把 message.created 转发到工作群,把 offline_message.created 写进工单系统,用 lead.updated 把线索同步到自己的 CRM。收到 visitor.deleted 时要删掉自己这边的副本——这是访客行使删除权时该做的事,在线客服聊天记录安全吗里讲了数据存在哪、谁能看。
接下来
Webhook 适合「你自己有系统要对接」的场合。如果你要的只是「有新咨询时赶紧知道」,不用写代码:开推送通知、设好工作时间就够了,见人不在电脑前,怎么不漏掉网站上的客户。
还没装在线客服的话,先花几分钟把插件装上、接待第一位访客(5 分钟上手 SealReply),再回来接 Webhook。整条链路——从安装到接待到跟进成交——在小网站在线客服完整指南里串了一遍。