Wenn ein asynchroner Job abgeschlossen ist, musst du ihn bei Lingo.dev nicht pollen. Stattdessen bekommst du einen Rückruf: ein POST an den HTTPS-Endpunkt, den du als deinen callbackUrl registriert hast. Das ist bequem. Es schafft aber auch Angriffsfläche – eine öffentliche URL nimmt alles entgegen, was aus dem Internet kommt, und jeder, der deine URL kennt, kann POST ein gefälschtes „Job abgeschlossen“-Ereignis an deinen Handler schicken.
Deshalb gilt für jeden Callback dieselbe Regel: erst verifizieren, dann vertrauen. Jede Zustellung trägt eine Signatur, die mit einem Secret berechnet wird, das nur du und Lingo.dev kennen. Berechne sie auf deiner Seite neu, vergleiche sie in konstanter Zeit, und eine gefälschte Payload erreicht deine Business-Logik gar nicht erst. Diese Seite ist die zentrale Referenz für diesen Mechanismus. Sowohl localization- als auch provisioning-Callbacks verwenden ihn unverändert – die jeweiligen Seiten beschreiben ihre eigenen Payload-Formate und verlinken für die Verifizierung hierher zurück.
Auf dieser Seite
- Die drei Header
- Das Signatur-Secret
- Eine Signatur verifizieren
- Warum der Raw Body wichtig ist
- Replays abwehren
- Schnell antworten, später verarbeiten
- Retries und Backoff
Die drei Header#
Lingo.dev folgt der Spezifikation Standard Webhooks – einem offenen Schema, das von mehreren Anbietern implementiert wird. So verifizierst du gegen einen veröffentlichten Standard statt gegen eine proprietäre Sonderlösung eines einzelnen Vendors. Jede Zustellung enthält drei Header:
| Header | Beschreibung |
|---|---|
webhook-id | Eine eindeutige Kennung für die Zustellung. |
webhook-timestamp | Unix-Zeitstempel in Sekunden, zu dem die Zustellung gesendet wurde. |
webhook-signature | Die Signatur selbst: v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))} |
Der signierte Inhalt besteht aus drei Teilen, die mit Punkten verbunden werden – webhook-id, dann webhook-timestamp, dann der Raw Request Body – genau in dieser Reihenfolge. Setze diesen String exakt so zusammen, berechne daraus mit deinem Secret ein HMAC-SHA256, kodiere das Ergebnis in Base64, und du erhältst den Wert für den Vergleich.
Der Header webhook-signature kann mehr als eine durch Leerzeichen getrennte Signatur enthalten, jeweils mit einer Schemaversion versehen (v1,...). Ein Verifizierer akzeptiert die Zustellung, wenn irgendeine Signatur übereinstimmt. Die defensive Art, diesen Header zu parsen, ist daher, die gesamte Liste zu durchlaufen statt nur einen einzelnen Wert auszulesen – genau deshalb iterieren die folgenden Beispiele über jede vorhandene Signatur.
Das Signatur-Secret#
Das Secret wird für deine Organisation erzeugt, wenn du zum ersten Mal einen Job mit einem callbackUrl einreichst. Es beginnt mit dem Präfix whsec_, gefolgt von Base64-kodierten Schlüsselbytes:
whsec_Mf9aQ7n...base64...key...bytesEntferne den Präfix whsec_ und dekodiere den Rest per Base64, um die rohen Schlüsselbytes zu erhalten – dieser dekodierte Wert ist der HMAC-Schlüssel, nicht der String mit Präfix. Gegen den wörtlichen Text whsec_... zu signieren, ist der häufigste Grund dafür, dass eine scheinbar korrekte Implementierung nie übereinstimmt. Also: erst dekodieren.
Behandle das Secret wie einen API-Schlüssel
Das Signatur-Secret ist die Grenze zwischen einem echten Callback und einem gefälschten. Bewahre es ausschließlich serverseitig auf – nicht in der Versionsverwaltung und nicht in Client-Bundles. Jeder, der es besitzt, kann Payloads signieren, die dein Handler akzeptiert. Unter API Keys erfährst du, wie Lingo.dev mit organisationsweiten Zugangsdaten umgeht.
Eine Signatur verifizieren#
Die Verifizierung ist eine einzelne Funktion, die du einmal vor deinen Handler schaltest. Sie erledigt drei Dinge: Sie berechnet die erwartete Signatur aus dem Raw Body neu, vergleicht sie mit dem eingehenden Wert per Prüfung in konstanter Zeit und weist alles zurück, was nicht übereinstimmt, bevor dein Code ausgeführt wird. Dieselbe Funktion schützt jedes asynchrone Ereignis, das Lingo.dev an dich sendet – abgeschlossene localization-Jobs, abgeschlossene provisioning-Jobs, jeden Typ, jede Produktoberfläche.
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");
}Vergleiche mit einer Funktion in konstanter Zeit – crypto.timingSafeEqual, hmac.compare_digest – nicht mit ==. Ein normaler String-Vergleich bricht ab, sobald sich zwei Bytes unterscheiden, und schon dieser Zeitunterschied reicht aus, um die Signatur Byte für Byte preiszugeben. Ein Vergleich in konstanter Zeit schließt diesen Seitenkanal – deshalb verwenden ihn auch beide Beispiele oben.
Warum der Raw Body wichtig ist#
Wichtig ist: Beide Funktionen signieren payload – also den Body genau so, wie er über die Leitung angekommen ist, noch vor jedem JSON-Parsing. Genau dieses Detail bringt ansonsten korrekte Integrationen am häufigsten zu Fall, und deshalb lohnt es sich, es an der entscheidenden Stelle klar auszusprechen:
Die Signatur wird über exakt die Bytes berechnet, die Lingo.dev gesendet hat. Sobald du den Body in ein Objekt parst und anschließend erneut serialisierst, können sich Leerzeichen, Schlüsselreihenfolge oder Zahlenformatierung ändern – und das neu berechnete HMAC passt nicht mehr zu einer Signatur, die über die ursprünglichen Bytes erzeugt wurde. Die Payload hat dieselbe Bedeutung; die Bytes nicht.
Verifiziere gegen den Raw Body, nicht gegen das geparste Objekt
Erfasse den Raw Request Body, bevor dein Framework ihn parst, und übergib genau diese Bytes an den Verifizierer. In Express verwendest du auf der Webhook-Route express.raw({ type: "application/json" }). In FastAPI liest du await request.body(). Parse erst, wenn die Signatur erfolgreich geprüft wurde – erst verifizieren, dann parsen.
Replays abwehren#
Eine gültig signierte Payload, die ein Angreifer abgefangen hat, kann Wort für Wort erneut abgespielt werden – die Signatur bleibt gültig, weil sich zwischen der ersten Zustellung und einer Kopie, die eine Stunde später verschickt wird, nichts daran ändert. Der Header webhook-timestamp begrenzt dieses Zeitfenster: Er hält fest, wann die Zustellung gesendet wurde, sodass dein Verifizierer alles zurückweisen kann, was älter ist als eine von dir gewählte Toleranz. Die Beispiele oben verwenden fünf Minuten.
Eine Zeitstempelprüfung stoppt veraltete Replays: Eine Kopie, die erst nach Ablauf deiner Toleranz erneut gesendet wird, besteht die Frischeprüfung nicht und erreicht deinen Handler nie.
Schnell antworten, später verarbeiten#
Sobald eine Zustellung verifiziert ist, gib sofort 200 zurück und erledige die eigentliche Arbeit – Datenbank-Schreibvorgänge, Downstream-Aufrufe, Cache-Invalidierung – erst danach.
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);
}
);Der Grund dafür ist technisch, nicht stilistisch. Ein langsamer Handler hält die HTTP-Verbindung offen; läuft er lang genug, um ein Timeout auszulösen, gilt die Zustellung als fehlgeschlagen und wird erneut zugestellt – aufwendige Arbeit im Response-Pfad macht aus einem Ereignis schnell mehrere. Bestätige also sofort, gib die Arbeit an eine Queue oder einen Background-Task ab, und aus einem einzelnen Event bleibt ein einzelnes Event. Die Payload-Formate, auf die du innerhalb von handleEvent verzweigst, sind bei den jeweiligen Produkten dokumentiert: localization callbacks und provisioning callbacks.
Retries und Backoff#
Dein Endpunkt wird gelegentlich nicht erreichbar sein – wegen eines Deploys, eines Timeouts oder eines Bad Gateway. Wenn das passiert, verwirft Lingo.dev das Ereignis nicht.
Wenn dein Endpunkt einen Nicht-2xx-Status zurückgibt oder nicht erreichbar ist, wird die Zustellung mit exponentiellem Backoff erneut versucht, beginnend nach 30 Sekunden, bis zu 5 Versuche. Nach dem fünften Versuch wird die Zustellung als fehlgeschlagen markiert und Lingo.dev stellt nicht weiter zu – aber das Ergebnis geht nicht verloren. Es bleibt im Job-Datensatz abrufbar, sodass dich auch eine längere Downtime nur einen Callback kostet, nie das eigentliche Ergebnis. Dieser Job-Datensatz ist dein Sicherheitsnetz: Baue den Webhook für den Regelfall und betrachte den gespeicherten Job als Source of Truth, auf die du jederzeit zurückgreifen kannst. Bei einem Übersetzungsjob kannst du ihn direkt pollen.
