← Blog

Integrations

Mollie, Adyen, Buckaroo webhooks: 19 quirks uit een rollout

Op een dinsdag in maart, 02:14, boekte de order-agent van een Utrechts D2C-merk dezelfde €38,50 elf keer terug. Mollie, Adyen en Buckaroo droegen elk bij.

Jacob Molkenboer· Oprichter · A Brand New Company· 20 jun 2026· 9 min
Drie messing schijfjes, groen lint door één, naast gekreukt bonnetje, rode lakzegel, donker leren onderlegger op ivoor papier.

Op een dinsdag in maart, 02:14, boekte de order-agent die we zes weken eerder live zetten voor een 24-koppig D2C-merk vlakbij Utrecht Centraal elf keer dezelfde pot gefermenteerde hot sauce van €38,50 terug. De klant had op vrijdag een chargeback ingediend bij haar bank. Haar bank draaide die op maandag terug. De agent — gekoppeld aan Mollie voor kaartbetalingen, Adyen voor SEPA Direct Debit en Buckaroo voor iDEAL — ving de terugdraaiing op en probeerde de refund te annuleren. Toen ving hij de annulering op. Toen ving hij Mollie op die opnieuw refund.created stuurde, omdat de chargeback-flip er voor Mollie uitzag als een gloednieuwe state transition op de oorspronkelijke refund.

Tegen de tijd dat on-call gepiept werd, waren er drie orders op dezelfde manier leeggelopen. Om 09:00 de volgende ochtend had het team een lijst van negentien webhook-gedragingen die een naam, een eigenaar en documentatie nodig hadden. Deze post is die lijst, gerangschikt naar welke echt geld kosten en welke alleen ruis opleveren.

Hoe de cheatsheet ontstond

Het merk verzendt zo'n 40.000 orders per maand, ruwweg verdeeld over 55% iDEAL, 30% kaart en 15% SEPA Direct Debit via een groothandelstak waar de ticket sizes standaard boven €2.500 uitkomen. Onze opdracht was een order-agent die supportmail triageert, refunds doet voor de evidente gevallen en alles wat ambigu is escaleert naar een menselijke reviewer. De webhook-laag was het makkelijke stuk. Drie PSP's, drie documentatiesites, één adapter per stuk. Klaar op vrijdag.

Het was niet klaar op vrijdag. Binnen 72 uur na go-live hadden we acht dubbele refunds, twee verdwenen SEPA-mandaten op partial captures, en één HMAC-signature mismatch die uiteindelijk een Cloudflare worker bleek te zijn die upstream de JSON-whitespace normaliseerde. We bevroren schrijfacties, lieten elke webhook tien dagen door een shadow queue lopen, en diffden wat elke PSP daadwerkelijk stuurde tegen wat de docs beweerden dat hij zou sturen. Het resultaat staat hieronder, in drie tiers.

Tier 1: refund-replay na een handmatige chargeback-flip

Dit zijn de vijf die echt geld kosten. Alle vijf vuren na een dashboard-resolutie of een chargeback-terugdraaiing aan bankzijde, en overtuigen een naïeve handler dat er een nieuwe refund uit moet. Als je handler geen idempotentie heeft op de event-tuple, loopt hier het geld weg.

  1. Mollie · refund.created herhaalt tot 24 uur na een chargeback-flip. De refund-id wordt hergebruikt; alleen createdAt verschuift. Als je handler keyt op payment-id, refund je twee keer. Key op (refund.id, status) en behandel het tweede event als een no-op.
  2. Adyen · REFUND_WITH_DATA komt binnen na een gewonnen dispute. Het veld originalReference is soms null als de terugdraaiing via Schemes-Disputes binnenkomt. Match op merchantReference en negeer elke refund waar success=true is maar de oorspronkelijke capture al SETTLED is.
  3. Buckaroo · brq_statuscode=190 gevolgd door brq_statuscode=190. De tweede bevat een nieuw brq_transactions-id maar hetzelfde brq_invoicenumber. Push v2 herhaalt de oorspronkelijke refund-webhook omdat de chargeback-flow het parent transaction-id niet teruggeeft aan je endpoint.
  4. Mollie · een refund die vastzit in processing stuurt de webhook bij elke status-poll. Als je handler state advance't bij elk event in plaats van alleen bij transitions, lijkt elke poll een nieuwe refund-aanvraag.
  5. Adyen · NOTIFICATION_OF_CHARGEBACK direct gevolgd door CHARGEBACK_REVERSED kan racen. Als je queue FIFO is maar je handlers concurrent draaien, kan het reversed event finaliseren vóórdat de chargeback geregistreerd is, waarna reconciliatie de order terug op paid zet zonder enige audit trail van wat er net gebeurde.
Waarschuwing

Als je handler niet idempotent is op de tuple (event_id, status, amount_cents), kost één dashboard-chargeback-flip je de order twee keer. We hebben het elf keer zien gebeuren.

De minimaal werkbare fix is een idempotency-log van één tabel, transactioneel weggeschreven met de side-effect:

CREATE TABLE webhook_events (
  psp           text     NOT NULL,
  event_id      text     NOT NULL,
  status        text     NOT NULL,
  amount_cents  integer  NOT NULL,
  received_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (psp, event_id, status, amount_cents)
);

Insert voordat je iets doet. Als de insert een unique_violation gooit, return 200 en loop weg. Zowel Adyen als Mollie onderschrijven dit patroon in hun webhook best practices en webhook overview, al maakt geen van beide docs het chargeback-replay-geval expliciet — daarom blijven mensen erin trappen. Koppel de tabel aan een dead-letter queue op dezelfde tuple; elk event dat drie keer faalt op de handler-logica belandt daar en de on-call-notificatie gaat af, in plaats van dat de agent zich blijft retryen in een dubbel.

Tier 2: SEPA-mandaat-id valt weg bij partial captures boven €2.500

Deze vijf duiken pas op als je gemiddelde orderwaarde boven de €2.500 piekt. Ons Utrechtse merk heeft een groothandelstak waar B2B-captures standaard die grens passeren. Daaronder bevat de payload het SEPA-mandaat-id en haakt de volgende direct debit-run netjes aan op de capture. Daarboven vinden alle drie de PSP's een eigen manier om het kwijt te raken, en het verlies is stil: 200 OK, capture als succesvol gemarkeerd, geen veld, geen error.

  1. Adyen · CAPTURE boven €2.500 verwijdert mandate.reference uit additionalData. De capture zelf slaagt, de webhook geeft 200, het mandaat-id is weg. Re-fetch uit /v68/payments/{pspReference}/details direct na de capture.
  2. Buckaroo · een partial SEPA-capture geeft brq_service_sepadirectdebit_mandatereference terug als lege string. Geen null. Lege string. Check je if mandate_ref:, dan sla je de regel over; check je if mandate_ref is not None:, dan sla je een leeg mandaat op en faalt de volgende direct debit-run twee weken later met een generieke upstream error.
  3. Mollie · de partial settlement-webhook laat mandateId volledig weg. Gedocumenteerd gedrag, makkelijk te missen. Je moet /v2/payments/{id} re-fetchen na elke settlement en het mandaat opslaan tegen de capture-regel, niet tegen de order.
  4. Adyen · captures boven €2.500 op SEPA-corporate routeren via een andere additionalData-key. Het mandaat zit onder sepa.mandateId in plaats van mandate.reference. De Adyen SEPA Direct Debit-reference vermeldt dat de corporate-flow bestaat; hij somt de keywissel niet op.
  5. Buckaroo · brq_mutationtype schakelt stil over op Collecting bij partial captures. Het mandaat-id verhuist naar brq_relatedtransaction, één niveau hoger in de payload. Je JSONPath breekt; je handler logt niets omdat het path naar undefined resolved en je nullish coalesce het verborg.

De reconstructie-code die we uiteindelijk live zetten is kort en lelijk. Het is ook het enige dat de SEPA-incassoruns sinds april schoon heeft gehouden:

async function resolveMandateId(psp: Psp, paymentId: string): Promise<string> {
  switch (psp) {
    case "mollie": {
      const p = await mollie.payments.get(paymentId);
      if (!p.mandateId) throw new Error(`mollie ${paymentId} has no mandate`);
      return p.mandateId;
    }
    case "adyen": {
      const d = await adyen.paymentsDetails(paymentId);
      return d.additionalData["mandate.reference"]
          ?? d.additionalData["sepa.mandateId"]
          ?? (() => { throw new Error(`adyen ${paymentId} mandate missing`); })();
    }
    case "buckaroo": {
      const t = await buckaroo.transaction(paymentId);
      const ref = t.Services
        ?.find(s => s.Name === "sepadirectdebit")
        ?.Parameters?.find(p => p.Name === "MandateReference")?.Value;
      return ref?.trim()
          || t.RelatedTransactions?.[0]?.MandateReference
          || (() => { throw new Error(`buckaroo ${paymentId} mandate missing`); })();
    }
  }
}

Tier 3: de negen die vooral ruis veroorzaken

Genummerd omdat de cheatsheet daadwerkelijk genummerd is. Dit zijn de items waar het on-call-document naar wijst als de pager afgaat en niemand zich nog herinnert welke PSP wat doet.

  1. Mollie · de webhook vuurt twee keer bij de eerste delivery. Eén keer met status=open, één keer met status=paid, vaak binnen dezelfde seconde. Geen bug; staat in de docs. Je handler moet het tolereren.
  2. Adyen · de HMAC-signature breekt als je edge proxy JSON-keys herordent. Cloudflare workers, AWS API Gateway-transformaties, alles wat de body round-tript via JSON.parse → JSON.stringify schrijft de keyvolgorde om. Verifieer tegen de ruwe request-bytes. De HMAC verification guide is hier expliciet over.
  3. Buckaroo · legacy push-endpoints accepteren nog steeds SHA1-signatures. Push v2 vereist SHA512. Als je keys rouleert en vergeet de algorithm-header bij te werken, blijft het oude endpoint gewoon verifiëren. Fail closed: weiger alles wat geen SHA512 is.
  4. Mollie · order-webhooks sturen een payment-id, geen order-id, voor Klarna partial captures. Je foreign key explodeert tenzij je beide meeneemt.
  5. Adyen · split-payment PAUSED-notificaties komen zo'n 30 seconden na AUTHORISATION binnen. Als je finaliseert op AUTH, sla je de split helemaal over en wordt je marketplace-verkoper nooit uitbetaald.
  6. Buckaroo · de idempotency-token zit in de body, niet in de header. brq_test ziet eruit als een header-veld; dat is het niet. Lees de body voordat je dedupliceert.
  7. Mollie · het chargebacks-endpoint hergebruikt het chargeback-id voor de reversal. Alleen reversedAt onderscheidt ze. Behandel reversedAt != null als een aparte state, geen soft-delete.
  8. Adyen · pspReference en merchantReference kunnen botsen als je underscores toestaat. Hou het aan de merchant-kant strikt alfanumeriek.
  9. Buckaroo · ordernummers worden bij de legacy connector afgekapt op 10 tekens. Stille truncatie; reconciliatie tegen je warehouse oogt correct totdat het elfde teken ertoe doet.

Wat er veranderde nadat we de lijst schreven

Drie dingen. Eén: elke PSP kreeg zijn eigen adapter, zonder gedeelde types ertussen. De verleiding om de payloads te harmoniseren naar één WebhookEvent-shape was de erfzonde. De shapes zijn niet hetzelfde, en doen alsof is precies hoe mandaat-ids en parent transaction-references verdwijnen in de middenlaag. Twee: elke side-effect zit nu achter de idempotency-tabel hierboven, en die tabel is de single source of truth voor of de agent al op een event gereageerd heeft. Drie: SEPA-mandaat-ids worden bij elke capture opnieuw opgehaald uit de PSP-API, nooit vertrouwd vanuit de webhook-payload, en opgeslagen tegen de capture-regel in plaats van tegen de order.

Het percentage dubbele refunds sinds de rewrite is nul. De SEPA-incassofaalkans is één op ongeveer 8.000 captures, allemaal veroorzaakt door echte mandaat-intrekkingen aan bankzijde en niet doordat wij het id ergens onderweg kwijtraken. De on-call-belasting van het team daalde van een piep om 2 uur 's nachts ongeveer om de week naar één enkele piep in de laatste zestig dagen, en die had niks met webhooks te maken.

Toen we deze order-agent bouwden voor het Utrechtse merk, was het ding dat de rollout bijna kelderde de aanname dat drie PSP-webhooks samen te voegen waren tot één event-shape. Dat kan niet. Ons werk aan AI-agents die met geld werken begint vanuit de aanname dat de payment-laag liegt en dat de database de enige bron van waarheid is.

Het kleinste dat je vandaag kunt doen

Open je webhook-handler. Zoek de regel waar je de refund schrijft. Voeg er één regel boven een INSERT … ON CONFLICT DO NOTHING toe tegen een tabel met key (psp, event_id, status, amount_cents). Deploy. Dat is de 80%-fix; de rest van deze lijst is de resterende 20%.

Kern

Als je webhook-handler niet idempotent is op (event_id, status, amount), refundt één chargeback-flip dezelfde order twee keer.

FAQ

Kan ik één webhook-handler hergebruiken voor Mollie, Adyen en Buckaroo?

Niet veilig. Ze verschillen in signature-algoritme, retry-cadans en welke events het parent transaction-id meedragen. Een dunne adapter per PSP plus een gedeelde idempotency-tabel is de kleinste correcte setup.

Waarom wordt refund.created opnieuw verstuurd na een handmatige chargeback-flip?

Sommige PSP's behandelen de dashboard-chargeback-resolutie als een nieuwe state transition op de oorspronkelijke refund en spelen de webhook opnieuw af. Zonder idempotentie op (event_id, status) stuurt je agent een tweede refund.

Wat is de veiligste manier om het SEPA-mandaat-id vast te leggen bij een partial capture?

Vertrouw de webhook-payload niet boven €2.500. Re-fetch de payment uit de PSP-API na elke capture en sla het mandaat-id op tegen de capture-regel, niet tegen de parent-order.

integrationsautomationai agentse-commerceworkflowoperations

Iets bouwen?

Start een project