Webhook-endpoints der overlever virkeligheden (og alle dubletterne)
“Den faktura blev vist betalt to gange”
Jeg sad en tirsdag aften med en ven, der havde bygget et lille abonnementssystem. Stripe sendte webhooks, hans API skrev til databasen, og alt så pænt ud i loggen. Indtil en kunde pludselig havde to aktive abonnementer og to fakturaer for den samme betaling.
Fejlen var ikke fancy. Webhooken var bare blevet leveret to gange, og hans kode var skrevet som om verden kun sender events én gang. Det gør den ikke.
I den her tekst bygger vi et webhook-endpoint op, som accepterer virkeligheden: leverandøren lover kun “at-least-once” levering, netværket taber ting, og du får både retrier og dubletter. Målet er et endpoint der:
1) svarer hurtigt med 2xx, 2) er sikkert (signatur), 3) ikke laver dobbelt arbejde, og 4) kan holdes i drift uden at du mister overblikket.
Jeg viser eksempler i Node/Express, men strukturen er den samme i Python, .NET, Java eller noget helt fjerde.
Baseline: få webhooken hurtigt væk fra nettet og ind i dit system
Det første jeg plejer at gøre, er at skille to ting ad i hovedet:
1) HTTP-endpointet der taler med Stripe, Mailgun, Paddle eller hvem du nu bruger.
2) Din egen forretningslogik, der skal opdatere ordrer, brugere, fakturaer osv.
Hvis de to ting er hårdt koblet, bliver du sårbar. En langsom database eller en mærkelig edge case betyder pludselig, at webhooken fejler. Leverandøren tror så, at du ikke har fået eventet, og begynder at retrye.
Et basalt webhook-endpoint i Express ser tit sådan her ud i første version:
app.post("/webhooks/stripe", async (req, res) => {
const event = req.body;
// Gør alt arbejdet direkte
await handleStripeEvent(event);
res.status(200).send("ok");
});
Det virker på localhost. Det knækker i produktion, når:
• handleren nogle gange er langsom
• du får en bug, der smider en exception midt i det hele
• databasen har en dårlig dag
Jeg vil meget hellere have et endpoint der:
1) validerer signaturen,
2) lægger eventet i en kø eller database,
3) svarer 200 hurtigt,
4) lader en worker-process tage sig af det tunge arbejde.
Det ligner noget i den her stil:
// Vigtigt: brug raw body til signatur-validering, mere om det senere
app.post("/webhooks/stripe", rawBodyMiddleware, async (req, res) => {
try {
const signature = req.headers["stripe-signature"]; // header-navn afhænger af leverandør
// 1) Valider signatur og parse event
const event = verifyAndParseStripeEvent(req.rawBody, signature);
// 2) Gem event i database / kø
await enqueueWebhookEvent({
provider: "stripe",
eventId: event.id,
type: event.type,
payload: event,
receivedAt: new Date(),
});
// 3) Svar hurtigt, så leverandøren stopper retry-loopet
res.status(200).send("ok");
} catch (err) {
// Hvis signaturen ikke er gyldig, skal vi IKKE sige 200
console.error("Webhook error", err);
res.status(400).send("invalid");
}
});
Her kan enqueueWebhookEvent være alt fra en simpel tabel i Postgres til en job queue som Redis + Bull, RabbitMQ eller en managed kø. Det vigtigste: HTTP-laget gør så lidt som muligt, men nok til, at du kan stole på det data, der lander i din kø.
Hvis du arbejder med asynkrone patterns i forvejen, har du måske allerede noget lignende liggende til baggrunds-jobs. Det samme mønster passer glimrende til webhooks.
Signaturverifikation: rå body, fælles hemmelighed og et snævert tidsvindue
Webhook security handler for mig om én ting: kun at acceptere events, som rent faktisk kommer fra leverandøren, og som ikke er ældre end nogle få minutter.
De fleste seriøse udbydere gør det nogenlunde på samme måde:
• De deler en secret med dig i dashboardet.
• De signer den rå HTTP-body med f.eks. HMAC-SHA256.
• De sender signaturen (og ofte et timestamp) med i en header.
• Du genskaber signaturen lokalt og sammenligner.
Det vigtige ord her er “rå”. Hvis dit framework når at parse JSON til et objekt og så serialiserer det igen, er du på tynd is. Whitespace, rækkefølge af felter eller encoding kan ændre sig, og så fejler verifikationen.
I Express bruger jeg typisk en separat middleware til raw body på lige præcis webhook-routen. Noget i retning af:
import bodyParser from "body-parser";
function rawBodyMiddleware(req, res, next) {
bodyParser.raw({ type: "application/json" })(req, res, (err) => {
if (err) return next(err);
req.rawBody = req.body; // Buffer
next();
});
}
Så har jeg både req.rawBody (Buffer) til signatur-verifikation og kan selv lave JSON.parse(req.rawBody.toString("utf8")), når signaturen er checket.
Selve verifikationen vil jeg ikke opfinde fra bunden. Følg leverandørens eget eksempel. Stripe har f.eks. en officiel metode i deres SDK:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
function verifyAndParseStripeEvent(rawBody, signatureHeader) {
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const event = stripe.webhooks.constructEvent(
rawBody,
signatureHeader,
endpointSecret
);
return event;
}
Fordelen her er, at Stripe selv håndterer HMAC, timestamps, tolerancer osv. Du behøver ikke læse OWASP-artikler om improper signature verification for at sove nogenlunde roligt.
Hvis du sidder med en mindre leverandør uden officiel klient, er mønsteret stadig det samme: brug veltestede crypto-biblioteker, sammenlign signaturer i konstant tid hvis muligt, og afvis events der er ældre end f.eks. 5 minutter baseret på timestampet i headeren.
Et lille tip: log aldrig hele signatur-headeren og din secret i samme loglinje. Det lyder oplagt, men jeg har set det ske. Det samme gælder selvfølgelig for alle andre secrets, som jeg også har skrevet om i en anden artikel om, at secrets skal være kedelige.
Deduplikering: du får dubletter, spørgsmålet er bare hvornår
Når signaturen er på plads, er næste problem: hvad sker der, når du får den samme event flere gange?
De fleste webhook-systemer lover “at-least-once” levering. Det betyder groft sagt:
• de forsøger at levere eventet indtil de får en 2xx,
• de kan sende det igen, hvis de ikke er sikre på, at du fik det,
• du må aldrig antage, at en event kun kommer én gang.
Det typiske mønster er:
1) Hver event har et unikt id (f.eks. evt_123).
2) Du gemmer det id et sted, når du har behandlet eventet.
3) Hvis du ser det samme id igen, springer du over selve behandlingen.
I database-variant kunne det være en simpel tabel:
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Så kan din worker gøre noget i den her stil:
async function processStripeEvent(job) {
const event = job.data; // hentet fra webhook_events
// 1) Tjek om vi allerede har behandlet eventet
const existing = await db.query(
"SELECT processed_at FROM webhook_events WHERE id = $1",
[event.id]
);
if (existing.rowCount > 0 && existing.rows[0].processed_at) {
// Idempotency: vi har allerede gjort arbejdet
return;
}
// 2) Start en transaktion, så vi både kan lave domænearbejde og markere eventet som behandlet
await db.tx(async (tx) => {
await handleStripeEventInDomain(tx, event);
await tx.query(
"UPDATE webhook_events SET processed_at = now(), status = $2 WHERE id = $1",
[event.id, "processed"]
);
});
}
Det her er kun rigtigt sikkert, hvis databasen sikrer, at id er unikt. Derfor bruger jeg ofte INSERT med konflikt-håndtering allerede i HTTP-laget:
async function enqueueWebhookEvent(data) {
await db.query(
`INSERT INTO webhook_events (id, provider, type, payload, status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO NOTHING`,
[data.eventId, data.provider, data.type, data.payload, "pending"]
);
}
Hvis samme event kommer igen, gør ON CONFLICT DO NOTHING arbejdet for dig. Du kan vælge at logge det, men du undgår at spamme din worker med dubletter.
Nogle systemer kræver ikke, at du gemmer events evigt. Det kan være fint med en TTL (time to live) på f.eks. 30 dage. I Postgres kan du løse det med en periodisk job, der sletter gamle rækker. Hvis du bruger Redis som dedupe-store, er det endnu mere oplagt at sætte en udløbstid direkte på nøglerne.
Det vigtigste er, at du tænker på hele kæden som idempotent: selv hvis handleren kaldes to gange med samme event, må din domænelogik ikke oprette to abonnementer, to ordrelinjer osv. Det kan betyde unikke constraints på domænetabellerne også, ikke kun på webhook_events.
Retries og sikker afvikling: at-least-once uden at eksplodere
Når du begynder at lægge webhook-events i en kø og have en worker, skal du tage stilling til retrier inde i din infrastruktur. Leverandøren har deres egen retry-logik baseret på dine HTTP-svar, men din worker kan også møde midlertidige problemer:
• databaseforbindelsen ryger et øjeblik,
• en ekstern API er nede i 10 sekunder,
• du deployer midt i et job.
De fleste job-køer har indbygget retries med exponential backoff. I BullMQ (Redis-baseret) kunne et job til webhook-events se sådan her ud:
webhookQueue.add(event.id, event, {
attempts: 5,
backoff: {
type: "exponential",
delay: 1000, // 1 sek. base-delay
},
});
Det betyder i praksis, at et midlertidigt problem ikke sender eventet i skraldespanden med det samme. Du får f.eks. 1, 2, 4, 8 og 16 sekunders ventetid mellem forsøgene.
En ting jeg selv har lært på den hårde måde: retrier er kun hjælpsomme, hvis din kode faktisk kan køre sikkert flere gange. Hvis handleStripeEventInDomain både laver network calls og skriver i databasen uden klar transaktion, kan du let ende med halvfærdigt arbejde, som så bliver gentaget på næste retry.
Jeg prøver derfor at holde handlerne så tæt på det her mønster som muligt:
async function handleStripeEventInDomain(tx, event) {
switch (event.type) {
case "invoice.payment_succeeded":
await markInvoicePaid(tx, event.data.object);
break;
case "customer.subscription.deleted":
await cancelSubscription(tx, event.data.object);
break;
default:
// Log ukendte typer, men fejler ikke webhooken
await tx.query(
"INSERT INTO webhook_unknown_events (id, type, payload) VALUES ($1, $2, $3)",
[event.id, event.type, event]
);
}
}
Her går al database-arbejde via en transaktion, så enten lykkes alt, eller også ruller det tilbage. Eksterne HTTP-kald prøver jeg at lægge før eller efter transaktionen, alt efter hvad der giver mest mening, og med en eller anden form for timeout, så de ikke hænger i lang tid.
Hvis du vil se et beslægtet mønster i praksis, har jeg skrevet om async-fælder før i artiklen om, at async/await ikke er magi. Mange af de samme ting går igen her: timeouts, tydelig fejlhåndtering og bevidste retrier.
Fejlhåndtering, dead letters og replays uden panik
Selv med retries vil du få webhook-events, der ikke kan behandles. Det kan være:
• dine domænedata er i en mærkelig tilstand,
• du har en ren bug i handleren,
• leverandøren har ændret formatet en smule.
Hvis du bare lader de jobs fejle i køen, mister du kontrol. Jeg vil gerne have et sted, hvor de lander bevidst. Typisk en “dead letter queue” eller en dedikeret tabel.
I database-versionen kunne du bruge status-feltet i webhook_events:
async function processStripeEvent(job) {
const event = job.data;
try {
await db.tx(async (tx) => {
await handleStripeEventInDomain(tx, event);
await tx.query(
"UPDATE webhook_events SET processed_at = now(), status = $2 WHERE id = $1",
[event.id, "processed"]
);
});
} catch (err) {
console.error("Failed to process webhook", { id: event.id, err });
await db.query(
"UPDATE webhook_events SET status = $2 WHERE id = $1",
[event.id, "failed"]
);
throw err; // lader køen stå for retries/dead-letter
}
}
Hvis din kø understøtter dead letters (mange gør), kan den flytte jobs, der har brugt alle forsøg, over i en separat kø eller markere dem som “failed”. Så er de nemme at finde igen.
Herfra vil jeg næsten altid have et lille internt værktøj (det kan være en simpel admin-side), hvor jeg kan:
• se alle fejlede webhook-events med type og fejlbesked,
• prøve at re-run en enkelt event manuelt,
• evt. ændre lidt i domænedata og så re-run.
Det lyder luksusagtigt, men det behøver ikke være smukt. En lille tabeloversigt og en “replay”-knap kan spare dig timer, når du en søndag får besked om, at 13 kunder ikke har fået opdateret deres abonnement korrekt.
Observability: du skal kunne følge én event hele vejen
Det sidste ben er synlighed. Når en webhook går galt, er det altid på et dårligt tidspunkt. Brugeren har typisk lige betalt, tilmeldt sig eller gjort noget, de forventer sker med det samme.
Jeg plejer at tænke i et “correlation id” per event. Webhook-event-idet er faktisk glimrende til det. Idéen er:
• HTTP-endpointet logger “vi modtog event X”.
• Køen logger “vi enqueuer event X”.
• Workeren logger “vi behandler event X” og evt. “event X fejlede”.
• Domænelogik logger “subscription Y opdateret pga. event X”.
Så kan du i din log-løsning filtrere på f.eks. evt_123 og se hele rejsen gennem systemet.
I kode ligner det bare en ekstra parameter i dine loglinjer:
function logWithEventId(eventId, message, extra = {}) {
console.log({ eventId, message, ...extra });
}
// Eksempelbrug
logWithEventId(event.id, "Received webhook", { type: event.type });
Hvis du bruger et rigtigt log-system (Datadog, ELK, CloudWatch osv.), kan du gemme eventId som et separat felt og lave søgninger på det.
Jeg logger også meget gerne den første og sidste status for en event: fra “received” til “processed” eller “failed”. Så kan jeg hurtigt lave et lille overblik over:
• hvor mange events vi får,
• hvor mange der fejler,
• hvor lang tid de i gennemsnit er om at gå fra modtaget til behandlet.
Det lyder som noget, man først behøver, når man er en stor virksomhed, men min erfaring er faktisk det modsatte. Små projekter har sjældent tid til at rode med mystiske bugs, så her er et par gode logs og simple metrics næsten vigtigere.
En lille webhook-tjekliste til produktion
Jeg slutter tit sådan et projekt af med en kort tjekliste. Ikke for pænheds skyld, men fordi jeg ved, at jeg selv glemmer ting, når jeg vender tilbage 6 måneder senere.
1. Endpoint og levering
• Svarer dit endpoint med 2xx hurtigt, også når din database er lidt langsom?
• Har du skilt HTTP-laget fra domænelogik, f.eks. via en kø eller tabel?
• Har du tests der simulerer leverandørens HTTP-kald (helst med deres docs som reference)?
2. Sikkerhed og signatur
• Bruger du rå body til signaturverifikation?
• Bruger du leverandørens officielle bibliotek, hvor det findes?
• Afviser du events, der er for gamle, baseret på timestamp i headeren?
• Ligger din webhook-secret i et sikkert miljø, ikke hardcodet?
3. Deduplikering og idempotens
• Gemmer du event-id et sted med unik constraint?
• Er din handler skrevet, så den kan kaldes flere gange uden at lave dobbelt domænearbejde?
• Har du tænkt over TTL/oprensning af gamle events, så tabellen ikke vokser uendeligt?
4. Retries og fejlhåndtering
• Har din kø fornuftige retry-indstillinger med exponential backoff?
• Har du en måde at markere events som “failed” efter alle forsøg?
• Har du en eller anden form for dead letter-oversigt?
5. Observability og replays
• Logger du event-id gennem hele flowet?
• Kan du hurtigt finde alle logs for en given event i dit log-system?
• Har du et lille værktøj eller script til at re-run en event manuelt?
Hvis du tjekker ja til det meste af det her, står du langt bedre end de fleste første webhook-implementeringer, jeg har set. Mange stopper ved “Express-route + console.log” og opdager først problemerne, når brugerne begynder at mangle data.
Hvis du vil bygge videre på det her, er næste naturlige skridt at kigge mere systematisk på din fejllogning generelt. Jeg har skrevet en tekst om at stoppe med at debugge i blinde, som passer ret godt sammen med den måde, vi lige har sat webhook-flowet op på.
Det vigtigste råd er enkelt: behandle hver eneste webhook som noget, der kan komme igen, fejle undervejs og kræve et replay, og design dit endpoint som om det er helt normalt.









1 kommentar