Integrations
Mollie, Buckaroo, Adyen webhooks: SEPA storno-valkuilen
Een Zaandamse abonnementsbox-retailer reconcilieerde €4.200 aan SEPA-betalingen die nooit afgewikkeld werden. De webhooks gaven allemaal 200 OK terug. De storno landde nooit.

Op een dinsdag in mei opende de finance lead van een Zaandamse abonnementsbox-retailer (32 medewerkers) haar reconciliatie-tab en zag €4.247 aan geslaagde SEPA-betalingen die de bank nooit had afgewikkeld. De PSP webhook-logs waren schoon. Elke endpoint gaf 200 OK terug. De storno was ergens anders geland.
Ze wist het nog niet, maar de agent die we twee weken eerder hadden opgeleverd verwerkte notificaties van drie providers (Mollie, Buckaroo en Adyen) over twee merchant-identifiers, en dezelfde code path die een betaling bevestigde slokte de reversal op. Het webhook-contract klopte. De boekhouding niet.
Wat volgt is de cheatsheet die we na die ochtend bouwden. Zeventien quirks over drie voor Nederland relevante PSP's, gerangschikt op hoe stilletjes ze een SEPA storno laten vallen op een multi-merchant tenant. Als je een reconciliatie-pipeline draait die ook maar een combinatie van deze providers raakt, heb je er waarschijnlijk een paar van geraakt. De gevaarlijke zijn de quirks die je nog niet bent tegengekomen.
Waarom SEPA storno de stille faalmodus is
Een SEPA-incasso kan tot acht weken na inning zonder opgave van reden worden teruggedraaid, en tot dertien maanden als het mandaat zelf wordt aangevochten. Tegen de tijd dat de storno binnenkomt heeft je magazijn al verzonden, zijn je boeken gesloten, en heeft de klant de doos biologische muesli al opgegeten.
Kaartrefunds zijn meestal luid. Ze komen terug via hetzelfde kanaal dat de betaling autoriseerde, met dezelfde merchant-identifier, op dezelfde werkdag. SEPA-reversals zijn stil. Ze komen laat aan, ze komen binnen op een ander event type dan het type dat de betaling bevestigde, en op een multi-merchant tenant komen ze vaak binnen op een webhook payload die de merchant niet duidelijk noemt.
Het patroon is consistent: een webhook vuurt, je endpoint geeft 200 terug, en de storno wordt geboekt op de verkeerde tenant of helemaal geen tenant. De PSP beschouwt het bericht als afgeleverd. Er komt geen retry.
Mollie-quirks die 200 teruggeven en je vergeten
Het webhook-contract van Mollie is berucht minimaal. De POST body bestaat uit één veld, id, en je wordt geacht terug te bellen naar GET /v2/payments/{id} om te weten wat er is gebeurd. Die karigheid is prima voor een single-profile shop en gevaarlijk voor een multi-profile tenant.
- De webhook vertelt je nooit wat het event is. Een chargeback-notificatie post dezelfde vorm als een geslaagde betaling. Als je handler 'ik heb deze id eerder gezien' als idempotent behandelt en kortsluit, mis je de chargeback. Refetch altijd en vergelijk het status-veld.
- De chargeback is een eigen resource. De payment status blijft
paid, ook na een SEPA-reversal. De reversal staat onder/payments/{id}/chargebacks. Als je alleen de payment leest, zie je hem nooit. - Test en live delen de URL. De enige discriminator is het
mode-veld op het payment object, niet op de webhook payload. We zagen een tenant een live chargeback naar het test-grootboek routeren omdat de agent aannam dat de URL de modus impliceerde. - Profielbinding zit op de payment, niet op de webhook. Als je meerdere Mollie-profielen hebt onder één organisatie-account, is de webhook payload zelf profiel-agnostisch. Je moet eerst de payment ophalen,
profileIdlezen, en daarna routeren. - Een 200 zonder side effect is een permanent verlies. Mollie probeert tot 24 uur opnieuw bij een non-200. Een 200 die je teruggeeft voordat je write commit, wordt behandeld als succesvolle aflevering. Een tweede kans krijg je niet.
- Refund en chargeback lijken op elkaar maar settelen anders. Een door de klant gestarte SEPA-reversal komt binnen als chargeback (kosten: ongeveer €12,50), niet als refund. Als je agent classificeert op het teken van het bedrag in plaats van op event type, registreert hij de kosten niet.
Buckaroo-quirks waar de signature liegt
De push-notificaties van Buckaroo komen in twee smaken die naast elkaar bestaan op hetzelfde merchant-account: de oude form-encoded 'Push' en de JSON-encoded 'Push v2'. Ze signeren anders, ze nesten anders, en dezelfde SEPA-reversal kan in beide formaten binnenkomen, afhankelijk van welke integratie de oorspronkelijke transactie heeft aangemaakt.
- Form-encoded pushes signeren de URL maar niet de query string. Als je webhooks doorstuurt via een router die een tenant-hint als query parameter toevoegt, valideert de HMAC en lijkt de payload nog steeds te kloppen. De tenant-hint wordt stilletjes genegeerd.
- Een SEPA-reversal komt binnen als StatusCode 890. Dat is 'cancelled by user', niet de failure-code 491 waar je je retry-logica misschien omheen hebt gebouwd. Een handler die vertakt op
StatusCode === 491behandelt de storno als een no-op. - Additional service payloads nesten per versie onder andere keys. Een SEPA-chargeback onder v1 staat op
AdditionalServices.Service[0].ResponseParameter. Onder v2 is datAdditionalServices[0].Parameters. Hetzelfde event, andere vorm. WebsiteKey, niet de transactie-key, is je tenant-grens. Multi-website Buckaroo-setups delen één endpoint. DeWebsiteKey-header vertelt je bij welke storefront de push hoort. De transactie-key is uniek, maar zegt niets over eigenaarschap.- Retries op de JSON push zijn beperkt. Een Buckaroo v2-push wordt afgevuurd met een veel korter retry-window dan v1. Als je endpoint om 04:00 een 502 teruggeeft omdat een downstream queue vol zat, is die storno vaak weg tegen de tijd dat de queue leegloopt. Het dashboard markeert hem dan als afgeleverd.
Adyen-quirks op multi-merchant tenants
Adyen is de strengste van de drie en ook het makkelijkst verkeerd in te richten. Notificaties komen in batches, worden per merchant-account ondertekend, en gaan door één endpoint heen dat moet fan-outen naar veel tenants.
NOTIFICATION_OF_CHARGEBACKis niet de chargeback. Het is de heads-up. Het echteCHARGEBACK-event komt later binnen, soms dagen later, met een andere PSP-reference. Beide moeten verwerkt worden, en het window om het dispuut te verdedigen start bij het eerste event.- SEPA SDD-reversals komen binnen als
REFUND_WITH_DATAmetsuccess: false. De 200 blijft verplicht. Als je een non-200 teruggeeft omdatsuccessfalse is, levert Adyen dezelfde notificatie opnieuw af op een schema van 5, 30 en 60 minuten en daarna exponentiële backoff. Je krijgt dezelfde storno vier keer. Als je idempotency key alleen op event id gebaseerd is, schrijf je hem vier keer weg. - HMAC-signing keys zijn per merchant-account. Eén webhook URL die vijf merchant-accounts bedient heeft vijf keys nodig. Als je tegen de eerste matchende key valideert, falen de andere vier signatures stilletjes open of dicht, afhankelijk van je library.
notificationItemsis een batch. Tot ongeveer twintig events komen in één POST binnen. Er is geen partial-success contract. Als item 7 van de 20 een fout gooit, accepteer je óf de hele batch óf niets, en Adyen levert de hele batch opnieuw af.- De
live-vlag staat in de root, niet per item. Een test- en een live-transactie kunnen geen batch delen, maar de discriminator is één veld dat je makkelijk mist als je alleen de per-item payload logt. merchantAccountCodeis de enige tenant-grens. De PSP-reference is uniek, maar zegt niets over eigenaarschap. Multi-merchant tenants moeten eerst opmerchantAccountCoderouteren, daarna op PSP-reference.
Een 200 OK is een contract. Het vertelt de PSP: 'ik heb deze notificatie duurzaam geaccepteerd, je kunt verder.' Als je handler een 200 teruggeeft voordat de write commit en de write daarna faalt, creëer je een stille data-loss die geen enkel dashboard je ooit laat zien. Acknowledge na commit, niet ervoor.
De reconciliatie-invariant die alle zeventien vangt
Nadat we de eerste zes hiervan in productie hadden geraakt, stopten we met het patchen van losse quirks en bouwden we de agent opnieuw rond één invariant: elke webhook is een hypothese, en de source of truth is het dagelijkse settlement-bestand.
Het settlement-bestand (Mollie's settlements.csv, de transactie-export van Buckaroo, Adyen's SettlementDetailsReport) is wat je bank daadwerkelijk ziet. De webhook is een real-time hint waarmee je eerder kunt verzenden. Als de hint en het bestand het oneens zijn, wint het bestand.
In de praktijk betekent dat drie dingen:
-- dagelijkse diff, één rij per afwijking
select
l.psp,
l.merchant_code,
l.psp_reference,
l.event_type as webhook_said,
s.settlement_status as bank_said,
l.amount_cents,
l.received_at
from webhook_ledger l
left join settlement_lines s
on s.psp = l.psp
and s.psp_reference = l.psp_reference
where l.received_at >= now() - interval '8 weeks'
and (s.settlement_status is null
or s.settlement_status <> l.event_type)
order by l.received_at desc;Die query is de ruggengraat van de agent. Elke webhook-write gaat naar webhook_ledger met de raw payload, de resolved PSP, de resolved tenant, en de timestamp. Elke settlement-bestandsimport vult settlement_lines. Afwijkingen ouder dan 24 uur gaan naar een mens, niet terug naar de agent.
De meeste van de zeventien quirks hierboven komen neer op dezelfde afwijking van één regel in de dagelijkse diff: webhook zei paid, settlement zegt reversed. Zodra dat signaal zichtbaar is, is de daadwerkelijke fix meestal een vijf-regelige aanpassing in de classifier. Het lastige was het signaal zichtbaar maken.
De reden dat we deze vorm prettig vinden, is dat hij het gangbare 'webhook aansluiten en hopen dat het werkt' model omdraait. De webhook mag nu fout zitten. We bouwen de agent zodat hij verwacht dat hij fout zit, en we sluiten de loop op het bestand dat de bank produceert.
Waar dit in productie liep
Toen we de payments-reconciliatie AI-agent bouwden voor de Zaandamse abonnementsbox-retailer, was niet het webhook-contract zelf wat ons verraste. Wel hoe makkelijk het is om een handler te schrijven die aan alle drie de vendor-docs voldoet en alsnog €4.000 aan SEPA-reversals verliest over een multi-merchant tenant. We losten het op door webhooks te behandelen als onbetrouwbare hints en te reconciliëren tegen het dagelijkse settlement-bestand. Dat is nu de standaardvorm die we opleveren voor iedereen die meer dan één PSP draait.
Trek het settlement-bestand van vorige week uit je PSP. Diff het tegen je eigen tabel met paid-orders. Tel de rijen waar het bestand reversed zegt en jouw tabel paid. Is dat aantal groter dan nul, dan heb je een webhook-quirk in productie. Zoek uit welke provider, welk event type, en welke tenant. Fix die ene. Ship eerst de diff-job, daarna pas de volgende fix.
Kern
Elke webhook is een hypothese. Het settlement-bestand is de waarheid. Bouw je reconciliatie-agent zodat hij verwacht dat de webhook fout zit.
FAQ
Waarom geeft mijn webhook-handler 200 terug maar verliest hij toch SEPA-reversals?
Omdat de 200 de aflevering vastlegt. Als je write faalt na de 200, doet de PSP geen retry. Acknowledge na de database commit, niet ervoor, en classificeer op event type in plaats van het teken van het bedrag.
Kan één webhook-endpoint meerdere Adyen merchant-accounts veilig bedienen?
Ja, maar elk merchant-account heeft een eigen HMAC-key nodig die wordt gevalideerd tegen het bijbehorende notificationItems-entry. Routeer eerst op merchantAccountCode, dan op PSP-reference, en verifieer daarna de signature.
Is de Mollie payment status genoeg om een SEPA storno te detecteren?
Nee. De payment status blijft paid na een SEPA-chargeback. De reversal staat onder /payments/{id}/chargebacks en moet apart worden opgehaald telkens als de webhook voor die payment id afvuurt.
Wat is de kleinste reconciliatiecheck die ik vandaag kan draaien?
Trek het PSP settlement-bestand van vorige week, doe een left-join met je paid-orders tabel op de PSP-reference, en tel de rijen waar het bestand reversed laat zien en jouw tabel paid. Een niet-nul resultaat betekent dat je een webhook-quirk in productie hebt.