|
Documentation
Book a DemoPlatform
PlatformMCPCLIAPI
Workflows
GuidesChangelog

Welcome

  • Overview
  • Authentication
  • Errors & status codes
  • Webhook signatures

Localization

  • Overview
  • Create jobs
  • Lock non-translatable keys
  • Track a job group
  • Get a single job
  • List jobs
  • Webhook delivery
  • Live progress (WebSocket)

Pipeline

  • Overview
  • Pre-localization AI edit
  • Human review
  • AI review (post-edit)
  • Rephrase for natural copy
  • Back-translation check
  • Configure the pipeline
  • Observe pipeline runs

Provisioning

  • Overview
  • Create a provisioning job
  • Source types
  • What the AI extracts
  • Webhook delivery
  • Live progress (WebSocket)

Synchronous

  • Localize
  • Recognize

Engine management

  • Engine Suggestions

Webhook signatures

When an async job finishes, Lingo.dev does not make you poll for it. It calls you back: a POST to the HTTPS endpoint you registered as your callbackUrl. That is the convenience. It is also the exposure – a public URL accepts whatever the internet sends it, and anyone who learns yours can POST a forged "job completed" event at your handler.

So the rule for every callback is the same: verify before you trust. Each delivery carries a signature computed from a secret only you and Lingo.dev hold. Recompute it on your side, compare it in constant time, and a forged payload never reaches your business logic. This page is the one place that mechanism lives. Both localization and provisioning callbacks use it unchanged – those pages cover their own payload shapes and link back here for verification.

On this page

  • The three headers
  • The signing secret
  • Verify a signature
  • Why the raw body matters
  • Reject replays
  • Respond fast, process later
  • Retry and backoff

The three headers#

Lingo.dev follows the Standard Webhooks specification, an open scheme several providers implement, so you are verifying against a published contract rather than a vendor's snowflake. Every delivery includes three headers:

HeaderDescription
webhook-idA unique identifier for the delivery.
webhook-timestampUnix timestamp in seconds when the delivery was sent.
webhook-signatureThe signature itself: v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))}

The signed content is the three parts joined by periods – webhook-id, then webhook-timestamp, then the raw request body – in that exact order. Reconstruct that string, run it through HMAC-SHA256 with your secret, base64-encode the result, and you have the value to compare against.

The webhook-signature header can carry more than one space-separated signature, each tagged with a scheme version (v1,...). A verifier accepts the delivery if any signature matches. Iterating the list rather than reading a single value is the defensive way to parse this header, so the samples below loop over every signature present.

The signing secret#

The secret is generated for your organization the first time you submit a job with a callbackUrl. It is prefixed whsec_, followed by base64-encoded key bytes:

text
whsec_Mf9aQ7n...base64...key...bytes

Strip the whsec_ prefix and base64-decode the remainder to recover the raw key bytes – that decoded value is the HMAC key, not the prefixed string. Signing against the literal whsec_... text is the most common reason a correct-looking implementation never matches, so decode first.

Treat the secret like an API key

The signing secret is what separates a real callback from a forged one. Keep it server-side, out of source control, and out of any client bundle. Anyone who holds it can sign payloads your handler will accept. See API Keys for how Lingo.dev handles org-scoped credentials.

Verify a signature#

Verification is a single function you mount once in front of your handler. It does three things: recompute the expected signature from the raw body, compare it to what arrived using a constant-time check, and reject anything that does not match before your code runs. The same function guards every async event Lingo.dev sends you – localization completions, provisioning completions, every type, every product surface.

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 outside a tolerance window (replay prevention)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
    throw new Error("Webhook timestamp too old");
  }

  // Recompute the expected signature over id.timestamp.body
  const content = `${msgId}.${timestamp}.${payload}`;
  const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
  const expected = crypto
    .createHmac("sha256", secretBytes)
    .update(content)
    .digest("base64");

  // A delivery may carry several signatures; accept if any matches
  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");
}

Compare with a constant-time function – crypto.timingSafeEqual, hmac.compare_digest – not ==. A plain string comparison returns as soon as two bytes differ, and that timing difference is enough to leak the signature one byte at a time. Constant-time comparison closes that side channel, which is why both samples above use it.

Why the raw body matters#

Notice that both functions sign payload – the body exactly as it arrived on the wire, before any JSON parsing. This is the detail that most often trips up an otherwise-correct integration, and it is worth stating plainly at the point where it bites:

The signature is computed over the exact bytes Lingo.dev sent. The moment you parse the body to an object and re-serialize it, you may change whitespace, key order, or numeric formatting – and the recomputed HMAC no longer matches a signature taken over the original bytes. The payload is identical in meaning; the bytes are not.

Verify against the raw body, not the parsed object

Capture the raw request body before your framework parses it, and pass those bytes to the verifier. In Express, use express.raw({ type: "application/json" }) on the webhook route. In FastAPI, read await request.body(). Parse only after the signature checks out – verification first, parsing second.

Reject replays#

A valid signed payload that an attacker captured can be replayed verbatim – the signature is still valid, because nothing in it changes between the first delivery and a copy sent an hour later. The webhook-timestamp header is what bounds that window: it records when the delivery was sent, so your verifier can reject anything older than a tolerance you choose. The samples above use five minutes.

A timestamp check stops a stale replay: a copy captured and resent later than your tolerance fails the freshness test and never reaches your handler.

Respond fast, process later#

Once a delivery is verified, return 200 immediately, then do the real work – database writes, downstream calls, cache invalidation – after you have responded.

javascript
app.post(
  "/webhooks/lingo",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let event;
    try {
      event = verifyWebhook(req.body.toString(), req.headers, process.env.LINGO_WEBHOOK_SECRET);
    } catch {
      return res.status(401).send("invalid signature");
    }

    // Acknowledge first, process after - never block the response on slow work
    res.status(200).send("ok");
    void handleEvent(event);
  }
);

The reason is mechanical, not stylistic. A slow handler holds the HTTP connection open; if it runs long enough to time out, the delivery counts as failed and gets retried – so heavy work inside the response path turns one event into several. Acknowledge fast, hand the work to a queue or a background task, and a single event stays a single event. The payload shapes you switch on inside handleEvent live with each product: localization callbacks and provisioning callbacks.

Retry and backoff#

Your endpoint will be down sometimes – a deploy, a timeout, a bad gateway. Lingo.dev does not drop the event when that happens.

If your endpoint returns a non-2xx status or is unreachable, delivery is retried with exponential backoff starting at 30 seconds, up to 5 attempts. After the fifth attempt the delivery is marked failed and Lingo.dev stops trying – but the result is not lost. It stays retrievable from the job record, so a stretch of downtime costs you a callback, never the result itself. That job record is your backstop: build the webhook for the common case, and treat the stored job as the source of truth you can always fall back to. For a translation job, poll it directly.

Next steps#

Authentication
How API keys authenticate every request to the API
Localization webhooks
The translation.completed and translation.failed payload shapes
Provisioning webhooks
Callback payloads for AI engine provisioning jobs

Was this page helpful?

Max PrilutskiyMax Prilutskiy·Updated about 5 hours ago·6 min read