Leer betrouwbare webhook-integraties met ondertekening, idempotentiesleutels, replay-bescherming en een snel debugproces voor klantmeldingen over fouten.

Als iemand zegt “webhooks werken niet”, bedoelen ze meestal één van drie dingen: events kwamen nooit aan, events kwamen twee keer aan, of events kwamen in een verwarrende volgorde. Vanuit hun perspectief miste het systeem iets. Vanuit jouw perspectief heeft de provider het wel verzonden, maar jouw endpoint accepteerde het niet, verwerkte het niet of registreerde het niet zoals je verwachtte.
Webhooks leven op het publieke internet. Requests worden vertraagd, opnieuw geprobeerd en soms uit volgorde geleverd. De meeste providers retryen agressief bij timeouts of niet-2xx responses. Dat verandert een klein haperinkje (een trage database, een deploy, een korte outage) in duplicaten en racecondities.
Slechte logs doen dit willekeurig lijken. Als je niet kunt bewijzen of een request authentiek was, kun je er niet veilig op handelen. Als je een klantmelding niet aan een specifieke bezorgpoging kunt koppelen, ga je raden.
De meeste echte fouten vallen in een paar categorieën:
Het praktische doel is simpel: accepteer echte events één keer, weiger fakes en laat een duidelijk spoor achter zodat je een klantmelding in minuten kunt debuggen.
Een webhook is gewoon een HTTP-request dat een provider naar een endpoint stuurt dat jij blootstelt. Je haalt het niet op zoals bij een API-call. De verzender pusht het wanneer iets gebeurt, en jouw taak is het te ontvangen, snel te antwoorden en veilig te verwerken.
Een typische levering bevat een request-body (vaak JSON) plus headers die helpen valideren en bijhouden wat je ontving. Veel providers voegen een timestamp, een event-type (zoals invoice.paid) en een unieke event-ID toe die je kunt opslaan om duplicaten te detecteren.
Wat teams verrast: levering is vrijwel nooit “exact één keer”. De meeste providers mikken op “at least once”, wat betekent dat hetzelfde event meerdere keren kan aankomen, soms minuten of uren uit elkaar.
Retries gebeuren om saaie redenen: je server is traag of timed out, je geeft een 500 terug, hun netwerk ziet je 200 niet, of je endpoint is tijdelijk onbeschikbaar tijdens deploys of trafficpieken.
Een timeout is vooral lastig. Je server kan het request ontvangen en zelfs de verwerking afronden, maar het antwoord bereikt de verzender niet op tijd. Vanuit het perspectief van de provider is het mislukt, dus ze proberen opnieuw. Zonder bescherming verwerk je hetzelfde event twee keer.
Een goed denkkader is om het HTTP-request te zien als een “bezorgpoging”, niet als “het event”. Het event wordt geïdentificeerd door zijn ID. Je verwerking moet op die ID gebaseerd zijn, niet op hoe vaak de provider je oproept.
Webhook-ondertekening is hoe de verzender bewijst dat een request echt van hen kwam en niet onderweg is gewijzigd. Zonder ondertekening kan iedereen die je webhook-URL raadt een nep "betaling geslaagd" of "gebruiker geüpgraded" event posten. Nog erger: een echt event kan onderweg aangepast worden (bedrag, klant-ID, event-type) en toch geldig lijken voor je app.
Het meest voorkomende patroon is HMAC met een gedeeld geheim. Beide kanten kennen dezelfde geheime waarde. De verzender neemt de exacte webhook-payload (meestal de ruwe request-body), berekent een HMAC met dat geheim en stuurt de handtekening mee naast de payload. Jouw taak is dezelfde HMAC over exact dezelfde bytes opnieuw te berekenen en te controleren of de handtekeningen overeenkomen.
Signature-gegevens worden meestal in een HTTP-header geplaatst. Sommige providers voegen daar ook een timestamp aan toe zodat je replay-bescherming kunt toevoegen. Minder vaak wordt de handtekening in de JSON-body ingebed, wat riskanter is omdat parsers of re-serialisatie de formattering kunnen veranderen en verificatie kunnen breken.
Bij het vergelijken van handtekeningen, gebruik geen normale stringvergelijking. Basisvergelijkingen kunnen timingverschillen lekken die een aanvaller helpen de juiste handtekening te raden over veel pogingen. Gebruik een constant-time vergelijkingsfunctie uit je taal- of crypto-bibliotheek en weiger bij iedere mismatch.
Als een klant meldt “jullie systeem accepteerde een event dat wij niet hebben verzonden”, begin dan met de signaturechecks. Als signature-verificatie faalt, heb je waarschijnlijk een geheim dat niet klopt of hasht je de verkeerde bytes (bijvoorbeeld geparseerde JSON in plaats van de ruwe body). Als het slaagt, kun je de afzenderidentiteit vertrouwen en doorgaan met deduping, ordering en retries.
Betrouwbare webhook-afhandeling begint met één saaie regel: verifieer wat je ontving, niet wat je graag zou willen ontvangen.
Vang de ruwe request-body exact zoals die binnenkwam. Parseer en re-serialiseer JSON niet voordat je de handtekening controleert. Kleine verschillen (whitespace, sleutelvolgorde, unicode) veranderen de bytes en kunnen geldige handtekeningen ongeldig doen lijken.
Bouw daarna de exacte payload op die je provider verwacht dat je ondertekent. Veel systemen ondertekenen een string zoals timestamp + "." + raw_body. De timestamp is geen versiering. Hij staat er zodat je oude requests kunt weigeren.
Bereken de HMAC met het gedeelde geheim en de vereiste hash (vaak SHA-256). Bewaar het geheim in een veilige opslag en behandel het als een wachtwoord.
Vergelijk tenslotte je berekende waarde met de signature-header met een constant-time vergelijking. Als het niet matcht, geef een 4xx terug en stop. Accepteer niet “toch maar”.
Een korte implementatie-checklist:
Een klant meldt “webhooks stopten met werken” nadat je JSON-parseermiddleware toevoegde. Je ziet signature-mismatches, vooral bij grotere payloads. De oplossing is meestal verifiëren met de ruwe body voordat je parseert, en loggen welke stap faalde (bijv. “signature header ontbreekt” vs “timestamp buiten toegestane window”). Dat ene detail verkort debugging vaak van uren naar minuten.
Providers retryen omdat bezorging niet gegarandeerd is. Je server kan een minuut down zijn, een netwerkhop kan het request droppen, of je handler kan timed out raken. De provider gaat ervan uit “misschien is het gelukt” en stuurt hetzelfde event opnieuw.
Een idempotentiesleutel is het ontvangstbewijs dat je gebruikt om een event te herkennen dat je al hebt verwerkt. Het is geen beveiligingsfeature en geen vervanging voor signature-verificatie. Het lost ook geen racecondities op tenzij je het veilig opslaat en controleert onder gelijktijdigheid.
De keuze van de sleutel hangt af van wat de provider je geeft. Geef de voorkeur aan een waarde die stabiel blijft over retries:
Wanneer je een webhook ontvangt, schrijf je de sleutel eerst naar opslag met een uniekheidsregel zodat slechts één request “wint”. Verwerk daarna het event. Als je dezelfde sleutel weer ziet, geef dan success terug zonder het werk nogmaals te doen.
Houd de opgeslagen “ontvangst” klein maar bruikbaar: de sleutel, verwerkingsstatus (ontvangen/verwerkt/faal), timestamps (eerste keer gezien/laatst gezien) en een minimale samenvatting (event-type en gerelateerd object-ID). Veel teams bewaren sleutels 7 tot 30 dagen zodat late retries en de meeste klantmeldingen gedekt zijn.
Replay-bescherming voorkomt een simpel maar vervelend probleem: iemand legt een echt webhook-request vast (met een geldige handtekening) en stuurt het later opnieuw. Als je handler elke levering als nieuw behandelt, kan die replay duplicaten veroorzaken: terugbetalingen, uitnodigingen of statuswijzigingen.
Een veelgebruikte aanpak is niet alleen de payload te ondertekenen maar ook een timestamp. Je webhook bevat headers zoals X-Signature en X-Timestamp. Bij ontvangst verifieer je de handtekening en controleer je ook of de timestamp recent genoeg is binnen een kort venster.
Klokdrift is meestal de reden voor onterechte afwijzingen. Jouw servers en de servers van de verzender kunnen een minuut of twee verschillen, en netwerken kunnen levering vertragen. Houd een marge en log waarom je een request weigerde.
Praktische regels die goed werken:
abs(now - timestamp) <= window (bijvoorbeeld 5 minuten plus een kleine marge).Als timestamps ontbreken, kun je geen echte replay-bescherming op basis van tijd doen. Leun dan zwaarder op idempotentie (sla en weiger duplicaat event IDs) en overweeg om timestamps verplicht te stellen in de volgende webhookversie.
Secret-rotatie is ook belangrijk. Als je signing-secrets roteert, houd dan meerdere actieve secrets tijdelijk overlappen. Verifieer eerst met het nieuwste geheim en val terug op oudere. Dit voorkomt klantbreuk tijdens een rollout. Als jouw team endpoints snel uitrolt (bijv. code genereren met Koder.ai en snapshots/rollback gebruiken bij deploys), helpt die overlap omdat oudere versies kortstondig nog live kunnen zijn.
Retries zijn normaal. Ga ervan uit dat elke levering gedupliceerd, vertraagd of uit volgorde kan zijn. Je handler moet zich hetzelfde gedragen of hij een event één of vijf keer ziet.
Houd het requestpad kort. Doe alleen wat nodig is om het event te accepteren en verplaats zwaarder werk naar een achtergrondjob.
Een eenvoudig patroon dat in productie standhoudt:
Geef 2xx alleen terug nadat je de handtekening hebt geverifieerd en het event hebt vastgelegd (of in de queue hebt gezet). Als je 200 teruggeeft voordat je iets opslaat, kun je events verliezen bij een crash. Als je zwaar werk doet voordat je antwoordt, veroorzaken timeouts retries en kun je bijwerkingen herhalen.
Trage downstream-systemen zijn de belangrijkste reden dat retries pijnlijk worden. Als je e-mailprovider, CRM of database traag is, laat dan een queue de vertraging opvangen. De worker kan met backoff opnieuw proberen en je kunt alerten op vastgelopen jobs zonder de verzender te blokkeren.
Out-of-order events komen ook voor. Bijvoorbeeld kan een subscription.updated aankomen voordat subscription.created. Bouw tolerantie door de huidige staat te checken voordat je wijzigingen toepast, upserts toe te staan en “niet gevonden” te behandelen als reden om later opnieuw te proberen (wanneer dat zinvol is) in plaats van als permanente fout.
Veel “willekeurige” webhook-problemen zijn zelf toegebracht. Ze lijken op netwerkgedrag, maar herhalen in patronen, meestal na een deploy, secret-rotatie of een kleine wijziging in parsing.
De meest voorkomende signature-bug is het hashen van de verkeerde bytes. Als je JSON eerst parseert, kan je server het herformatteren (whitespace, sleutelvolgorde, getalnotatie). Vervolgens verifieer je de handtekening tegen een andere body dan die de verzender signeerde, en faalt verificatie ondanks dat de payload echt is. Verifieer altijd tegen de ruwe request-body bytes precies zoals ontvangen.
De volgende grote bron van verwarring zijn secrets. Teams testen in staging maar verifiëren per ongeluk met het productiegeheim, of houden een oud geheim na rotatie. Als een klant meldt dat iets “alleen in één omgeving” faalt, ga dan eerst uit van verkeerd geheim of verkeerde config.
Enkele fouten die tot lange onderzoeken leiden:
Voorbeeld: een klant zegt “order.paid kwam nooit aan.” Je ziet signature-failures die begonnen na een refactor die request-parsing middleware veranderde. De middleware leest en her-encodeert JSON, dus je signature-check gebruikt nu een gewijzigde body. De fix is simpel, maar alleen als je weet waar te zoeken.
Als een klant zegt “jullie webhook vuurde niet”, behandel het als een trace-probleem, niet als giswerk. Koppel aan één exacte bezorgpoging van de provider en volg die door je systeem.
Begin met het verkrijgen van de delivery-identifier, request-ID of event-ID van de provider voor de mislukte poging. Met die enkele waarde zou je snel de bijbehorende logentry moeten vinden.
Controleer daarna drie dingen op volgorde:
Bevestig vervolgens wat je teruggaf aan de provider. Een trage 200 kan net zo slecht zijn als een 500 als de provider timeout en opnieuw probeert. Kijk naar statuscode, responstijd en of je handler al heeft bevestigd voordat hij zwaar werk deed.
Als je wilt reproduceren, doe dat veilig: bewaar een geredigeerde ruwe request-sample (belangrijke headers plus ruwe body) en replay die in een testomgeving met hetzelfde geheim en verificatiecode.
Als een webhook-integratie “willekeurig” faalt, telt snelheid meer dan perfectie. Dit runbook vangt de gebruikelijke oorzaken.
Haal eerst één concreet voorbeeld:provider-naam, event-type, geschatte timestamp (met timezone) en eventuele event-ID die de klant kan zien.
Controleer dan:
Als de provider zegt “we hebben 20 keer opnieuw geprobeerd”, controleer dan eerst de veelvoorkomende patronen: verkeerd geheim (signature faalt), klokdrift (replay-window), payload-grootte limieten (413), timeouts (geen response) en pieken van 5xx door downstream dependencies.
Een klant mailt: “We hebben gisteren een invoice.paid event gemist. Ons systeem heeft nooit bijgewerkt.” Hier is een snelle manier om het te traceren.
Bevestig eerst of de provider leveringspogingen deed. Haal het event ID, timestamp, bestemmings-URL en de exacte responsecode die jouw endpoint teruggaf. Als er retries waren, noteer dan de eerste foutreden en of een latere retry slaagde.
Valideer daarna wat je code aan de rand zag: bevestig het signing-secret dat voor dat endpoint is geconfigureerd, recomputeer signature-verificatie met de ruwe request-body en controleer de request-timestamp tegen je toegestane venster.
Wees voorzichtig met replay-vensters tijdens retries. Als je venster 5 minuten is en de provider probeert 30 minuten later opnieuw, kun je een legitieme retry weigeren. Als dat je beleid is, documenteer het en maak het duidelijk. Zo niet, verruim het venster of verander de logica zodat idempotentie het primaire mechanisme tegen duplicaten blijft.
Als signature en timestamp er goed uitzien, volg dan het event ID door je systeem en beantwoord: hebben jullie het verwerkt, gededuped of gedropt?
Veelvoorkomende uitkomsten:
Als je op de klant reageert, houd het kort en specifiek: “We ontvingen bezorgpogingen om 10:03 en 10:33 UTC. De eerste timed out na 10s; de retry werd afgewezen omdat de timestamp buiten ons 5-minuten venster viel. We hebben het venster vergroot en snellere bevestiging toegevoegd. Stuur event ID X opnieuw als dat nodig is.”
De snelste manier om webhook-fouten te stoppen is ervoor te zorgen dat iedere integratie hetzelfde playbook volgt. Leg vast welk contract jij en de verzender overeenkomen: vereiste headers, de exacte ondertekeningsmethode, welke timestamp gebruikt wordt en welke ID's je als uniek beschouwt.
Standaardiseer vervolgens wat je voor elke leveringspoging vastlegt. Een klein receipt-log is meestal genoeg: received_at, event_id, delivery_id, signature_valid, idempotency_result (nieuw/duplicate), handler_version en response status.
Een workflow die nuttig blijft naarmate je groeit:
Als je apps bouwt op Koder.ai (koder.ai), is Planning Mode een goede manier om eerst het webhook-contract te definiëren (headers, ondertekening, ID's, retry-gedrag) en vervolgens een consistent endpoint en receipt-record over projecten te genereren. Die consistentie maakt debugging snel in plaats van heldhaftig.
Omdat webhook-delivery meestal at-least-once is, niet exactly-once. Providers retryen bij timeouts, 5xx-responses en soms als ze je 2xx niet op tijd zien, dus je kunt duplicaten, vertragingen en buiten-volgorde leveringen krijgen, zelfs als alles ogenschijnlijk werkt.
Standaardregel: verifieer eerst de handtekening, sla/dedupeer het event op, geef dan 2xx terug, en voer zware taken asynchroon uit.\n\nAls je zware taken uitvoert voordat je antwoord geeft, loop je tegen timeouts aan en trigger je retries; als je antwoordt voordat je iets hebt opgeslagen, kun je events verliezen bij crashes.
Gebruik de ruwe request-body bytes exact zoals ze binnenkwamen. Parseer JSON niet en serialiseer het vervolgens opnieuw voor verificatie—whitespace, key-volgorde en getalnotatie kunnen handtekeningen breken.\n\nZorg er ook voor dat je de door de provider gesigneerde payload precies reproduceert (vaak timestamp + "." + raw_body).
Geef een 4xx terug (meestal 400 of 401) en verwerk de payload niet.\n\nLog een minimale reden (ontbrekende signatuur-header, mismatch, timestamp buiten venster), maar log geen secrets of volledige gevoelige payloads.
Een idempotentiesleutel is een stabiele unieke identifier die je opslaat zodat retries geen bijwerkingen opnieuw uitvoeren.\n\nBeste opties:\n\n- Event ID (ideaal wanneer één event één zakelijke wijziging betekent)\n- Delivery/message ID (als die constant blijft over retries)\n- Hash van stabiele velden (laatste redmiddel)\n\nHandhaaf het met een unique constraint zodat onder gelijktijdigheid maar één request ‘wint’.
Schrijf de idempotentiesleutel voordat je bijwerkingen doet, met een uniekheidsregel. Doe daarna één van de volgende:\n\n- Markeer het als verwerkt na succes, of\n- Noteer een foutstatus zodat je veilig opnieuw kunt proberen\n\nAls de insert faalt omdat de sleutel al bestaat, geef 2xx terug en sla de bedrijfsactie over.
Gebruik een timestamp in de gesigneerde data en verwerp requests buiten een kort venster (bijv. een paar minuten).\n\nOm legitieme retries niet te blokkeren:\n\n- Sta wat clock drift toe\n- Log je server-tijd en de ontvangen timestamp bij afwijzing\n- Zie idempotentie als de belangrijkste verdediging tegen duplicaten; het tijdvenster is vooral bedoeld om latere replays tegen te houden
Neem niet aan dat leveringsvolgorde gelijk is aan event-volgorde. Maak handlers tolerant:\n\n- Gebruik upserts waar mogelijk\n- Controleer de huidige staat voordat je wijzigingen toepast\n- Als een object niet gevonden wordt, overweeg dan opnieuw proberen later (via een queue) in plaats van permanente failure\n\nSla event ID en type op zodat je kunt redeneren over wat er gebeurde, ook als de volgorde vreemd is.
Log een kleine “receipt” per leveringspoging zodat je één event end-to-end kunt traceren:\n\n- provider, event_id, delivery_id\n- signature_ok, replay_ok\n- idempotency result (nieuw/duplicate)\n- response_code, latency_ms\n- timestamps (received/first_seen/last_seen)\n\nMaak logs doorzoekbaar op event ID zodat support snel klantmeldingen kan beantwoorden.
Begin met het vragen naar één concreet identificatiemiddel: event ID of delivery ID, plus een geschat tijdstip.\n\nControleer vervolgens in deze volgorde:\n\n1. Resultaat van handtekeningverificatie\n2. Resultaat van timestamp/replay-venster (indien gebruikt)\n3. Idempotentie-uitkomst (nieuw vs duplicate)\n4. Wat je teruggaf (statuscode + latency)\n\nAls je endpoints bouwt met Koder.ai, houd dan het handlerpatroon consistent (verify → record/dedupe → queue → respond). Consistentie maakt deze checks snel bij incidenten.