Webhook 签名怎么验证:Node、PHP、Go、Python 完整示例

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

Webhook 签名验证只有三步:拿你的签名密钥,对「时间戳 + 点 + 原始请求体」算一次 HMAC-SHA256,再把结果和请求头里的签名做常量时间比较;顺手拒掉时间戳偏差超过 5 分钟的请求。三步缺一个,你的接收地址就还是个谁都能写入的公开入口。

下面先说清为什么要验、签名是怎么算出来的,然后给 Node.js、Python、PHP、Go 四份能直接复制进项目的代码,最后是「代码照抄了但签名就是对不上」时该查的几个地方。

An envelope with a wax seal arriving at a server, next to a magnifying glass holding a matching stamp

不验签会发生什么

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 分钟以外的请求」才真的能防重放。

A key, a clock dial and a sheet of paper flowing through a funnel and coming out as a single padlock

HMAC 不是加密,是带密钥的摘要:同样的密钥和同样的内容,永远算出同样的结果;内容或密钥差一个字节,结果就完全不同。而且因为算的是摘要,别人拿到签名也推不回密钥。

Webhook 签名验证的三个要点

代码只有十几行,但每一行都有理由:

  1. 用原始请求体,不要用解析过的 JSON。 这是最常踩的坑,后面单独说。
  2. 用常量时间比较。 == 会在第一个不同的字节上就返回,比较耗时的差异能被用来一位一位地猜签名。各语言都有专门的函数:Node 的 crypto.timingSafeEqual、Python 的 hmac.compare_digest、PHP 的 hash_equals、Go 的 hmac.Equal。
  3. 检查时间戳,给一点容差。 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()。

签名老是不对?按这个顺序查

Two envelopes at a shield in front of a server, the one with an intact seal passing through and the cracked one bounced back

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 → 添加地址:

  1. 填接收地址(公网 HTTPS,内网和 localhost 会被拒绝)。
  2. 勾选要订阅的事件。目前有 8 个:conversation.created、conversation.assigned、conversation.closed、message.created、offline_message.created、visitor.identified、lead.updated、visitor.deleted。
  3. 复制签名密钥(whsec_…),它只显示这一次。

保存后用「发送测试事件」投一个 ping 过去,然后在「投递日志」里看结果:每次投递的状态码、耗时、下次重试时间和发出的内容都留 30 天,可以直接「重发」同一个事件(id 不变,正好用来验证你的去重逻辑)。Webhook 和开放 API 需要入门版及以上套餐。

常用的接法有这些:把 message.created 转发到工作群,把 offline_message.created 写进工单系统,用 lead.updated 把线索同步到自己的 CRM。收到 visitor.deleted 时要删掉自己这边的副本——这是访客行使删除权时该做的事,在线客服聊天记录安全吗里讲了数据存在哪、谁能看。

接下来

Webhook 适合「你自己有系统要对接」的场合。如果你要的只是「有新咨询时赶紧知道」,不用写代码:开推送通知、设好工作时间就够了,见人不在电脑前,怎么不漏掉网站上的客户。

还没装在线客服的话,先花几分钟把插件装上、接待第一位访客(5 分钟上手 SealReply),再回来接 Webhook。整条链路——从安装到接待到跟进成交——在小网站在线客服完整指南里串了一遍。