You created a job group and got a 202 back in milliseconds. The translations are now running in the background, one job per locale. You could poll each job until it finishes – but you'd rather not run a polling loop just to learn that German is ready. You want your server told the moment each locale lands.
That is what the webhook does. When you pass a callbackUrl while creating jobs, Lingo POSTs the result to that URL as each job reaches a terminal state – one POST per locale, the moment it lands. A locale that translates cleanly arrives as translation.completed with the data. A locale that fails arrives as translation.failed with the error. You are told either way, per language, without asking.
This page covers the two payloads and how to handle them. The delivery is signed and retried – that machinery is shared with provisioning and lives on the webhook signature verification page, linked at each point you'll need it.
On this page
- How delivery works
- The completed payload
- The failed payload
- Handling a webhook
- When delivery is the wrong tool
How delivery works#
Each locale in a group is an independent job. The instant one reaches a terminal state, its result is delivered to your callbackUrl on its own – Lingo does not wait for the slowest locale, and does not batch the group into a single call. Fourteen target locales means up to fourteen POSTs, arriving as each language finishes, in whatever order they finish.
Set the destination per request with callbackUrl when you create the job group, or set an organization default in the dashboard that every group inherits. A per-request callbackUrl overrides the org default for that group.
HTTPS only
callbackUrl must use HTTPS. An HTTP URL is rejected with a 400 when you create the job – the webhook is signed, and a signed payload over plaintext defeats the point.
Two payload shapes cross the wire, distinguished by their type field: translation.completed and translation.failed. Both name the job and group they belong to and the locale they carry, so a single handler can route on type and update the right record.
Handle unknown event types gracefully
Today the wire carries translation.completed and translation.failed. Treat the set as open – branch on the types you know and ignore the rest, so a future event type can't break a deployed handler.
The completed payload#
When a job finishes successfully, the payload carries the translated data – the same shape you'd get from fetching the job, pushed to you instead of polled. The data mirrors the structure you submitted: every string translated, every non-string value (numbers, booleans, null) preserved, nesting intact.
{
"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" }
}
}| Field | Description |
|---|---|
type | translation.completed |
jobId | The job that finished (ljb_ prefix) |
groupId | The group it belongs to (ljg_ prefix) |
sourceLocale | The source locale you submitted |
targetLocale | The locale this payload was translated into |
data | Translated content, matching the structure of the data you submitted |
A job that produces output is not a failure – so a job that finished as completed_with_warnings (output produced, but an optional pipeline stage fell through) is delivered as translation.completed, with usable data. The webhook tells you the locale is ready; the per-step warnings that explain the fall-through live on the single job, which you fetch by jobId when you want them.
The failed payload#
A locale can fail – a model can time out, every configured model can be unavailable. When a job reaches failed, you are still told. The payload type is translation.failed, and it carries an error string in place of data:
{
"type": "translation.failed",
"jobId": "ljb_C3d4E5f6G7h8I9j0",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"sourceLocale": "en",
"targetLocale": "ja",
"error": "Model timeout after 30 seconds"
}| Field | Description |
|---|---|
type | translation.failed |
jobId | The job that failed |
groupId | The group it belongs to |
sourceLocale | The source locale you submitted |
targetLocale | The locale that failed |
error | Human-readable failure description |
The failure is scoped to one locale. If you submitted de, fr, and ja, a ja failure is delivered as its own translation.failed POST while de and fr arrive as translation.completed – the German and French translations ship regardless. The group's partial-failure status reflects the mix. To recover the failed locale, submit a new job for just that locale with a fresh idempotency key.
Handling a webhook#
A skeptical reader's first thought here is the right one: my handler does real work – a database write, a cache bust, a fan-out to connected clients – so won't that hold the connection open long enough to time the webhook out?
It would, so don't make Lingo wait for it. Return 200 first, then process. Acknowledge receipt immediately and do the real work after the response is sent. A handler that returns promptly keeps delivery healthy; a handler that blocks on downstream work invites a retry it didn't need.
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);
}
});The verifyWebhook middleware is the one piece this page doesn't define. Every delivery is signed following the Standard Webhooks spec, so it isn't a scheme you have to reverse-engineer. How you verify it – and the retry schedule behind a non-2xx response – is documented in full on webhook signature verification, shared with provisioning. Wire that middleware in before you trust a payload: an unverified body is an unauthenticated one.
Verify before you trust the body
Your endpoint is a public URL; anyone can POST to it. Verify the signature against the raw request body before acting on any payload. The how – headers, the HMAC, the whsec_ secret – is on the signature verification page.
When delivery is the wrong tool#
The webhook is a push convenience, not the system of record. Two cases call for something else, and both are one link away.
If your endpoint was down when a result was delivered, the platform retries – and if every retry is exhausted, the result isn't lost. It stays retrievable by jobId; the job's callbackStatus records whether the push ultimately succeeded. The retry schedule itself is on the signature and delivery page. The webhook saves you a polling loop in the common case; the job record is always there underneath it in the uncommon one.
And if what you want is live progress in a UI – a counter ticking from 3 of 14 to 4 of 14 as locales land, rather than a per-locale callback to your server – that is the job-group WebSocket, not the webhook.
