跳到主要内容

回调验签

业务后端使用 webhookSecret 校验原始请求字节。以下示例使用各语言标准库,不依赖 Server SDK。地址配置与投递确认见服务端回调。

算法​

signed_payload = UTF8(x-eduskit-timestamp + ".") + raw_request_body_bytes
expected = HMAC_SHA256(UTF8(webhookSecret), signed_payload)
x-eduskit-signature = "v1=" + hex(expected)

示例接受与接收服务器当前时间相差不超过 300 秒的签名时间戳,并使用常量时间比较摘要。这是接收方的防重放策略,服务端没有替业务接口设置这个校验窗口。接收服务器应同步时钟;较早产生的事件可以带有新的投递时间戳。

在 JSON 中间件改写请求体之前读取字节,保留空格、换行、字段顺序和 UTF-8 内容。签名密钥按 UTF-8 文本使用,即使外观为十六进制字符串,也不先做 hex 解码。

选择语言​

向函数传入原始正文、x-eduskit-timestamp、x-eduskit-signature 和配置的密钥。缺失请求头时拒绝请求或传空字符串;重复的签名头、时间戳头应拒绝。可显式传入时间的参数仅用于确定性测试,生产使用接收服务器当前时间。

Node.js 接收 Buffer;Python 接收 bytes;Java、Go 接收字节数组;PHP 使用 php://input 读取的原始字符串。Java 示例要求 Java 11+,PHP 示例要求 PHP 8.1+。Java 的加密计算异常应返回错误响应,不能确认接收成功。

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(
rawBody,
timestamp,
signature,
secret,
nowSeconds = Math.floor(Date.now() / 1000),
) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
if (
typeof timestamp !== 'string' ||
timestamp.trim() !== timestamp ||
!/^[0-9]{1,16}$/.test(timestamp)
)
return false;
if (
typeof signature !== 'string' ||
signature.length !== 67 ||
!/^v1=[0-9a-fA-F]{64}$/.test(signature)
)
return false;
const sentAt = Number(timestamp);
if (!Number.isSafeInteger(sentAt) || Math.abs(nowSeconds - sentAt) > 300) return false;
const expected = createHmac('sha256', secret)
.update(timestamp + '.', 'utf8')
.update(rawBody)
.digest();
return timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex'));
}

验签之后​

返回 true 仅表示签名和时间窗通过。继续解析正文、检查应用归属和请求头/正文事件标识,并完成幂等持久化后再确认接收。验签失败可返回 HTTP 401;平台会将其视为投递失败并重试,密钥不匹配时需修正配置。

验签函数文件位于文档仓库的 examples/webhooks/。将其接入业务 HTTP 路由,并配置事件持久化与去重。