Integrations
Mollie, Adyen, Buckaroo: 15 webhook-valkuilen rond refunds
Een Utrechts ticketingplatform vroeg ons refunds over drie PSP's te automatiseren. Dit overleefde retries, stille metadata-drops en een SEPA-storno die twee keer uitbetaalde.

De mail kwam binnen om 23:14 op een dinsdag. De oprichter van een ticketingplatform met 24 mensen in Utrecht had 312 refunds in de wachtrij, een ops lead op vakantie in Naxos en een Buckaroo SEPA-incasso storno die zojuist dezelfde €87 refund twee keer had uitbetaald. Ze wilden de hele flow voor het einde van de maand geautomatiseerd hebben, over Mollie, Adyen en Buckaroo.
We namen de opdracht aan. Wat volgt is de cheatsheet die uit die rollout kwam: vijftien webhook-valkuilen bij de drie Nederlandse payment service providers, gerangschikt op hoeveel geld of slaap ze ons gekost hebben. De lijst is uitgesproken. Het is ook het document dat we nu meegeven aan elke klant die met PSP's werkt, omdat bijna niks hiervan in de happy-path-docs staat.
De opzet waarmee elke valkuil bovenkwam
Het platform verkoopt twee productlijnen. Losse event-tickets gaan via Mollie (iDEAL en card, gemiddelde orderwaarde onder €100). Seizoenpassen en B2B-groepsboekingen lopen via Adyen (hogere ticketsizes, MOTO geaccepteerd). Een klein maar hardnekkig segment van oudere kopers kiest liever voor SEPA-incasso voor de seizoenpassen, en dat gaat via Buckaroo, omdat Buckaroo's afhandeling van automatische incasso's in NL nog steeds het schoonst is.
Het refund-volume zit rond de 4 tot 6 procent van de orders, met pieken na buitenevents die door het weer afgelast worden. Voor de rollout besteedden twee ops-medewerkers tien uur per week aan refunds opzoeken in drie dashboards. De opdracht: elke refund die vanuit het support-tool gestart wordt, vuurt de juiste PSP-call af, plaatst de status terug in het grootboek, en laat fouten zien in Slack vóór een klant klaagt. Standaard werk. De vijftien valkuilen hieronder zijn de reden dat het zes weken kostte in plaats van twee.
De vijf valkuilen die echt geld kostten
Deze staan bovenaan omdat elk een meetbaar bedrag kostte vóór we het door hadden. Als je maar één sectie leest, lees dan deze.
1. Buckaroo SEPA-storno herhaalt zes keer in 90 seconden
Als een klant een SEPA-incasso terugdraait, komt Buckaroo's Push-notificatie voor statuscode 690 (Pay Failure) niet één keer binnen. Hij komt zes keer binnen in de eerste 90 seconden, daarna nog twee keer in het volgende uur, en daarna sporadisch over een periode van 24 uur. Er is geen deduplicatie-header. De Transaction key is elke keer identiek; alleen BRQ_TIMESTAMP verandert.
De eerste versie van onze handler behandelde elke push als gezaghebbend en boekte telkens een compenserend credit. Dat vuurde zes keer af. De fix is een unique constraint op database-niveau op (provider, transaction_key, status_code), niet een check op applicatie-niveau, omdat de retries parallel binnenkomen en je 'hebben we dit al gezien?'-SELECT met zichzelf gaat racen.
CREATE UNIQUE INDEX webhook_dedup_idx
ON psp_events (provider, transaction_key, status_code);
-- INSERT ... ON CONFLICT DO NOTHING is your friend.
-- Process the event only when the insert returned a new row.
INSERT INTO psp_events (provider, transaction_key, status_code, raw_body)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, transaction_key, status_code) DO NOTHING
RETURNING id;
Behandel elke PSP-webhook handler alsof hij elk event twee tot tien keer gaat ontvangen. Idempotentie is geen nice-to-have; het is het contract.
2. Adyen geeft 200 OK terug maar laat additionalData vallen bij een SEPA-reversal
Bij SEPA-incasso reversals bevat de notification-payload van Adyen het additionalData-blok op de oorspronkelijke AUTHORISATION, maar trimt het op de daaropvolgende CHARGEBACK_REVERSED- of REFUND_FAILED-notificaties. Als je je interne order_id alleen binnen additionalData hebt opgeslagen, komt het reversal-event binnen met de pspReference correct gematcht, een HTTP 200 OK verwacht terug, en je order_id-veld leeg.
De lookup faalt stilletjes. Je geeft 200 terug, Adyen markeert de webhook als afgeleverd, en je support-tool komt nooit te weten dat de refund daadwerkelijk teruggedraaid is. De fix is om de mapping (pspReference → internal_order_id) altijd te persisteren op de originele betaling, en daarna op pspReference te keyen voor elk vervolgevent. De veldverwachtingen staan in Adyens notification reference: de tabel maakt expliciet welke velden gegarandeerd zijn per event-type en welke niet.
3. Mollie-refund-webhook vuurt twee keer als de refund zowel in Dashboard als API is aangemaakt
Als een supportmedewerker een refund aanmaakt vanuit het Mollie Dashboard, en je achtergrond-reconciler ook een refund-call afvuurt voor dezelfde betaling binnen dezelfde minuut (omdat het support-tool de dashboard-actie nog niet gezien heeft), maakt Mollie twee refunds aan. Beide slagen. Beide vuren webhooks. De klant wordt twee keer terugbetaald.
Dit klinkt als een procesprobleem, en dat is het ook, maar het is ook een webhook-probleem, want er is geen race-preventie-API. De fix is een Redis-lock gekoppeld aan de Mollie payment-id met een TTL van 60 seconden rond elke refund-uitgifte, plus een beleid dat support nooit direct vanuit het Mollie Dashboard refundt.
4. Adyens NOTIFICATION_OF_CHARGEBACK kan ná CHARGEBACK binnenkomen
De twee events zijn ontworpen als een sequentie: eerst de notification, daarna de daadwerkelijke chargeback. In de praktijk kunnen ze in willekeurige volgorde binnenkomen, of kan NOTIFICATION_OF_CHARGEBACK bij sommige kaartschema's volledig overgeslagen worden. Als je state machine de notification verplicht voor de overgang, blijven sommige disputes hangen in een limbo totdat iemand handmatig ingrijpt.
Bouw de state machine zo dat CHARGEBACK altijd geaccepteerd wordt als eindevent, ongeacht of NOTIFICATION_OF_CHARGEBACK eraan voorafging. Gebruik die laatste als hint om alvast verdedigingsdocumenten klaar te zetten, niet als poort.
5. Buckaroo Status_FailureURL vuurt voor zowel echte failures als gebruikersannuleringen
Buckaroo post naar je Status_FailureURL wanneer een betaling mislukt EN wanneer een gebruiker midden in de flow op 'annuleren' klikt. Beide komen binnen met BRQ_STATUSCODE 490 (Failed). Het onderscheidende veld is BRQ_STATUSCODE_DETAIL, dat 502 is bij een echte bankweigering en S001 bij een gebruikersannulering. Als je ops bij elke failure een alert stuurt, leer je ze het kanaal binnen een week te negeren.
De vijf valkuilen die reconciliatie breken
6. Mollie-webhooks bevatten alleen een ID, nooit de event-payload
Dit staat in de docs, maar wordt constant vergeten. De webhook-body van Mollie is x-www-form-urlencoded met één veld: id=tr_xxx. Je haalt de betaling opnieuw op via de API om de status te krijgen. De reden is logisch (ondertekende bron van waarheid, geen payload-knoeien), maar het verdubbelt je latency-budget en betekent dat een API-storing webhook-verwerking breekt. Zie de Mollie webhook reference voor de ontwerpredenering.
7. Adyens .live-veld is de string "false", geen boolean
Makkelijk te missen in een code review. Als je if (notification.live) { ... } schrijft, evalueert die string als truthy en draaien je test-events door je productie-logica heen. Gebruik een strikte gelijkheid tegen de string "true", of parse hem één keer aan de rand van de handler en vertrouw daarna de ruwe waarde nooit meer.
8. Buckaroo tekent standaard met SHA-512, maar accepteert SHA-1 nog vrolijk
Het signature-algoritme is instelbaar in het Buckaroo merchant-paneel, en SHA-1 is daar nog steeds een geldige optie. Als je een oude configuratie hebt geërfd, controleert je verificatiecode misschien SHA-1 en weigert hij stilletjes SHA-512-pushes (of, erger: hij accepteert ongetekende pushes). Zet de schakelaar op SHA-512, en verifieer daarna dat je code de algoritme-header zowel op oude als nieuwe flows aankan.
9. Mollie chargeback-notificaties laten refund.id weg als er een gedeeltelijke refund aan vooraf ging
Als er voor een betaling een gedeeltelijke refund is uitgegeven en daarna een chargeback voor het resterende bedrag binnenkomt, draagt de chargeback-webhook payload van Mollie de bijbehorende refund.id niet mee. Je logica om 'koppel deze chargeback aan de refund die hem triggerde' geeft null terug. Match op payment_id en chargeback_id, en accepteer dat de refund-link een aparte API-call vereist.
10. Adyen batcht meerdere events in notificationItems[]
De Adyen notification-webhook post een array van events in één HTTP-request. Voorbeeldcode in sommige oude blogposts verwerkt alleen notificationItems[0]. Verwerk de hele array. We hebben tijdens drukke periodes tot elf events in één batch gezien.
De vijf valkuilen die alleen irriteerden
11. Buckaroo post alles als x-www-form-urlencoded met brq_-prefixes
Elk veld komt in lowercase binnen met de brq_-prefix. Je parser moet hoofdletterongevoelig zijn en de prefix strippen, want de naamgevingsconventie in hun docs gebruikt Title_Case terwijl het wire-format dat niet doet.
12. Mollie's 'test webhook'-knop gebruikt tr_test als payment-id
Als je vanuit het Mollie-dashboard een test-webhook activeert, is het id-veld letterlijk de string tr_test, die niet via de API te resolven valt. Je handler geeft een 404 op de fetch en lijkt kapot in het delivery-log van het dashboard. Short-circuit wanneer id === "tr_test" en geef direct 200 terug.
13. Adyens amount.value zit in minor units, en 'minor' is afhankelijk van de valuta
€15 komt binnen als 1500 (twee minor units). JPY 15 komt binnen als 15 (nul minor units). BHD 15 komt binnen als 15000 (drie minor units). Gebruik een valuta-bewuste deler, geen hardcoded /100. De exponent-tabel is klein en stabiel; bak hem in.
14. Buckaroo SEPA TransactionType-codes C001 en C002 staan niet in de hoofd-webhook reference
Het TransactionType voor SEPA-incasso debit is C001 en de tegenpool voor refund is C002. Deze staan in de Direct Debit-specifieke docs, maar niet in de hoofd-webhook reference. Als je je handler vanuit de hoofdreference geschreven hebt, eindigen je SEPA-events in de 'onbekend transaction type'-tak en worden ze genegeerd.
15. Mollie refund.failed voor SEPA kan dagen na de refund afvuren
SEPA-refunds kunnen mislukken (onvoldoende saldo bij de ontvangende bank, gesloten rekening) tot vijf werkdagen nadat Mollie de refund als queued rapporteert. Houd het refund-record zeven dagen open en in write-lock. Sluit de grootboekregel niet af op de queued-status.
Het reconciliatiepatroon dat alle drie de PSP's overleefde
Na vijftien valkuilen aan debugging ziet de architectuur er bij ons zo uit:
// Webhook entrypoint: dumb and fast.
app.post('/webhooks/:psp', async (req, res) => {
const event = normalize(req.params.psp, req.headers, req.body)
const inserted = await db.psp_events.insertIfNew(event)
res.status(200).send('OK') // ack immediately
if (inserted) await queue.publish('psp.event', event.id)
})
// Worker: slow, idempotent, re-runnable.
queue.consume('psp.event', async ({ id }) => {
const event = await db.psp_events.find(id)
await applyStateTransition(event) // pure function, no I/O
await updateOrderLedger(event) // idempotent UPSERT
})
Drie eigenschappen tellen. Het webhook-endpoint doet nooit business-logica, dus een trage database zorgt er niet voor dat Buckaroo retries gaat sturen. Deduplicatie gebeurt op insert-time, niet in applicatie-code. De worker is een pure consumer van de events-tabel, wat betekent dat we elk event kunnen replayen door zijn ID opnieuw in de queue te zetten.
Dit patroon werkt ook voor Stripe en Paddle, maar bij de drie Nederlandse PSP's verdient het pas echt zijn geld, omdat hun retry-gedrag het meest agressief is en hun foutmodes het minst uniform. Toen we de refund-automatisering bouwden voor het Utrechtse ticketingplatform, was het langste deel niet het schrijven van de integraties: het was ontdekken dat elke PSP 'afgeleverd' anders definieert. We installeren het dedup-at-insert-patroon hierboven nu als eerste bij elke klant die integraties en procesautomatisering over meerdere PSP's draait.
Wat je morgenochtend kunt doen
Als je refunds over meer dan één PSP draait, besteed dan vanavond twintig minuten aan één query. Groepeer je laatste 90 dagen aan webhook-events op (provider, transaction_key, status_code) en tel duplicates. Het aantal zal je verbazen. Dat aantal is de omvang van de bug waarvoor je nog geen rekening hebt gehad.
Kern
Behandel elke PSP-webhook alsof hij twee tot tien keer binnenkomt. Idempotentie hoort in je database-constraint, niet in je applicatie-code.
FAQ
Waarom zoveel valkuilen bij maar drie providers?
Elke PSP heeft zijn webhook-contract geoptimaliseerd voor een andere foutmode: signing, retries, batch delivery, SEPA-reversals. De valkuilen zijn bewuste ontwerpkeuzes die slecht samenwerken zodra één grootboek alle drie consumeert.
Speelt dit alleen bij Nederlandse PSP's?
De retry- en dedup-patronen gelden voor elke PSP. De specifieke valkuilen (volume van Buckaroo SEPA-storno's, gedrag van Adyen additionalData) komen het meest naar boven in NL, omdat Nederlandse ticketing leunt op iDEAL en SEPA-incasso.
Kan ik webhooks synchroon verwerken en de queue overslaan?
Alleen bij heel laag volume. Met één Buckaroo SEPA-event dat zes keer afvuurt in 90 seconden zal een synchrone handler met een database-lock vastlopen en zijn retry-budget verspelen. Gebruik een queue.
Heb ik per PSP een aparte handler nodig?
Eén endpoint per PSP voor de signature-verificatie, maar één normaliser die één event-tabel voedt. Drie dunne handlers, één pipeline, één worker.
Werkt dit patroon ook met Stripe?
Ja. De retry-envelope van Stripe loopt tot 3 dagen met exponential backoff, en idempotency keys zijn first-class. Het dedup-at-insert-patroon werkt out of the box en haalt de meeste edge cases weg.