Você criou um grupo de jobs e recebeu um 202 de volta em milissegundos. As traduções agora estão rodando em segundo plano, um job por idioma. Você até poderia consultar cada job até ele terminar — mas prefere não manter um loop de polling só para descobrir que o alemão já está pronto. O que você quer é que o seu servidor seja avisado no instante em que cada idioma ficar pronto.
É exatamente para isso que serve o webhook. Quando você passa uma callbackUrl ao criar os jobs, a Lingo.dev envia o resultado por POST para essa URL assim que cada job atinge um estado terminal — um POST por idioma, no exato momento em que ele fica pronto. Um idioma traduzido com sucesso chega como translation.completed com os dados. Um idioma que falha chega como translation.failed com o erro. Você é informado de qualquer jeito, idioma por idioma, sem precisar consultar.
Esta página explica os dois formatos de payload e como lidar com eles. A entrega é assinada e reenviada em caso de falha — essa mecânica é compartilhada com o provisioning e fica na página de verificação de assinatura de webhook, com links nos pontos em que você vai precisar dela.
Nesta página
- Como a entrega funciona
- O payload de conclusão
- O payload de falha
- Como lidar com um webhook
- Quando a entrega não é a ferramenta certa
Como a entrega funciona#
Cada idioma em um grupo é um job independente. No instante em que um deles atinge um estado terminal, o resultado é entregue separadamente para sua callbackUrl — a Lingo.dev não espera o idioma mais lento nem agrupa tudo em uma única chamada. Quatorze idiomas de destino significam até quatorze POSTs, chegando conforme cada idioma termina, na ordem em que terminarem.
Defina o destino por requisição com callbackUrl ao criar o grupo de jobs, ou configure um padrão da organização no dashboard, que todos os grupos herdam. Uma callbackUrl definida por requisição substitui o padrão da organização para aquele grupo.
Somente HTTPS
callbackUrl precisa usar HTTPS. Uma URL HTTP é rejeitada com 400 quando você cria o job — o webhook é assinado, e um payload assinado em texto puro perde totalmente o sentido.
Dois formatos de payload trafegam pela rede, diferenciados pelo campo type: translation.completed e translation.failed. Ambos informam o job, o grupo ao qual pertencem e o idioma que carregam, para que um único handler possa fazer o roteamento com base em type e atualizar o registro certo.
Lide bem com tipos de evento desconhecidos
Hoje, a transmissão inclui translation.completed e translation.failed. Trate esse conjunto como aberto — faça branch dos tipos que você conhece e ignore o restante, para que um tipo de evento futuro não quebre um handler já em produção.
O payload de conclusão#
Quando um job termina com sucesso, o payload traz o data traduzido — no mesmo formato que você obteria ao buscar o job, só que enviado para você em vez de consultado por polling. O data espelha a estrutura que você enviou: todas as strings traduzidas, todos os valores que não são string (números, booleanos, null) preservados, com o aninhamento intacto.
{
"type": "translation.completed",
"jobId": "ljb_A1b2C3d4E5f6G7h8",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"sourceLocale": "en",
"targetLocale": "de",
"data": {
"id": "course_101",
"title": "Einführung in maschinelles Lernen",
"steps": [
{ "heading": "Was ist ML?", "body": "Maschinelles Lernen ist ein Teilbereich der künstlichen Intelligenz." },
{ "heading": "Überwachtes Lernen", "body": "Trainieren eines Modells mit gelabelten Daten." }
],
"metadata": { "author": "Dr. Smith", "difficulty": "beginner" }
}
}| Campo | Descrição |
|---|---|
type | translation.completed |
jobId | O job que terminou (prefixo ljb_) |
groupId | O grupo ao qual ele pertence (prefixo ljg_) |
sourceLocale | O idioma de origem que você enviou |
targetLocale | O idioma para o qual este payload foi traduzido |
data | Conteúdo traduzido, no mesmo formato do data que você enviou |
Um job que produz saída não é uma falha — então, se ele termina como completed_with_warnings (saída gerada, mas uma etapa opcional do pipeline falhou), ele é entregue como translation.completed, com data utilizável. O webhook informa que o idioma está pronto; os avisos por etapa que explicam essa falha ficam no job individual, que você busca por jobId quando precisar deles.
O payload de falha#
Um idioma pode falhar — um modelo pode atingir o tempo limite, ou todos os modelos configurados podem ficar indisponíveis. Quando um job chega a failed, você ainda é informado. O tipo do payload é translation.failed, e ele traz uma string error no lugar de data:
{
"type": "translation.failed",
"jobId": "ljb_C3d4E5f6G7h8I9j0",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"sourceLocale": "en",
"targetLocale": "ja",
"error": "Model timeout after 30 seconds"
}| Campo | Descrição |
|---|---|
type | translation.failed |
jobId | O job que falhou |
groupId | O grupo ao qual ele pertence |
sourceLocale | O idioma de origem que você enviou |
targetLocale | O idioma que falhou |
error | Descrição da falha em linguagem clara |
A falha fica restrita a um único idioma. Se você enviou de, fr e ja, uma falha em ja é entregue em seu próprio POST translation.failed, enquanto de e fr chegam como translation.completed — as traduções em alemão e francês seguem normalmente. O status de falha parcial do grupo reflete essa combinação. Para recuperar o idioma que falhou, envie um novo job só para ele, com uma nova chave de idempotência.
Como lidar com um webhook#
O primeiro pensamento de quem está lendo com ceticismo é o certo: meu handler faz trabalho de verdade — grava no banco, invalida cache, faz fan-out para clientes conectados — então isso não vai manter a conexão aberta tempo demais e acabar estourando o tempo do webhook?
Vai — então não faça a Lingo.dev esperar. Retorne 200 primeiro; processe depois. Confirme o recebimento imediatamente e deixe o trabalho de verdade para depois que a resposta for enviada. Um handler que responde rápido mantém a entrega saudável; um handler que bloqueia por causa do trabalho downstream provoca um retry desnecessário.
app.post("/webhooks/translations", verifyWebhook, async (req, res) => {
// Acknowledge first - one POST per locale, the moment it lands.
res.status(200).send("ok");
const { type, jobId, groupId, targetLocale, data } = req.body;
if (type === "translation.completed") {
await db.content.update({
where: { groupId },
data: { [`content_${targetLocale}`]: data },
});
// Advance your own progress model - your UI can poll this or receive it over SSE.
await db.translationProgress.increment({
where: { groupId },
data: { completedLanguages: { increment: 1 } },
});
}
if (type === "translation.failed") {
console.error(`Translation failed: ${jobId} (${targetLocale})`, req.body.error);
}
});O middleware verifyWebhook é a única peça que esta página não define. Toda entrega é assinada seguindo a especificação Standard Webhooks, então não é um esquema que você precise decifrar por conta própria. Como fazer essa verificação — e qual é a agenda de retry por trás de uma resposta não 2xx — está documentado em detalhes em verificação de assinatura de webhook, compartilhada com o provisioning. Conecte esse middleware antes de confiar em qualquer payload: um corpo não verificado é um corpo não autenticado.
Verifique antes de confiar no corpo
Seu endpoint é uma URL pública; qualquer pessoa pode enviar um POST para ela. Verifique a assinatura com base no corpo bruto da requisição antes de agir sobre qualquer payload. O passo a passo — headers, HMAC e o segredo whsec_ — está na página de verificação de assinatura.
Quando a entrega não é a ferramenta certa#
O webhook é uma conveniência de envio, não a fonte da verdade. Há dois casos em que faz mais sentido usar outra coisa — e ambos estão a um clique de distância.
Se o seu endpoint estiver fora do ar quando um resultado for entregue, a plataforma tenta novamente — e, se todas as tentativas se esgotarem, o resultado não se perde. Ele continua recuperável por jobId; o callbackStatus do job registra se o envio acabou ou não tendo sucesso. A própria agenda de retry está na página de assinatura e entrega. O webhook poupa você de um loop de polling no caso comum; no incomum, o registro do job continua lá por trás de tudo.
E, se o que você quer é progresso em tempo real em uma UI — um contador passando de 3 de 14 para 4 de 14 conforme os idiomas ficam prontos, em vez de um callback por idioma para o seu servidor — aí o recurso certo é o WebSocket do grupo de jobs, não o webhook.
