The async localization API is purpose-built for applications where content needs to be available in many languages and users expect to see each translation appear the moment it's ready. Submit a translation job, get a 202 back in milliseconds, and show a progress indicator in your UI while each language arrives independently via webhook.
Enterprise feature
The async localization API is available on Enterprise plans. Contact us to enable it for your organization.
The problem#
Training platforms, content management systems, and e-learning tools often need to translate content into dozens of languages the moment it's created or updated. The synchronous /process/localize endpoint works, but creates friction at scale.
Consider a training module authored in English that needs to reach learners in 14 languages. You have two options with the synchronous API:
Parallel calls - Fire 14 requests simultaneously, one per target language. Each carries the same source payload. You can display each language as it arrives, but you're making 14 network round-trips with redundant data.
Batch call - Use
batchLocalizeTextin the SDK to translate all languages in one request. Fewer network calls, but you wait for all 14 to finish before displaying any of them.
Both approaches tie up your application while translations process. If your server restarts, translations in flight are lost. If one language fails in a batch, you need retry logic for partial failures. And neither gives your end users a clean way to see that translations are in progress.
The async API eliminates these tradeoffs. Submit one job per language, get a 202 back in milliseconds, and wire the job status into your UI as a progress indicator. Each language arrives independently via webhook the moment it completes. Your application stays responsive. Your users see translations appearing in real time. The platform handles retries.
How it works#
Submit a job
POST to /translation-jobs with your content and target language. The API validates the request, creates a job record, and returns 202 with a job ID. Your application is free to continue immediately.
Background processing
The platform queues the job and processes it through your localization engine. It applies the same model selection, glossary rules, brand voice, and instructions as the synchronous API. The job status moves from queued to processing to completed or failed.
Receive results
When translation completes, the platform POSTs the result to your webhook URL. Each job is independent - if you submitted 14 languages, you receive 14 webhook callbacks as each one finishes. Alternatively, poll GET /translation-jobs/:id for status.
Authentication#
Same as the synchronous API. Pass your API key in the X-API-Key header. API keys are scoped to an organization and have access to all localization engines within it. See API Keys for details.
Create a translation job#
POST /translation-jobs| Parameter | Type | Description |
|---|---|---|
sourceLocale | string | BCP-47 source locale (e.g., en) |
targetLocale | string | BCP-47 target locale (e.g., de) |
data | object | Key-value pairs to translate |
hints | object (optional) | Contextual hints per key (array of breadcrumb strings) |
callbackUrl | string (optional) | Webhook URL for this job. Overrides the organization default. |
idempotencyKey | string (optional) | Client-generated UUID to prevent duplicate jobs. |
Request#
{
"sourceLocale": "en",
"targetLocale": "de",
"data": {
"lesson_title": "Introduction to Machine Learning",
"lesson_summary": "This lesson covers the fundamentals of ML, including supervised and unsupervised learning."
},
"callbackUrl": "https://your-app.com/webhooks/translations",
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440000"
}Response (202 Accepted)#
{
"id": "tjb_A1b2C3d4E5f6G7h8",
"status": "queued",
"createdAt": "2026-03-16T10:30:00.000Z"
}Examples#
// Translate to 3 languages in parallel - fire and forget
const languages = ["de", "fr", "ja"];
const jobs = await Promise.all(
languages.map((targetLocale) =>
fetch("https://api.lingo.dev/translation-jobs", {
method: "POST",
headers: {
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
sourceLocale: "en",
targetLocale,
data: {
lesson_title: "Introduction to Machine Learning",
lesson_summary: "This lesson covers the fundamentals...",
},
callbackUrl: "https://your-app.com/webhooks/translations",
}),
}).then((r) => r.json())
)
);
// All 3 calls return 202 in milliseconds.
// Results arrive via webhook as each language completes.
console.log(jobs.map((j) => j.id));
// ["tjb_A1b2C3d4E5f6G7h8", "tjb_B2c3D4e5F6g7H8i9", "tjb_C3d4E5f6G7h8I9j0"]Check job status#
Poll this endpoint to check progress. Useful for CLI tools, debugging, or environments where you cannot expose a webhook endpoint.
GET /translation-jobs/:idResponse#
{
"id": "tjb_A1b2C3d4E5f6G7h8",
"status": "completed",
"sourceLocale": "en",
"targetLocale": "de",
"outputData": {
"lesson_title": "Einführung in maschinelles Lernen",
"lesson_summary": "Diese Lektion behandelt die Grundlagen des ML, einschließlich überwachtem und unüberwachtem Lernen."
},
"createdAt": "2026-03-16T10:30:00.000Z",
"startedAt": "2026-03-16T10:30:01.000Z",
"completedAt": "2026-03-16T10:30:04.000Z"
}| Field | Description |
|---|---|
status | queued, processing, completed, or failed |
outputData | Translated key-value pairs. Present when status is completed. |
errorMessage | Error description. Present when status is failed. |
callbackStatus | Webhook delivery state: pending, delivered, or failed. |
Polling interval
For most translation jobs, processing takes 2-8 seconds. A 2-second polling interval is a reasonable starting point.
List translation jobs#
GET /translation-jobs?engineId=eng_abc123&limit=20&cursor=...Returns a paginated list of jobs for the specified engine, ordered by creation time (newest first). Use the nextCursor value from the response to fetch subsequent pages.
Webhook delivery#
When a job completes, the platform POSTs the result to your webhook URL.
Webhook URL configuration#
Configure a default webhook URL on your organization in the dashboard. This URL receives callbacks for all translation jobs. Individual jobs can override the default by passing callbackUrl in the request body.
Payload#
{
"type": "translation.completed",
"jobId": "tjb_A1b2C3d4E5f6G7h8",
"sourceLocale": "en",
"targetLocale": "de",
"data": {
"lesson_title": "Einführung in maschinelles Lernen",
"lesson_summary": "Diese Lektion behandelt die Grundlagen..."
}
}For failed jobs, the payload type is translation.failed with an error field instead of data.
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 | HMAC-SHA256 signature of {webhook-id}.{webhook-timestamp}.{body} |
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: 30 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours. After 5 failed attempts, the webhook is marked as failed. You can still retrieve the result by polling GET /translation-jobs/:id.
Best practices#
Translate multiple languages in parallel#
Submit one job per target language. Each job processes independently, and webhook callbacks arrive as each language completes. This gives your users the fastest time-to-first-translation while remaining languages continue in the background. Show a progress indicator ("3 of 14 languages ready") that updates as each webhook arrives.
// Content saved - kick off translations for all target languages
async function onContentSaved(content) {
const targetLocales = ["de", "fr", "ja", "ko", "pt-BR"];
const jobs = await Promise.all(
targetLocales.map((locale) =>
fetch("https://api.lingo.dev/translation-jobs", {
method: "POST",
headers: {
"X-API-Key": process.env.LINGO_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
sourceLocale: "en",
targetLocale: locale,
data: {
title: content.title,
summary: content.summary,
body: content.body,
},
idempotencyKey: `${content.id}-${locale}-${content.updatedAt}`,
}),
}).then((r) => r.json())
)
);
// All 5 return 202 instantly.
// Store job IDs to track progress in your UI.
await db.translationProgress.create({
contentId: content.id,
totalLanguages: targetLocales.length,
completedLanguages: 0,
jobIds: jobs.map((j) => j.id),
});
}Handle webhook callbacks#
Return 200 immediately from your webhook handler, then process the translation asynchronously. This prevents webhook timeouts and ensures reliable delivery. Update your progress tracker so the UI can reflect each completed language in real time.
app.post("/webhooks/translations", verifyWebhook, async (req, res) => {
// Return 200 first - process in background
res.status(200).send("ok");
const { type, jobId, targetLocale, data } = req.body;
if (type === "translation.completed") {
// Save the translated content to your database
await db.content.update({
where: { id: extractContentId(jobId) },
data: { [`content_${targetLocale}`]: data },
});
// Update progress - your UI can poll this or receive it via SSE
await db.translationProgress.increment({
where: { jobIds: { has: jobId } },
data: { completedLanguages: { increment: 1 } },
});
}
if (type === "translation.failed") {
// Log and alert - the job can be retried or investigated
console.error(`Translation failed: ${jobId}`, req.body.error);
}
});Use idempotency keys#
If your application might submit the same translation twice (e.g., due to retries or duplicate events), pass an idempotencyKey. The platform returns the existing job instead of creating a duplicate. Keys are scoped per engine and expire after 24 hours.
A natural idempotency key: {contentId}-{locale}-{updatedAt}. This ensures a new job is created only when the content actually changes.
Handle partial failures#
Each language is an independent job. If German succeeds but Japanese fails, the German translation is delivered normally. The failed Japanese job appears with status: "failed" and an errorMessage. Retry by submitting a new job with a fresh idempotency key.
