A SaaS-od a külvilággal beszél: Stripe-eseményeket fogad, partnereknek küld értesítést, harmadik fél API-jait hívja. Ez a modul azt mutatja meg, hogyan csináld ezt Workersön megbízhatóan — aláírás-ellenőrzéssel, idempotenciával, retry-jal és sorokkal.
A leggyakoribb hiba, hogy a webhook-végpont mindent egyben csinál: ellenőriz, feldolgoz, DB-t ír, emailt küld — és a küldő fél közben vár. Ha bármelyik lépés lassú vagy hibázik, a partner újraküldi az eseményt, és kezdődik a duplikációs káosz. A helyes szerkezet kétfázisú:
Ez a rész az, ahol Node-ból érkezve el lehet csúszni. Az aláírás a nyers, bájtszintű törzs felett készül — ha a keretrendszered már JSON-ná alakította és újra sorosítod, az ellenőrzés hibázni fog (más lesz a szóköz, a kulcssorrend). Workersön szerencsére egyszerű a helyzet: a request.text() pontosan azt adja, ami érkezett — csak arra kell figyelni, hogy előbb a szöveget vedd ki, és abból parse-olj, ne fordítva.
export default defineEventHandler(async (event) => {
const { env, context } = event.context.cloudflare;
// 1) NYERS törzs — ebből lesz az aláírás-ellenőrzés alapja
const raw = await readRawBody(event, "utf8");
const sig = getHeader(event, "stripe-signature");
// 2) HMAC ellenőrzés Web Cryptóval (nincs natív modul, 2. modul)
if (!(await verifyStripeSignature(raw, sig, env.STRIPE_WEBHOOK_SECRET))) {
throw createError({ statusCode: 400, message: "invalid signature" });
}
const evt = JSON.parse(raw); // csak az ellenőrzés UTÁN parse-olunk
// 3) dedup: ugyanaz az esemény kétszer is megérkezhet
const inserted = await env.DB.prepare(
"INSERT OR IGNORE INTO webhook_events (id, provider, received_at) VALUES (?, 'stripe', ?)"
).bind(evt.id, new Date().toISOString()).run();
if (inserted.meta.changes === 0) return { ok: true, duplicate: true };
// 4) sorba tesszük — a valódi munka a consumerben (7. modul)
await env.JOBS.send({ type: "stripe-event", eventId: evt.id, payload: evt });
return { ok: true }; // gyors 200 → a partner nem küldi újra
});
server/utils/stripe-sig.ts — az ellenőrzés Web Cryptóval
export async function verifyStripeSignature(raw, header, secret) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const t = parts.t, v1 = parts.v1;
// replay-védelem: a túl régi aláírást ne fogadjuk el
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = await crypto.subtle.sign(
"HMAC", key, new TextEncoder().encode(`${t}.${raw}`));
return timingSafeEqual(toHex(mac), v1); // időállandó összehasonlítás!
}
waitUntil-t használd a feldolgozásra?Kísértő megoldás: gyors 200, a munka pedig ctx.waitUntil()-ben. Ez kis dolgokra (log, metrika) rendben van, de webhookra nem: ha a háttérfeldolgozás hibázik, nincs retry — a partner már 200-at kapott, nem küldi újra, az esemény pedig elveszett. A Queue épp ezt a garanciát adja: retry, késleltetés, DLQ (7. modul).
Két oka is van, hogy ugyanaz az esemény többször feldolgozásra kerüljön: a partner újraküldi (mert nem kapott időben választ), és a Queue is legalább-egyszeri kézbesítésű (7. modul). Ezért a feldolgozásnak idempotensnek kell lennie — háromszintű védelem:
| Szint | Eszköz | Mit fog meg |
|---|---|---|
| 1. Beérkezés | INSERT OR IGNORE az esemény-azonosítóval (fent) | a partner ismétlését |
| 2. Feldolgozás | állapot-ellenőrzés: „ez a számla már ki van fizetve?" | a queue-duplikátumot |
| 3. Kimenő hatás | idempotencia-kulcs a partner API-hívásban (a legtöbb fizetési API támogatja) | a dupla terhelést/emailt |
async queue(batch, env) {
for (const msg of batch.messages) {
try {
const { eventId, payload } = msg.body;
// 2. szint: az állapot dönt, nem az esemény érkezése
const already = await env.DB.prepare(
"SELECT processed_at FROM webhook_events WHERE id = ?"
).bind(eventId).first();
if (already?.processed_at) { msg.ack(); continue; }
await handleStripeEvent(env, payload);
await env.DB.prepare(
"UPDATE webhook_events SET processed_at = ? WHERE id = ?"
).bind(new Date().toISOString(), eventId).run();
msg.ack();
} catch (err) {
msg.retry({ delaySeconds: 60 }); // hibánál újra, DLQ-ig (7. modul)
}
}
}
WHERE updated_at < ?); ② vagy — ami sokszor egyszerűbb és biztosabb — a webhookot csak jelzésnek tekintsd, és az esemény hatására kérdezd le a partner API-jából a friss állapotot. A Stripe-nál például: ne az eseményben lévő adatot írd be, hanem kérdezd le az előfizetés aktuális állapotát, és azt mentsd.Ha az ügyfeleid webhookokat kapnak tőled (ez B2B SaaS-nál elvárás), a felelősség megfordul. Amit a te oldaladról meg kell oldani:
| Követelmény | Megoldás |
|---|---|
| Megbízható kézbesítés | Queue: az esemény bekerül, a consumer küld; hiba → retry exponenciális visszalépéssel, végül DLQ (7. modul) |
| Aláírás (hogy az ügyfél ellenőrizni tudja) | HMAC-SHA256 a nyers törzsön, időbélyeggel, ügyfelenkénti titokkal — ugyanaz a séma, amit a Stripe-tól kaptál |
| Lassú/halott végpont | timeout (AbortSignal.timeout), és sok hiba után az endpoint automatikus letiltása + értesítés |
| Sorrend és duplikátum | eseményenként azonosító + időbélyeg a törzsben — hogy az ügyfél is tudjon dedupálni |
| Újraküldés | admin-felület: „küldd újra" gomb, és az utolsó N kézbesítés naplója |
| Tenant-izoláció | egy ügyfél lassú endpointja ne fogja meg a többiek kézbesítését — külön queue vagy megfelelő max_concurrency |
async function deliver(env, endpoint, event) {
const body = JSON.stringify(event);
const ts = Math.floor(Date.now() / 1000);
const sig = await hmacHex(endpoint.secret, `${ts}.${body}`);
const res = await fetch(endpoint.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-MyApp-Signature": `t=${ts},v1=${sig}`,
"X-MyApp-Event-Id": event.id,
"X-MyApp-Delivery": crypto.randomUUID(),
},
body,
signal: AbortSignal.timeout(10_000), // ne lógjon örökké
});
if (!res.ok) throw new Error(`delivery failed: ${res.status}`);
}
Amikor te hívsz külső API-t (fizetés, számlázó, CRM), a Workers-környezet néhány szabálya érvényes:
Promise.all-nál batchelj.AbortSignal.timeout): egy lassú partner ne tartsa fogva a kérésedet.Retry-After fejlécét vedd figyelembe.fetch() + cf.cacheTtl vagy KV (18. modul) — árfolyam, ország-lista, adószám-ellenőrzés.fetch ugyanaz a webstandard API, amit a böngészőből ismersz — nincs axios-konfiguráció, proxy-beállítás, ügynök-kezelés. A partner-SDK-k viszont változó minőségben működnek: ha egy SDK Node-specifikus (natív crypto, http modul, stream-ek), gyakran egyszerűbb a REST API-t közvetlenül fetch-csel hívni, mint az SDK-t erőltetni. Egy vékony saját kliens ráadásul kisebb bundle-t jelent (2. modul).wrangler dev --tunnel — a lokális dev szervered kap publikus URL-t, amit beírhatsz a Stripe/GitHub webhook-beállításába (11. modul). Nincs szükség ngrokra.Az endpoint csak hitelesít, dedupál és sorba tesz — így néhány ms alatt 200-at ad, és a partner nem küldi újra. A tényleges (lassú, hibázható) feldolgozás a queue consumerben fut, ahol van retry, késleltetés és DLQ. Így egy hibás feldolgozás nem jelent elveszett eseményt.
Az aláírás a bájtszintű törzs felett készül; ha JSON-ná alakítod és újra sorosítod, más bájtsorozatot kapsz (szóközök, kulcssorrend), és az ellenőrzés hibázik. Helyes sorrend: nyers szöveg kiolvasása → aláírás-ellenőrzés (időállandó összehasonlítással, replay-ablakkal) → csak utána JSON-parse.
ctx.waitUntil()-be tenni?
Mert nincs retry: a partner már megkapta a 200-at, tehát nem küldi újra, és ha a háttérmunka hibázik, az esemény véglegesen elveszik. A waitUntil apró mellékhatásokra (log, metrika) való, nem üzleti feldolgozásra.
① Időbélyeg-alapú védelem: a régebbi eseményt eldobod (WHERE updated_at < ?). ② Vagy — általában egyszerűbb — a webhookot csak jelzésnek tekinted, és az esemény hatására lekérdezed a partner API-jából a friss állapotot, azt mented el.
Megbízható kézbesítés (queue + retry + DLQ), aláírás ügyfelenkénti titokkal és időbélyeggel, timeout és halott endpointok automatikus letiltása, esemény-azonosító a dedupáláshoz, újraküldés admin-felületről, és tenant-izoláció, hogy egy lassú ügyfél ne fogja meg a többiek kézbesítését.