|
文档
预约演示平台
平台MCPCLIAPI
工作流
指南更新日志

欢迎

  • 概览
  • 身份验证
  • 错误与状态码
  • Webhook 签名

本地化

  • 概览
  • 创建任务
  • 锁定不可翻译的键
  • 查看任务组状态
  • 获取单个作业
  • 列出作业
  • Webhook 结果投递
  • 实时进度(WebSocket)

流水线

  • 概览
  • 本地化前 AI 预编辑
  • 人工审核
  • AI 审校(后编辑)
  • 改写成更自然的文案
  • 回译检查
  • 配置 Pipeline
  • 查看流水线运行记录

预配

  • 概览
  • 创建预配作业
  • 来源类型
  • AI 会提取哪些内容
  • Webhook 投递
  • 实时进度(WebSocket)

同步

  • 本地化
  • Recognize

引擎管理

  • Engine Suggestions

Webhook 签名

当异步任务完成时,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 编码的密钥字节:

text
whsec_Mf9aQ7n...base64...key...bytes

去掉 whsec_ 前缀后,再对剩余部分做 base64 解码,才能还原出原始密钥字节——这个解码后的值才是 HMAC 密钥,而不是带前缀的字符串本身。直接拿字面量 whsec_... 文本去参与签名,是看起来实现没问题却始终无法匹配的最常见原因,所以一定要先解码。

像保护 API 密钥一样保护这个密钥

签名密钥是区分真实回调与伪造回调的关键。请将它保存在服务端,不要提交到源代码仓库,也不要打进任何客户端 bundle。任何持有它的人,都能签出你的处理器会接受的 payload。想了解 Lingo.dev 如何处理组织级凭据,请参见 API Keys。

验证签名#

验证只需要一个函数,把它统一挂在处理器前面即可。它会做三件事:基于原始请求体重新计算预期签名、用常量时间比较收到的签名是否一致,并在你的代码运行之前拦下所有不匹配的请求。同一个函数可以保护 Lingo.dev 发给你的所有异步事件——localization 完成、provisioning 完成、所有类型、所有产品入口,全部通用。

javascript
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,然后再处理真正的工作——数据库写入、下游调用、缓存失效——也就是在响应发出之后再执行。

javascript
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 用于覆盖常规路径,而已存储的任务始终是你可以回退到的事实来源。对于翻译任务,可以 直接轮询它。

后续步骤#

身份验证
了解 API 密钥如何为发往 API 的每一次请求完成身份验证
Localization Webhooks
translation.completed 和 translation.failed 的 payload 结构
Provisioning Webhooks
AI 引擎 provisioning 任务的回调 payload

这个页面对你有帮助吗?

Max PrilutskiyMax Prilutskiy·已更新 12 天前·2 分钟阅读