Když asynchronní úloha skončí, Lingo.dev vás nenutí ji neustále dotazovat. Zavolá zpět: POST na HTTPS endpoint, který jste zaregistrovali jako svůj callbackUrl. To je ta výhoda. Zároveň je to ale i riziko – veřejná URL přijme cokoli, co na ni internet pošle, a kdokoli, kdo se tu vaši dozví, může na váš handler POST podvrženou událost „job completed“.
Pravidlo pro každý callback je stejné: nejdřív ověřit, až potom důvěřovat. Každé doručení nese podpis vypočtený ze secretu, který znáte jen vy a Lingo.dev. Přepočítejte ho na své straně, porovnejte ho v konstantním čase a podvržený payload se nikdy nedostane do vaší aplikační logiky. Tato stránka je jediné místo, kde tenhle mechanismus žije. Callbacky pro lokalizaci i provisioning ho používají beze změny – jejich vlastní stránky řeší konkrétní tvary payloadů a pro ověření odkazují sem.
Na této stránce
- Tři hlavičky
- Secret pro podepisování
- Jak ověřit podpis
- Proč záleží na raw body
- Jak odmítat replaye
- Odpovězte rychle, zpracujte později
- Opakování a backoff
Tři hlavičky#
Lingo.dev se řídí specifikací Standard Webhooks, otevřeným schématem, které implementuje více poskytovatelů. Ověřujete tedy podle zveřejněného kontraktu, ne podle nějaké proprietární zvláštnosti jednoho vendora. Každé doručení obsahuje tři hlavičky:
| Hlavička | Popis |
|---|---|
webhook-id | Jedinečný identifikátor doručení. |
webhook-timestamp | Unixový čas v sekundách, kdy bylo doručení odesláno. |
webhook-signature | Samotný podpis: v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))} |
Podepisovaný obsah tvoří tři části spojené tečkami – webhook-id, pak webhook-timestamp a nakonec raw body requestu – přesně v tomto pořadí. Sestavte tento řetězec, spusťte nad ním HMAC-SHA256 se svým secretem, výsledek zakódujte do base64 a získáte hodnotu, kterou pak porovnáte.
Hlavička webhook-signature může nést víc podpisů oddělených mezerami, z nichž každý je označený verzí schématu (v1,...). Verifikátor přijme doručení, pokud se shoduje kterýkoli podpis. Defenzivní způsob parsování této hlavičky je projít celý seznam místo čtení jediné hodnoty, proto ukázky níže iterují přes všechny přítomné podpisy.
Secret pro podepisování#
Secret se pro vaši organizaci vygeneruje ve chvíli, kdy poprvé odešlete úlohu s callbackUrl. Má prefix whsec_, za kterým následují bajty klíče zakódované v base64:
whsec_Mf9aQ7n...base64...key...bytesOdstraňte prefix whsec_ a zbytek dekódujte z base64, abyste získali surové bajty klíče – právě tahle dekódovaná hodnota je klíč pro HMAC, ne řetězec s prefixem. Podepisování proti doslovnému textu whsec_... je nejčastější důvod, proč se implementace, která vypadá správně, nikdy neshoduje, takže nejdřív dekódujte.
Se secretem zacházejte jako s API klíčem
Secret pro podepisování odlišuje skutečný callback od podvrženého. Držte ho na serveru, mimo source control a mimo jakýkoli klientský bundle. Kdokoli ho získá, může podepisovat payloady, které váš handler přijme. Jak Lingo.dev pracuje s přihlašovacími údaji vázanými na organizaci, popisuje API Keys.
Jak ověřit podpis#
Ověření je jedna funkce, kterou jednou nasadíte před svůj handler. Dělá tři věci: z raw body znovu spočítá očekávaný podpis, porovná ho s tím, co přišlo, pomocí kontroly v konstantním čase, a všechno, co nesedí, odmítne ještě před spuštěním vašeho kódu. Stejná funkce chrání každou asynchronní událost, kterou vám Lingo.dev posílá – dokončení lokalizace, dokončení provisioningu, každý typ i každý produktový povrch.
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");
}Porovnávejte pomocí funkce v konstantním čase – crypto.timingSafeEqual, hmac.compare_digest – ne pomocí ==. Obyčejné porovnání řetězců skončí hned, jakmile se dva bajty liší, a tenhle časový rozdíl stačí k tomu, aby podpis unikal po bajtech. Porovnání v konstantním čase tenhle side channel uzavírá, proto ho používají obě ukázky výše.
Proč záleží na raw body#
Všimněte si, že obě funkce podepisují payload – tělo přesně v podobě, v jaké dorazilo po síti, ještě před jakýmkoli parsováním JSON. Je to detail, na kterém nejčastěji ztroskotá jinak správná integrace, a stojí za to ho pojmenovat přesně tam, kde bolí:
Podpis se počítá nad přesnými bajty, které Lingo.dev odeslal. Ve chvíli, kdy tělo naparsujete do objektu a znovu serializujete, můžete změnit whitespace, pořadí klíčů nebo formát čísel – a nově spočítaný HMAC už se nebude shodovat s podpisem vytvořeným nad původními bajty. Význam payloadu je stejný, bajty ne.
Ověřujte proti raw body, ne proti naparsovanému objektu
Zachyťte raw body requestu dřív, než ho váš framework naparsuje, a tyto bajty předejte verifikátoru. V Expressu použijte na webhook route express.raw({ type: "application/json" }). Ve FastAPI čtěte await request.body(). Parsujte až poté, co podpis projde kontrolou – nejdřív ověření, potom parsování.
Jak odmítat replaye#
Platný podepsaný payload, který útočník zachytí, lze doslova přehrát znovu – podpis zůstává platný, protože se mezi prvním doručením a kopií odeslanou o hodinu později nic nemění. Hlavička webhook-timestamp tohle okno omezuje: zaznamenává, kdy bylo doručení odesláno, takže váš verifikátor může odmítnout všechno starší, než je tolerance, kterou si zvolíte. Ukázky výše používají pět minut.
Kontrola timestampu zastaví zastaralý replay: kopie zachycená a znovu odeslaná později, než dovoluje vaše tolerance, neprojde kontrolou čerstvosti a k vašemu handleru se vůbec nedostane.
Odpovězte rychle, zpracujte později#
Jakmile je doručení ověřené, okamžitě vraťte 200 a skutečnou práci – zápisy do databáze, downstream volání, invalidaci cache – udělejte až potom, co odešlete odpověď.
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);
}
);Důvod je mechanický, ne stylistický. Pomalý handler drží HTTP spojení otevřené; pokud běží tak dlouho, že vyprší timeout, doručení se považuje za neúspěšné a bude se opakovat – takže těžká práce v response path promění jednu událost v několik. Potvrďte přijetí rychle, předejte práci do fronty nebo na background task a z jedné události zůstane jedna událost. Tvary payloadů, podle kterých přepínáte uvnitř handleEvent, patří ke konkrétním produktům: callbacky lokalizace a callbacky provisioningu.
Opakování a backoff#
Váš endpoint bude občas nedostupný – kvůli deployi, timeoutu nebo chybné bráně. Když se to stane, Lingo.dev událost nezahodí.
Pokud váš endpoint vrátí jiný status než 2xx nebo je nedostupný, doručení se opakuje s exponenciálním backoffem začínajícím na 30 sekundách, maximálně 5 pokusů. Po pátém pokusu je doručení označeno jako neúspěšné a Lingo.dev už to dál nezkouší – výsledek se ale neztratí. Zůstává dostupný v záznamu úlohy, takže i delší výpadek vás stojí callback, nikdy ne samotný výsledek. Právě záznam úlohy je vaše pojistka: webhook postavte pro běžný případ a uloženou úlohu berte jako zdroj pravdy, ke kterému se můžete vždy vrátit. U překladové úlohy ji můžete dotazovat přímo.
