|
Knowledgebase
EnterprisePlatform
PlatformAPI
React (MCP)CLIIntegrationsReact (Lingo Compiler)
Alpha
GuidesChangelog

Synchronous

  • How it works
  • Localize
  • Recognize

Asynchronous

  • Localization
  • Provisioning

Coming SoonSoon

  • SDKs

Async Localization API

Max PrilutskiyMax Prilutskiy·Updated 1 day ago·12 min read

Translate content to multiple languages in a single request. One POST with your content and target locales returns a 202 in milliseconds. Each language processes independently through your localization engine, and results arrive via webhook or real-time WebSocket stream the moment each one completes.

On this page

  • The problem
  • Authentication
  • How it works
  • Create localization jobs
  • Get a job group
  • Get a single job
  • List jobs
  • Webhooks
  • WebSocket

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 localization API works, but creates friction at scale.

Consider a training module authored in English that needs to reach learners in 14 languages. With the synchronous API, you have two options:

  1. 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.

  2. Single request - Translate all languages in one synchronous call. Fewer network round-trips, 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, 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 localization API eliminates these tradeoffs. One request creates a job group with one job per target locale. Each job processes independently through your localization engine, and results arrive via webhook the moment each language completes. For real-time progress in your UI, connect a WebSocket to the job group. Your application stays responsive. Your users see translations appearing in real time. The platform handles retries, delivery guarantees, and failure isolation.

How it works#

1

Submit a request

POST to /jobs/localization with your content and target locales. The API validates the request, creates a job group with one job per locale, and returns 202 with the group ID and job summaries. Your application is free to continue immediately.

2

Background processing

The platform processes each job independently through your localization engine via durable background workflows. Each job applies the same model selection, glossary rules, brand voice, and instructions as the synchronous API. Jobs move from queued to processing to completed or failed.

3

Receive results

As each language completes, the platform delivers the result to your webhook URL. For real-time progress in your UI, connect a WebSocket to the job group and receive status events as they happen.

Authentication#

Same as the synchronous localization 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 localization jobs#

Submit content for translation to multiple locales in a single request. The API creates one job per target locale, processes them independently through your localization engine, and delivers results via webhooks or WebSocket.

text
POST /jobs/localization
ParameterTypeDescription
sourceLocalestringBCP-47 source locale (e.g., en)
targetLocalesstring[]Array of BCP-47 target locales (e.g., ["de", "fr", "ja"])
dataobjectKey-value content to translate. Supports nested objects and arrays.
hintsobject (optional)Contextual hints per key (array of breadcrumb strings)
callbackUrlstring (optional)HTTPS webhook URL for this job group. Overrides the organization default.
idempotencyKeystring (optional)Client-generated UUID to prevent duplicate job groups.
engineIdstring (optional)Localization engine ID. Uses the organization's default engine if omitted.

Request#

The data field accepts flat key-value pairs or nested structures with objects and arrays at any depth. The localization engine translates all string values, preserves non-string values (numbers, booleans, null) as-is, and maintains the exact structure of the input.

json
{
  "sourceLocale": "en",
  "targetLocales": ["de", "fr", "ja"],
  "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"
}

HTTPS required

The callbackUrl must use HTTPS. HTTP URLs are rejected with a 400 error.

Response (202 Accepted)#

json
{
  "groupId": "ljg_A1b2C3d4E5f6G7h8",
  "status": "pending",
  "jobs": [
    { "id": "ljb_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "queued" },
    { "id": "ljb_B2c3D4e5F6g7H8i9", "targetLocale": "fr", "status": "queued" },
    { "id": "ljb_C3d4E5f6G7h8I9j0", "targetLocale": "ja", "status": "queued" }
  ],
  "createdAt": "2026-03-16T10:30:00.000Z"
}

Examples#

javascript
const response = await fetch("https://api.lingo.dev/jobs/localization", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.LINGO_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    sourceLocale: "en",
    targetLocales: ["de", "fr", "ja"],
    data: {
      title: "Introduction to Machine Learning",
      steps: [
        { heading: "What is ML?", body: "Machine learning is a subset of AI." },
        { heading: "Supervised Learning", body: "Training with labeled data." },
      ],
    },
    callbackUrl: "https://your-app.com/webhooks/translations",
  }),
});

const { groupId, jobs } = await response.json();
// 202 back in milliseconds. 3 jobs queued.
// Results arrive via webhook as each language completes.
console.log(groupId); // "ljg_A1b2C3d4E5f6G7h8"
console.log(jobs.length); // 3

Idempotency keys

If your application might submit the same request twice (due to retries or duplicate events), pass an idempotencyKey. The platform returns the existing job group instead of creating a duplicate. Keys are scoped per localization engine. A natural key combines content identity with version: {contentId}-v{contentVersion}.

Get a job group#

Retrieve the status of a job group and all its child jobs.

text
GET /jobs/localization/groups/:groupId

Response#

json
{
  "groupId": "ljg_A1b2C3d4E5f6G7h8",
  "status": "processing",
  "sourceLocale": "en",
  "totalJobs": 3,
  "completedJobs": 1,
  "failedJobs": 0,
  "jobs": [
    { "id": "ljb_A1b2C3d4E5f6G7h8", "targetLocale": "de", "status": "completed", "completedAt": "2026-03-16T10:30:04.000Z" },
    { "id": "ljb_B2c3D4e5F6g7H8i9", "targetLocale": "fr", "status": "processing", "completedAt": null },
    { "id": "ljb_C3d4E5f6G7h8I9j0", "targetLocale": "ja", "status": "queued", "completedAt": null }
  ],
  "createdAt": "2026-03-16T10:30:00.000Z"
}
Group statusMeaning
pendingGroup created, no jobs started yet
processingAt least one job is in progress
completedAll jobs completed successfully
partialSome jobs completed, some failed
failedAll jobs failed

Polling interval

For most jobs, processing takes 2-8 seconds per language. If you're polling instead of using webhooks or WebSocket, a 2-second interval is a reasonable starting point.

Partial failures

Each language is an independent job. If German succeeds but Japanese fails, the German translation is delivered normally via webhook. The failed job appears with status: "failed" and an errorMessage. The group status becomes partial. To retry, submit a new request with only the failed locales and a fresh idempotency key.

Get a single job#

Retrieve full details for one job, including translated output when complete.

text
GET /jobs/localization/:jobId

Response#

The outputData field mirrors the structure of the input data with all string values translated. Nested objects, arrays, and non-string values are preserved as-is.

json
{
  "id": "ljb_A1b2C3d4E5f6G7h8",
  "groupId": "ljg_A1b2C3d4E5f6G7h8",
  "targetLocale": "de",
  "status": "completed",
  "outputData": {
    "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" }
  },
  "errorMessage": null,
  "callbackStatus": "delivered",
  "createdAt": "2026-03-16T10:30:00.000Z",
  "startedAt": "2026-03-16T10:30:01.000Z",
  "completedAt": "2026-03-16T10:30:04.000Z"
}
FieldDescription
statusqueued, processing, completed, or failed
outputDataTranslated content matching the input structure. Present when status is completed.
errorMessageError description. Present when status is failed.
callbackStatusWebhook delivery state: pending, delivered, or failed.

List jobs#

text
GET /jobs/localization?engineId=eng_abc123&status=completed&limit=20&cursor=...

Returns a paginated list of jobs, ordered by creation time (newest first). Filter by engineId or status. Use the nextCursor value from the response to fetch subsequent pages.

ParameterTypeDescription
engineIdstring (optional)Filter by localization engine ID
statusstring (optional)Filter by job status: queued, processing, completed, or failed
limitnumber (optional)Max results per page (default 20, max 100)
cursorstring (optional)Opaque cursor from a previous response's nextCursor

Response#

json
{
  "items": [
    {
      "id": "ljb_C3d4E5f6G7h8I9j0",
      "groupId": "ljg_A1b2C3d4E5f6G7h8",
      "targetLocale": "ja",
      "status": "completed",
      "createdAt": "2026-03-16T10:30:00.000Z",
      "completedAt": "2026-03-16T10:30:06.000Z"
    }
  ],
  "nextCursor": "eyJ0IjoiMjAyNi0wMy0xNlQxMDozMDowMC4wMDBaIiwiaSI6ImxqYl9CMmMzRDRlNUY2ZzdIOGk5In0"
}

Webhooks#

When a localization job completes or fails, the platform POSTs the result to your webhook URL. Each language is delivered independently the moment it finishes.

Configure a default webhook URL on your organization in the dashboard. 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#

When a job completes:

json
{
  "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" }
  }
}

When a job fails, the payload type is translation.failed with an error field instead of data:

json
{
  "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:

HeaderDescription
webhook-idThe job ID (unique per message)
webhook-timestampUnix timestamp (seconds) when the webhook was sent
webhook-signaturev1,{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:

javascript
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 the job.

Handling callbacks#

Return 200 immediately from your webhook handler, then process the translation asynchronously. This prevents webhook timeouts and ensures reliable delivery.

javascript
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);
  }
});

WebSocket#

For real-time progress updates, connect a WebSocket to a job group. Every message includes a full state snapshot, so clients never need to maintain local state or worry about missed events.

text
GET /jobs/localization/groups/:groupId/ws

Message types#

TypeWhenKey fields
snapshotOn initial connectionFull group state
job.completedA job finishes successfullyjobId, locale, plus full group state
job.failedA job failsjobId, locale, error, plus full group state
group.completedAll jobs are donegroupId, status, plus full group state

Every message contains a snapshot object with the current group state: totalJobs, completedJobs, failedJobs, and a jobs map with per-job locale and status.

Message payloads#

On connect, the server sends the current state:

json
{
  "type": "snapshot",
  "snapshot": {
    "groupId": "ljg_A1b2C3d4E5f6G7h8",
    "totalJobs": 3,
    "completedJobs": 1,
    "failedJobs": 0,
    "jobs": {
      "ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
      "ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "processing" },
      "ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "queued" }
    }
  }
}

As jobs finish, each event includes the updated snapshot:

json
{
  "type": "job.completed",
  "jobId": "ljb_B2c3D4e5F6g7H8i9",
  "locale": "fr",
  "snapshot": {
    "groupId": "ljg_A1b2C3d4E5f6G7h8",
    "totalJobs": 3,
    "completedJobs": 2,
    "failedJobs": 0,
    "jobs": {
      "ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
      "ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "completed" },
      "ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "processing" }
    }
  }
}

When a job fails:

json
{
  "type": "job.failed",
  "jobId": "ljb_C3d4E5f6G7h8I9j0",
  "locale": "ja",
  "error": "Model timeout after 30 seconds",
  "snapshot": { "...": "..." }
}

When every job has resolved, the server sends a final event and closes the connection:

json
{
  "type": "group.completed",
  "groupId": "ljg_A1b2C3d4E5f6G7h8",
  "status": "completed",
  "snapshot": { "...": "..." }
}

The status field is completed when all jobs succeeded, partial when some failed, or failed when all failed.

Example#

javascript
import WebSocket from "ws";

const groupId = "ljg_A1b2C3d4E5f6G7h8";
const ws = new WebSocket(
  `wss://api.lingo.dev/jobs/localization/groups/${groupId}/ws`,
  { headers: { "X-API-Key": process.env.LINGO_API_KEY } }
);

ws.on("message", (raw) => {
  const event = JSON.parse(raw);
  const { snapshot } = event;

  switch (event.type) {
    case "snapshot":
      console.log(`${snapshot.completedJobs}/${snapshot.totalJobs} complete`);
      break;
    case "job.completed":
      console.log(`${event.locale} ready (${snapshot.completedJobs}/${snapshot.totalJobs})`);
      break;
    case "job.failed":
      console.error(`${event.locale} failed: ${event.error}`);
      break;
    case "group.completed":
      console.log(`All translations done: ${event.status}`);
      ws.close();
      break;
  }
});

Keep your API key server-side

WebSocket connections require API key authentication. Connect from your backend, then proxy events to your frontend over your own WebSocket or server-sent events channel.

Wiring into your UI#

The job group provides a natural progress model. Store the groupId when you create jobs, then update your UI as WebSocket events arrive. A progress indicator ("3 of 14 languages ready") that updates in real time gives users confidence that translations are in progress.

javascript
async function onContentSaved(content) {
  const response = await fetch("https://api.lingo.dev/jobs/localization", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.LINGO_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      sourceLocale: "en",
      targetLocales: ["de", "fr", "ja", "ko", "pt-BR"],
      data: {
        title: content.title,
        steps: content.steps,
        metadata: content.metadata,
      },
      callbackUrl: "https://your-app.com/webhooks/translations",
      idempotencyKey: `${content.id}-v${content.version}`,
    }),
  });

  const { groupId, jobs } = await response.json();

  // Store for progress tracking in your UI
  await db.translationProgress.create({
    contentId: content.id,
    groupId,
    totalLanguages: jobs.length,
    completedLanguages: 0,
  });
}

Next Steps#

Async Provisioning API
AI-powered engine configuration from your existing content
Localize
Synchronous translation for single locale pairs
API Keys
Generate and manage API keys for your organization

Was this page helpful?