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:
| Header | Description |
|---|---|
webhook-id | A unique identifier for the delivery. |
webhook-timestamp | Unix timestamp in seconds when the delivery was sent. |
webhook-signature | The 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:
whsec_Mf9aQ7n...base64...key...bytesStrip 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.
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.
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.
