当异步任务完成时,Lingo.dev 不会让你反复轮询。它会主动回调:向你注册为 callbackUrl 的 HTTPS 端点发送一个 POST。这就是它省心的地方。但便利也伴随着暴露面——公开 URL 会接收互联网上发来的任何内容,而任何知道你地址的人,都可以向你的处理器 POST 一个伪造的“任务完成”事件。
所以,每个回调都遵循同一条规则:先验证,再信任。 每次投递都会附带一个签名,由只有你和 Lingo.dev 持有的密钥计算得出。你需要在服务端重新计算,并用常量时间进行比较,这样伪造的 payload 就进不了你的业务逻辑。本页集中说明的就是这一套机制。localization 和 provisioning 回调都直接使用它——各自的页面会介绍自己的 payload 结构,并链接回这里说明验证方式。
本页内容
三个请求头#
Lingo.dev 遵循 Standard Webhooks 规范。这是一套已有多家服务提供商实现的开放方案,因此你验证的是公开发布的标准契约,而不是某个供应商自成一派的私有规则。每次投递都会包含三个请求头:
| 请求头 | 说明 |
|---|---|
webhook-id | 本次投递的唯一标识符。 |
webhook-timestamp | 投递发送时的 Unix 时间戳(秒)。 |
webhook-signature | 签名本身:v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))} |
参与签名的内容由三部分按句点连接而成——先是 webhook-id,然后是 webhook-timestamp,最后是 原始请求体——顺序必须完全一致。按这个顺序重建字符串,用你的密钥计算 HMAC-SHA256,再将结果做 base64 编码,就能得到用于比对的值。
webhook-signature 请求头里可能包含多个以空格分隔的签名,每个签名都带有一个方案版本标记(v1,...)。只要其中任意一个签名匹配,验证器就应接受这次投递。解析这个请求头时,遍历整个列表而不是只读取单个值,才是更稳妥的做法,因此下面的示例都会循环检查其中的每一个签名。
签名密钥#
当你第一次提交带有 callbackUrl 的任务时,系统会为你的组织生成这个密钥。它以 whsec_ 为前缀,后面跟着经过 base64 编码的密钥字节:
whsec_Mf9aQ7n...base64...key...bytes去掉 whsec_ 前缀后,再对剩余部分做 base64 解码,才能还原出原始密钥字节——这个解码后的值才是 HMAC 密钥,而不是带前缀的字符串本身。直接拿字面量 whsec_... 文本去参与签名,是看起来实现没问题却始终无法匹配的最常见原因,所以一定要先解码。
像保护 API 密钥一样保护这个密钥
签名密钥是区分真实回调与伪造回调的关键。请将它保存在服务端,不要提交到源代码仓库,也不要打进任何客户端 bundle。任何持有它的人,都能签出你的处理器会接受的 payload。想了解 Lingo.dev 如何处理组织级凭据,请参见 API Keys。
验证签名#
验证只需要一个函数,把它统一挂在处理器前面即可。它会做三件事:基于原始请求体重新计算预期签名、用常量时间比较收到的签名是否一致,并在你的代码运行之前拦下所有不匹配的请求。同一个函数可以保护 Lingo.dev 发给你的所有异步事件——localization 完成、provisioning 完成、所有类型、所有产品入口,全部通用。
import crypto from "node:crypto";
function verifyWebhook(payload, headers, secret) {
const msgId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
// Reject timestamps outside a tolerance window (replay prevention)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
throw new Error("Webhook timestamp too old");
}
// Recompute the expected signature over id.timestamp.body
const content = `${msgId}.${timestamp}.${payload}`;
const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
const expected = crypto
.createHmac("sha256", secretBytes)
.update(content)
.digest("base64");
// A delivery may carry several signatures; accept if any matches
for (const sig of signatures.split(" ")) {
const [version, value] = sig.split(",", 2);
if (version === "v1" && crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(value)
)) {
return JSON.parse(payload);
}
}
throw new Error("Invalid webhook signature");
}请使用常量时间比较函数——crypto.timingSafeEqual、hmac.compare_digest——而不是 ==。普通字符串比较会在两个字节出现差异时立刻返回,而这种时间差足以让签名被逐字节推断出来。常量时间比较可以封住这个侧信道,这也是上面两个示例都采用它的原因。
为什么原始请求体至关重要#
请注意,这两个函数签名的都是 payload——也就是请求体在网络上传输时的原始内容,在任何 JSON 解析之前。这是最容易让原本正确的集成翻车的细节,所以值得在最容易踩坑的地方直接讲清楚:
签名是基于 Lingo.dev 发送的精确字节计算出来的。只要你把请求体解析成对象再重新序列化,就可能改变空白字符、键顺序或数字格式——这样重新计算出的 HMAC 就不再匹配基于原始字节生成的签名。payload 的语义可以完全相同,但字节绝不会一样。
基于原始请求体验证,不要基于解析后的对象
在框架解析请求之前,先捕获原始请求体,并把这些字节传给验证器。在 Express 中,请在 webhook 路由上使用 express.raw({ type: "application/json" })。在 FastAPI 中,请读取 await request.body()。只有在签名校验通过之后再做解析——先验证,后解析。
拒绝重放#
攻击者一旦截获了一个有效签名的 payload,就可以原封不动地重放——因为从第一次投递到一小时后发出的副本,参与签名的内容没有任何变化,所以签名依然有效。而 webhook-timestamp 请求头正是用来收紧这个时间窗口的:它记录了投递发送的时间,因此你的验证器可以拒绝所有早于你设定容忍范围的请求。上面的示例使用的是五分钟。
时间戳检查可以拦住过期重放:副本一旦在超出你容忍范围之后被重新发送,就会在时效性校验中失败,根本到不了你的处理器。
快速响应,延后处理#
一旦投递通过验证,请立即返回 200,然后再处理真正的工作——数据库写入、下游调用、缓存失效——也就是在响应发出之后再执行。
app.post(
"/webhooks/lingo",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = verifyWebhook(req.body.toString(), req.headers, process.env.LINGO_WEBHOOK_SECRET);
} catch {
return res.status(401).send("invalid signature");
}
// Acknowledge first, process after - never block the response on slow work
res.status(200).send("ok");
void handleEvent(event);
}
);原因是机制使然,不是风格偏好。处理器太慢会一直占着 HTTP 连接;如果慢到超时,这次投递就会被视为失败并触发重试——于是把重活放在响应路径里,就会把一个事件放大成多个事件。正确做法是尽快确认接收,再把工作交给队列或后台任务,这样一个事件就始终只是一个事件。至于你在 handleEvent 里根据不同 payload 结构做分支处理的细节,会分别放在各产品页面中:localization callbacks 和 provisioning callbacks。
重试与退避#
你的端点偶尔总会不可用——比如正在部署、请求超时,或出现 bad gateway。Lingo.dev 不会因此直接丢掉事件。
如果你的端点返回非 2xx 状态码,或根本无法访问,系统会从 30 秒开始按指数退避重试,最多 5 次。第五次尝试之后,这次投递会被标记为失败,Lingo.dev 也会停止继续重试——但结果本身不会丢失。它仍然可以从任务记录中取回,因此一段时间的宕机最多只会让你错过一次回调,不会让结果本身消失。这份任务记录就是你的兜底:webhook 用于覆盖常规路径,而已存储的任务始终是你可以回退到的事实来源。对于翻译任务,可以 直接轮询它。
