Quando uma tarefa assíncrona termina, a Lingo.dev não o obriga a fazer polling. Em vez disso, faz-lhe um callback: um POST para o endpoint HTTPS que registou como callbackUrl. Essa é a conveniência. Mas também é a exposição — um URL público aceita tudo o que a internet lhe envia, e qualquer pessoa que descubra o seu pode POST um evento "tarefa concluída" forjado para o seu handler.
Por isso, a regra para todos os callbacks é a mesma: verifique antes de confiar. Cada entrega inclui uma assinatura calculada a partir de um segredo que só você e a Lingo.dev conhecem. Recalcule-a do seu lado, compare-a em tempo constante, e uma payload forjada nunca chegará à sua lógica de negócio. Esta página é a referência única para esse mecanismo. Tanto os callbacks de localização como os de aprovisionamento usam-no sem alterações — essas páginas explicam os respetivos formatos de payload e remetem para esta no que toca à verificação.
Nesta página
- Os três cabeçalhos
- O segredo de assinatura
- Verificar uma assinatura
- Porque é que o corpo em bruto importa
- Rejeitar repetições
- Responder rapidamente, processar depois
- Novas tentativas e backoff
Os três cabeçalhos#
A Lingo.dev segue a especificação Standard Webhooks, um esquema aberto adotado por vários fornecedores, pelo que está a verificar com base num contrato publicado em vez de uma implementação proprietária e idiossincrática. 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 do pedido em bruto — exatamente por esta ordem. Reconstrua essa string, aplique-lhe HMAC-SHA256 com o seu segredo, codifique o resultado em base64 e terá o valor a comparar.
O cabeçalho webhook-signature pode conter mais do que uma assinatura, separadas por espaços, cada uma identificada com uma versão do 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 analisar este 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 submete uma tarefa com um callbackUrl. Tem o prefixo whsec_, seguido de bytes de chave codificados em base64:
whsec_Mf9aQ7n...base64...key...bytesRemova o prefixo whsec_ e descodifique em base64 o restante para recuperar os bytes em bruto da chave — esse valor descodificado é a chave HMAC, não a string com o prefixo. Assinar com o texto literal whsec_... é a razão mais comum para uma implementação aparentemente correta nunca corresponder, por isso descodifique primeiro.
Trate o segredo como uma chave de API
O segredo de assinatura é o que distingue um callback real de um callback forjado. Mantenha-o no servidor, fora do controlo de versões e fora de qualquer bundle de cliente. Qualquer pessoa que o tenha pode assinar payloads que o seu handler aceitará. Consulte API Keys para perceber como a Lingo.dev gere credenciais ao nível da organização.
Verificar uma assinatura#
A verificação resume-se a uma única função, colocada uma vez à frente do seu handler. Faz três coisas: recalcula a assinatura esperada a partir do corpo em bruto, compara-a com o que chegou usando uma verificação em tempo constante e rejeita tudo o que não corresponder antes de o seu código correr. A mesma função protege todos os eventos assíncronos que a Lingo.dev lhe envia — conclusões de localização, conclusões de aprovisionamento, todos os tipos, em todas as superfícies 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 em tempo constante — crypto.timingSafeEqual, hmac.compare_digest — e não com ==. Uma comparação simples de strings devolve assim que dois bytes diferem, e essa diferença de tempo basta para revelar a assinatura um byte de cada vez. A comparação em tempo constante fecha esse canal lateral, e é por isso que ambos os exemplos acima a usam.
Porque é que o corpo em bruto importa#
Repare que ambas as funções assinam payload — o corpo exatamente como chegou pela rede, antes de qualquer parsing de JSON. Este é o detalhe que mais frequentemente faz tropeçar uma integração que, de resto, está correta, e vale a pena dizê-lo de forma clara no ponto em que costuma falhar:
A assinatura é calculada sobre os bytes exatos que a Lingo.dev enviou. No momento em que faz parsing do corpo para um objeto e o serializa de novo, pode alterar espaços em branco, a ordem das chaves ou a formatação numérica — e o HMAC recalculado deixa de corresponder a uma assinatura obtida sobre os bytes originais. A payload é idêntica no significado; os bytes não.
Verifique com base no corpo em bruto, não no objeto analisado
Capture o corpo do pedido em bruto antes de o seu framework o analisar e passe esses bytes ao verificador. Em Express, use express.raw({ type: "application/json" }) na rota do webhook. Em FastAPI, leia await request.body(). Só faça o parsing depois de a assinatura ser validada — primeiro verificação, depois parsing.
Rejeitar repetições#
Uma payload válida e assinada que um atacante tenha capturado pode ser repetida palavra por palavra — a assinatura continua válida, porque nada nela muda entre a primeira entrega e uma cópia enviada uma hora mais tarde. O cabeçalho webhook-timestamp é o que limita essa janela: regista quando a entrega foi enviada, para que o seu verificador possa rejeitar qualquer coisa mais antiga do que a tolerância que definir. Os exemplos acima usam cinco minutos.
Uma verificação do timestamp bloqueia uma repetição antiga: uma cópia capturada e reenviada depois da sua tolerância falha o teste de frescura e nunca chega ao seu handler.
Responder rapidamente, processar depois#
Assim que uma entrega for verificada, devolva 200 de imediato e só depois trate do trabalho real — escritas na base de dados, chamadas a serviços a jusante, invalidação de cache — já depois de responder.
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);
}
);A razão é mecânica, não estilística. Um handler lento mantém a ligação HTTP aberta; se demorar o suficiente para atingir o timeout, a entrega conta como falhada e volta a ser tentada — por isso, trabalho pesado dentro do fluxo de resposta transforma um evento em vários. Acuse a receção rapidamente, entregue o trabalho a uma fila ou a uma tarefa em segundo plano, e um único evento continuará a ser um único evento. Os formatos de payload sobre os quais faz switch dentro de handleEvent vivem em cada produto: callbacks de localização e callbacks de aprovisionamento.
Novas tentativas e backoff#
Por vezes, o seu endpoint vai estar indisponível — um deploy, um timeout, um bad gateway. A Lingo.dev não descarta o evento quando isso acontece.
Se o seu endpoint devolver um estado não 2xx ou estiver inacessível, a entrega volta a ser tentada com backoff exponencial a partir de 30 segundos, até 5 tentativas. Após a quinta tentativa, a entrega é marcada como falhada e a Lingo.dev deixa de tentar — mas o resultado não se perde. Continua disponível no registo da tarefa, pelo que um período de indisponibilidade lhe custa um callback, nunca o próprio resultado. Esse registo da tarefa é a sua salvaguarda: crie o webhook para o caso comum e trate a tarefa armazenada como a fonte de verdade a que pode sempre recorrer. Para uma tarefa de tradução, faça polling diretamente.
