When an async localization job completes or fails, the platform POSTs the result to your webhook URL. Each language is delivered independently the moment it finishes.
Webhook URL configuration#
Configure a default webhook URL on your organization in the dashboard. This URL receives callbacks for all jobs across all groups. Individual requests can override the default by passing callbackUrl when creating jobs.
HTTPS required
Webhook URLs must use HTTPS. The platform rejects HTTP callback URLs.
Payload#
Completed#
{
"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" }
}
}Failed#
For failed jobs, the payload type is translation.failed with an error field instead of data.
{
"type": "translation.failed",
"jobId": "ljb_C3d4E5f6G7h8I9j0",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"sourceLocale": "en",
"targetLocale": "ja",
"error": "Model timeout after 30 seconds"
}Signature verification#
Every webhook includes three headers for signature verification, following the Standard Webhooks specification:
| Header | Description |
|---|---|
webhook-id | The job ID (unique per message) |
webhook-timestamp | Unix timestamp (seconds) when the webhook was sent |
webhook-signature | v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))} |
The webhook secret is auto-generated for your organization the first time you submit a job with a callback URL. It uses the whsec_ prefix followed by base64-encoded key bytes.
Verify the signature using your organization's webhook secret:
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 older than 5 minutes (replay prevention)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
throw new Error("Webhook timestamp too old");
}
// Compute expected signature
const content = `${msgId}.${timestamp}.${payload}`;
const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
const expected = crypto
.createHmac("sha256", secretBytes)
.update(content)
.digest("base64");
// Verify (constant-time comparison)
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");
}Preserve the raw body
Compute the signature from the raw HTTP body before JSON parsing. If your framework auto-parses the body, whitespace or key ordering differences will break verification.
Retry behavior#
If your endpoint returns a non-2xx status code or is unreachable, the platform retries with exponential backoff starting at 30 seconds, up to 5 attempts. After all retries are exhausted, the webhook is marked as failed. You can still retrieve the result by polling GET /jobs/localization/:jobId.
Handling callbacks#
Return 200 immediately from your webhook handler, then process the translation asynchronously. This prevents webhook timeouts and ensures reliable delivery.
app.post("/webhooks/translations", verifyWebhook, async (req, res) => {
// Return 200 first - process in background
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 },
});
// Update progress - your UI can poll this or receive it via SSE
await db.translationProgress.increment({
where: { groupId },
data: { completedLanguages: { increment: 1 } },
});
}
if (type === "translation.failed") {
console.error(`Translation failed: ${jobId}`, req.body.error);
}
});