Quando um job assíncrono termina, Lingo.dev não obriga você a ficar consultando o status. Ela chama você de volta: um POST para o endpoint HTTPS que você registrou como seu callbackUrl. Essa é a conveniência. Mas também é a exposição: uma URL pública aceita tudo o que a internet mandar, e qualquer pessoa que descubra a sua pode POST um evento forjado de "job concluído" para o seu handler.
Por isso, a regra para todo callback é a mesma: verifique antes de confiar. Cada entrega vem com uma assinatura calculada a partir de um segredo que só você e a Lingo.dev possuem. Recalcule do seu lado, compare em tempo constante, e um payload forjado nunca chega à sua lógica de negócio. Esta página concentra todo esse mecanismo. Os callbacks de localização e provisionamento usam exatamente o mesmo processo — aquelas páginas cobrem apenas seus próprios formatos de payload e apontam de volta para esta aqui quando o assunto é verificação.
Nesta página
- Os três cabeçalhos
- O segredo de assinatura
- Como verificar uma assinatura
- Por que o corpo bruto importa
- Como rejeitar replays
- Responda rápido, processe depois
- Retries e backoff
Os três cabeçalhos#
A Lingo.dev segue a especificação Standard Webhooks, um esquema aberto adotado por vários provedores, então você verifica com base em um contrato publicado — e não em uma solução proprietária e fora do padrão. Cada entrega inclui três cabeçalhos:
| Cabeçalho | Descrição |
|---|---|
webhook-id | Um identificador único da entrega. |
webhook-timestamp | Timestamp Unix, em segundos, de quando a entrega foi enviada. |
webhook-signature | A própria assinatura: v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))} |
O conteúdo assinado são três partes unidas por pontos — webhook-id, depois webhook-timestamp e, por fim, o corpo bruto da requisição — nessa ordem exata. Reconstrua essa string, aplique HMAC-SHA256 com o seu segredo, codifique o resultado em base64 e você terá o valor a ser comparado.
O cabeçalho webhook-signature pode trazer mais de uma assinatura separada por espaço, cada uma marcada com uma versão de esquema (v1,...). Um verificador aceita a entrega se qualquer assinatura corresponder. Percorrer a lista, em vez de ler um único valor, é a forma defensiva de interpretar esse cabeçalho — por isso os exemplos abaixo iteram por todas as assinaturas presentes.
O segredo de assinatura#
O segredo é gerado para a sua organização na primeira vez que você envia um job com um callbackUrl. Ele vem com o prefixo whsec_, seguido pelos bytes da chave codificados em base64:
whsec_Mf9aQ7n...base64...key...bytesRemova o prefixo whsec_ e decodifique o restante em base64 para recuperar os bytes brutos da chave — esse valor decodificado é a chave HMAC, não a string com prefixo. Assinar usando literalmente o texto whsec_... é o motivo mais comum para uma implementação aparentemente correta nunca funcionar, então decodifique primeiro.
Trate esse segredo como uma chave de API
O segredo de assinatura é o que separa um callback legítimo de um forjado. Mantenha-o no servidor, fora do controle de versão e longe de qualquer bundle de cliente. Quem tiver esse segredo pode assinar payloads que o seu handler vai aceitar. Consulte API Keys para ver como a Lingo.dev trata credenciais no escopo da organização.
Como verificar uma assinatura#
A verificação cabe em uma única função, que você posiciona uma vez antes do seu handler. Ela faz três coisas: recalcula a assinatura esperada a partir do corpo bruto, compara com o valor recebido usando uma checagem em tempo constante e rejeita tudo o que não corresponder antes de o seu código rodar. É a mesma função que protege todo evento assíncrono enviado pela Lingo.dev — conclusões de localização, conclusões de provisionamento, qualquer tipo, em qualquer superfície do produto.
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");
}Compare com uma função de tempo constante — crypto.timingSafeEqual, hmac.compare_digest — e nunca com ==. Uma comparação simples de string retorna assim que encontra bytes diferentes, e essa diferença de tempo já basta para vazar a assinatura um byte por vez. A comparação em tempo constante fecha esse canal lateral, por isso ambos os exemplos acima a utilizam.
Por que o corpo bruto importa#
Repare que ambas as funções assinam payload — o corpo exatamente como chegou pela rede, antes de qualquer parsing de JSON. Esse é o detalhe que mais costuma derrubar uma integração que, fora isso, estaria correta — e vale a pena deixar isso explícito justamente no ponto em que ele costuma causar problema:
A assinatura é calculada sobre os bytes exatos que a Lingo.dev enviou. No momento em que você faz o parsing do corpo para um objeto e o serializa de novo, pode mudar espaços em branco, ordem das chaves ou formatação numérica — e o HMAC recalculado deixa de bater com a assinatura gerada sobre os bytes originais. O payload continua com o mesmo significado; os bytes, não.
Verifique com base no corpo bruto, não no objeto parseado
Capture o corpo bruto da requisição antes que o framework faça o parsing e passe esses bytes ao verificador. No Express, use express.raw({ type: "application/json" }) na rota do webhook. No FastAPI, leia await request.body(). Só faça o parsing depois que a assinatura for validada — primeiro verifique, depois parseie.
Como rejeitar replays#
Um payload válido e assinado, se capturado por um invasor, pode ser reenviado literalmente — a assinatura continua válida, porque nada nela muda entre a entrega original e uma cópia enviada uma hora depois. O cabeçalho webhook-timestamp é o que limita essa janela: ele registra quando a entrega foi enviada, para que o seu verificador possa rejeitar qualquer coisa mais antiga do que a tolerância que você definir. Nos exemplos acima, ela é de cinco minutos.
A checagem de timestamp bloqueia replays antigos: uma cópia capturada e reenviada depois do limite de tolerância falha no teste de validade e nunca chega ao seu handler.
Responda rápido, processe depois#
Depois que uma entrega for verificada, retorne 200 imediatamente e só então faça o trabalho de verdade — gravações no banco, chamadas downstream, invalidação de cache — depois da resposta.
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);
}
);O motivo é mecânico, não estilístico. Um handler lento mantém a conexão HTTP aberta; se demorar o suficiente para expirar, a entrega conta como falha e entra em retry — então colocar trabalho pesado no caminho da resposta transforma um evento em vários. Confirme rápido, envie o trabalho para uma fila ou tarefa em segundo plano, e um único evento continua sendo um único evento. Os formatos de payload sobre os quais você faz o switch dentro de handleEvent ficam nas páginas de cada produto: callbacks de localização e callbacks de provisionamento.
Retries e backoff#
Seu endpoint vai ficar indisponível às vezes — seja por um deploy, timeout ou bad gateway. Quando isso acontecer, a Lingo.dev não descarta o evento.
Se o seu endpoint retornar um status diferente de 2xx ou estiver inacessível, a entrega entra em retry com backoff exponencial a partir de 30 segundos, até 5 tentativas. Depois da quinta tentativa, a entrega é marcada como falha e a Lingo.dev para de tentar — mas o resultado não se perde. Ele continua disponível no registro do job, então um período de indisponibilidade custa um callback, nunca o resultado em si. Esse registro do job é o seu plano B: use o webhook para o caso mais comum e trate o job armazenado como a fonte da verdade à qual você sempre pode recorrer. Para um job de tradução, consulte-o diretamente.
