Когда асинхронная задача завершается, Lingo.dev не заставляет вас постоянно опрашивать её статус. Сервис сам вызывает ваш обработчик: отправляет POST на HTTPS-эндпоинт, который вы зарегистрировали как свой callbackUrl. В этом и удобство. Но в этом же и риск: публичный URL принимает всё, что ему присылают из интернета, и любой, кто узнает ваш адрес, может POST поддельное событие "job completed" в ваш обработчик.
Поэтому правило для любого callback одно: сначала проверяйте, потом доверяйте. Каждая доставка содержит подпись, вычисленную с помощью секрета, который есть только у вас и Lingo.dev. Пересчитайте её на своей стороне, сравните за постоянное время — и поддельный payload не доберётся до вашей бизнес-логики. На этой странице собрано всё об этом механизме. И callbacks локализации, и callbacks развёртывания используют его без изменений — на тех страницах описаны только их собственные форматы payload и даны ссылки сюда для проверки.
На этой странице
- Три заголовка
- Секрет подписи
- Проверка подписи
- Почему важно исходное тело запроса
- Защита от повторного воспроизведения
- Отвечайте быстро, обрабатывайте позже
- Повторные попытки и backoff
Три заголовка#
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-ключом
Секрет подписи — это то, что отличает настоящий callback от поддельного. Храните его на стороне сервера, вне системы контроля версий и вне любых клиентских бандлов. Любой, у кого он есть, может подписывать payload, которые ваш обработчик примет. См. API Keys, чтобы узнать, как Lingo.dev работает с учётными данными уровня организации.
Проверка подписи#
Проверка — это одна функция, которую вы один раз ставите перед своим обработчиком. Она делает три вещи: пересчитывает ожидаемую подпись по исходному телу запроса, сравнивает её с пришедшей с помощью проверки за постоянное время и отклоняет всё, что не совпадает, ещё до выполнения вашего кода. Одна и та же функция защищает все асинхронные события, которые отправляет Lingo.dev: завершения локализации, завершения развёртывания — любой тип, в любом продукте.
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 используйте express.raw({ type: "application/json" }) на маршруте webhook. В FastAPI считывайте await request.body(). Парсить нужно только после успешной проверки подписи — сначала проверка, потом разбор.
Защита от повторного воспроизведения#
Даже корректно подписанный payload, который перехватил злоумышленник, можно дословно отправить повторно — подпись останется действительной, потому что между первой доставкой и копией, отправленной через час, в ней ничего не меняется. Именно заголовок webhook-timestamp ограничивает это окно: он фиксирует время отправки, чтобы ваш код проверки мог отклонять всё, что старше выбранного вами допуска. В примерах выше используется пять минут.
Проверка временной метки останавливает устаревшие повторы: копия, перехваченная и отправленная заново позже допустимого интервала, не проходит проверку свежести и не попадает в обработчик.
Отвечайте быстро, обрабатывайте позже#
Как только доставка проверена, сразу верните 200, а уже потом выполняйте реальную работу — запись в базу данных, вызовы downstream-сервисов, инвалидацию кэша.
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-соединение открытым; если он работает достаточно долго, чтобы сработал тайм-аут, доставка считается неуспешной и будет повторена — и тогда тяжёлая работа в пути ответа превращает одно событие в несколько. Быстро подтвердите получение, передайте работу в очередь или фоновую задачу — и одно событие так и останется одним. Форматы payload, по которым вы переключаетесь внутри handleEvent, описаны отдельно для каждого продукта: callbacks локализации и callbacks развёртывания.
Повторные попытки и backoff#
Иногда ваш эндпоинт будет недоступен — из-за деплоя, тайм-аута или ошибки шлюза. В таких случаях Lingo.dev не отбрасывает событие.
Если ваш эндпоинт возвращает статус вне диапазона 2xx или недоступен, доставка повторяется с экспоненциальным backoff, начиная с 30 секунд, до 5 попыток. После пятой попытки доставка помечается как неуспешная, и Lingo.dev прекращает попытки — но результат не теряется. Он остаётся доступным в записи задачи, поэтому даже длительный простой лишит вас callback, но не самого результата. Эта запись задачи — ваша страховка: стройте webhook под типовой сценарий, а сохранённую задачу считайте источником истины, к которому всегда можно вернуться. Для задачи перевода опрашивайте её напрямую.
