← Blog

Integrations

Mollie en Adyen webhooks: 19 valkuilen voor dunning agents

Een Rotterdamse subscription-box met 34 mensen zag terugboekingen verdwijnen achter stille 200 OK's. Dit is de cheatsheet met 19 valkuilen die we daarna ophingen.

Jacob Molkenboer· Oprichter · A Brand New Company· 12 jun 2026· 8 min
Crème envelop met waszegel op linnen onderlegger, lichtgroen zijden lint, koperen paperclip, gevouwen doorslagbon op ivoor.

De Rotterdamse subscription-box runt achtduizend SEPA-mandaten en heeft een finance lead die PHP kan lezen. Wij hadden hun dunning agent zes weken eerder opgeleverd. Elke dinsdagochtend belandde hetzelfde Slack-bericht in ons kanaal: een payment-ID, een screenshot van het terugboekingsoverzicht, en één zin. Waarom staat deze weer op betaald en hebben we dat niet gezien?

De agent had op elke webhook 200 OK teruggegeven. Mollie was tevreden. Adyen was tevreden. De logs van de agent toonden negentien succesvol verwerkte notificaties voor dat exacte mandaat in die maand. En toch was het chargeback-reversal event dat ertoe deed, het event dat ons had moeten vertellen om een betalende klant weer in te schakelen en de mislukte top-up te chasen, geluidloos weggevallen.

Dit is de cheatsheet die we aan dat kanaal vastpinden nadat we alle negentien valkuilen hadden opgespoord. Hij is geschreven met de stille faalmodus in het achterhoofd: events die 200 teruggeven en een chargeback-reversal op een getokeniseerd SEPA-mandaat opslokken. Dat was de fout die de operator echte klanten kostte.

De vorm van een stille 200

Zowel Mollie als Adyen behandelen 200 OK als een permanente bevestiging. Je hebt gezegd dat je het hebt ontvangen. Ze sturen het niet opnieuw. Als jouw handler 200 teruggaf omdat de JSON parste en de queue-insert in een try/catch zat die een duplicate-key violation opslokte, dan is het event weg. Het retry-beleid is je enige verzekering, en die heb je net geannuleerd.

Dit is de faalmodus achter elk autonoom systeem dat succes meldt in de audit log terwijl de factuur in productie uit de hand loopt. Stil succes is de duurste bug die een agent kan opleveren. Een dunning agent die een chargeback-reversal met 200 afhandelt en het mandaat nooit herstelt, brandt een betalende klant op voor de prijs van één try/catch.

Twee regels kwamen uit deze opdracht voort:

Ten eerste, stel de 200 uit totdat het event duurzaam is weggeschreven naar een queue die je zelf beheert. Niet je business-database. Een queue. Databases hebben triggers, schema's en migraties die op interessante manieren kunnen falen. Queues zijn bewust dom. Ten tweede, behandel onbekende event types als park-and-page, nooit als 200-and-drop. Hoe vaak Adyen een nieuwe eventCode heeft uitgerold zonder dat iemand in de kamer het zich herinnerde, is niet nul.

Het luie state model van Mollie

De webhook body van Mollie bevat precies één bruikbaar veld: het payment-ID, gepost als application/x-www-form-urlencoded. Om te weten wat er echt is gebeurd, fetch je de payment. De fetch is de bron van waarheid, en in de fetch zit het spoor van de chargeback-reversal.

<?php
// /webhooks/mollie.php
$paymentId = $_POST['id'] ?? null;
if (!$paymentId) {
    http_response_code(400);
    exit;
}

// Fetch with chargebacks embedded so reversals appear in one round-trip.
$payment = $mollie->payments->get($paymentId, [
    'embed' => 'chargebacks',
]);

$status = $payment->status; // open|pending|paid|failed|charged_back|...

// A reversed chargeback leaves payment.status as 'charged_back'
// but exposes reversedAt on the chargeback object.
$reversedAt = null;
foreach ($payment->chargebacks() as $cb) {
    if (!empty($cb->reversedAt)) {
        $reversedAt = $cb->reversedAt;
    }
}

enqueue('mollie.payment', [
    'paymentId'  => $payment->id,
    'mandateId'  => $payment->mandateId,
    'customerId' => $payment->customerId,
    'status'     => $status,
    'reversedAt' => $reversedAt,
    'raw'        => $payment->toArray(),
]);

http_response_code(200);

Drie dingen om op te merken. De chargeback-reversal is geen aparte webhook; Mollie pingt dezelfde URL en je moet de payment opnieuw fetchen met de chargebacks ingesloten om reversedAt te zien. De mandateId op de payment is het mandaat dat werd gebruikt op het moment van de oorspronkelijke afschrijving; tegen de tijd dat de reversal binnenkomt, kan je subscription al overgegaan zijn naar een nieuw mandaat na een bankheruitgifte, en het verkeerde herstellen is een stille manier om vertrouwen te breken. En settlement reports laten chargebacks vaak zien voordat de webhook dat doet. Reconcileer in beide richtingen, niet alleen inkomend.

Als je het afgelopen jaar niet meer in de docs van Mollie hebt gekeken: de webhook reference en de chargebacks API zijn de twee pagina's die wij open houden tijdens een dunning rebuild.

Adyen's multi-event chargeback flow

Adyen post een batch van notificationItems per request. De verwachte success body is de letterlijke string [accepted], platte tekst, geen JSON, geen 204. Doe je dat verkeerd, dan retried Adyen de hele batch, inclusief de items die je al schoon had verwerkt.

// /webhooks/adyen.js
import express from 'express';
import { verifyHmac } from './hmac.js';

const app = express();

app.post('/webhooks/adyen', express.json(), async (req, res) => {
  const items = req.body.notificationItems || [];

  for (const wrapper of items) {
    const item = wrapper.NotificationRequestItem;

    // HMAC signs each item, not the envelope.
    if (!verifyHmac(item, process.env.ADYEN_HMAC_KEY)) {
      await deadLetter('adyen.bad_hmac', item); // park, never 200-and-drop
      continue;
    }

    // Idempotency MUST key on (eventCode, pspReference).
    // CHARGEBACK reuses the original AUTHORISATION pspReference.
    const key = `${item.eventCode}:${item.pspReference}`;
    await enqueueOnce('adyen.event', key, item);
  }

  res.status(200).type('text/plain').send('[accepted]');
});

De chargeback flow bestaat uit drie events die in willekeurige volgorde kunnen binnenkomen en soms weken uit elkaar. NOTIFICATION_OF_CHARGEBACK is de heads-up. CHARGEBACK is de afschrijving. CHARGEBACK_REVERSED betaalt het bedrag terug. Adyen legt de levenscyclus helder uit in hun dispute notifications guide, en wij doen het nog steeds elke zes maanden verkeerd.

Twee verdere quirks om in te bouwen. additionalData is standaard string-getyped; booleans komen binnen als "true" en "false", expiry dates zijn strings, en zelfs codes die op integers lijken zijn gedocumenteerd als strings. Forceer de types op één plek bovenaan je handler, nooit inline bij de consumer. En dan SECOND_CHARGEBACK. Dat is de val. Een teruggedraaide chargeback kan weken later opnieuw worden geopend door de uitgevende bank. Het mandaat is niet veilig alleen omdat je gisteren CHARGEBACK_REVERSED hebt gezien. Wacht het volledige SEPA-venster af voordat je de klant terugzet naar een hoger vertrouwensniveau.

Er is ook een race condition op tokenisatie. RECURRING_CONTRACT bevestigt dat de token is opgeslagen, maar het kan binnenkomen na de eerste AUTHORISATION op die token. Jouw handler ziet dan een afschrijving op een token die je nog niet kent. Park, niet rejecten.

De SEPA-staart waar niemand op rekent

SEPA Core direct debit geeft de betaler een onvoorwaardelijk venster van acht weken om de incasso om welke reden dan ook terug te draaien. Daarna kan een ongeautoriseerde transactie nog steeds tot dertien maanden worden betwist. Dertien maanden is langer dan de levensverwachting van de meeste subscription-producten.

Waarschuwing

Een SEPA-terugboeking kan dertien maanden na de oorspronkelijke incasso binnenkomen. De idempotency-tabel van je dunning agent moet de mandaatstatus minstens zo lang bewaren, anders mist een late chargeback-reversal de lookup en geeft je handler stilletjes 200 terug.

Wij leerden dit de dure manier. De Rotterdamse operator was zijn webhook-event-log na negentig dagen gaan opschonen omdat de tabel te dik werd. De eerste chargeback-reversal die binnenkwam op een payment van eenennegentig dagen oud vond geen parent-record, de handler slikte de foreign-key error in, gaf 200 terug, en Adyen markeerde het event als afgeleverd. De klant bleef churned. Wij vingen het drie weken later op bij de volgende settlement-reconciliatie. De fix is simpel. Bewaar het ruwe event dertien maanden. Bewaar de afgeleide state voor altijd. Storage is goedkoper dan een verloren klant.

De cheatsheet

Pin dit aan je kanaal. Wij deden het.

Mollie

  1. De webhook body bevat alleen id. Fetch de payment om te weten wat er is gebeurd.
  2. Een webhook kan vuren terwijl payment.status nog open is. Handlers moeten idempotent en re-entrant zijn.
  3. Subscription-webhooks vuren op dezelfde URL als losse betalingen. Maak onderscheid op subscriptionId in de gefetchte payment.
  4. Chargebacks krijgen geen apart event type. De payment-status klapt om naar charged_back en er verschijnt een chargeback-object.
  5. Chargeback-reversal is alleen zichtbaar via reversedAt op de ingesloten chargeback. Fetch altijd met embed=chargebacks.
  6. Mandaat-intrekkingen door de betaler komen pas naar boven als de volgende afschrijvingspoging faalt. Er is geen proactief mandate-revoked event.
  7. Mollie retried ongeveer 24 uur op non-200. Een 200 met een ingeslikte exception annuleert de retry permanent.
  8. De mandateId op een payment is het mandaat dat werd gebruikt op het moment van de afschrijving. De subscription kan inmiddels ergens anders naartoe wijzen.
  9. Settlement reports kunnen chargebacks tonen voordat de webhook binnenkomt. Reconcileer beide kanten op.

Adyen

  1. Notificaties komen gebatcht binnen als notificationItems. Eén slecht item mag niet de hele batch laten falen.
  2. De success body is de letterlijke string [accepted]. Geen JSON. Geen 204.
  3. HMAC tekent elk NotificationRequestItem, niet de envelop. Verifieer per item.
  4. success: true betekent dat het event is overgebracht, niet dat de betaling is geslaagd. Lees altijd eventCode en de result-velden.
  5. De chargeback flow is NOTIFICATION_OF_CHARGEBACK, daarna CHARGEBACK, daarna CHARGEBACK_REVERSED. Aparte POSTs, mogelijk in andere volgorde.
  6. SECOND_CHARGEBACK kan binnenkomen na CHARGEBACK_REVERSED. Het mandaat is nog niet veilig.
  7. additionalData is grotendeels string-getyped, inclusief booleans. Forceer types op één plek.
  8. Dezelfde pspReference wordt hergebruikt voor de autorisatie en de chargeback. Idempotency moet keyen op (eventCode, pspReference).
  9. Nieuwe eventCode-waardes worden zonder ceremonie toegevoegd. Onbekende events moeten parken, niet 200-and-drop.
  10. RECURRING_CONTRACT bevestigt tokenisatie. Het kan binnenkomen na de eerste AUTHORISATION op die token.

Wat wij hebben opgeleverd

Toen wij de dunning agent voor de Rotterdamse operator bouwden, was de stille 200 het ding waar we steeds over struikelden. We zijn uiteindelijk elke webhook-handler achter een dunne enqueue-only laag gaan zetten die de bevestiging uitstelt totdat het ruwe event duurzaam is weggeschreven naar een Redis stream, en we hebben een nachtelijke reconciliatie-job toegevoegd die het settlement-bestand van elke gateway vergelijkt met het afgeleide grootboek. De gemiste chargeback-reversals zakten in de volgende facturatie-cyclus naar nul. Het meeste werk dat we tegenwoordig voor subscription-operators doen aan AI-agents begint bij de webhook-grens, want dat is waar het geld stilletjes weglekt.

Open je webhook-handler vandaag, vind de regel die 200 teruggeeft, en stel één vraag: is het event duurzaam weggeschreven naar iets dat niet dezelfde database is waar je business-logica staat? Is het antwoord nee, dan is dat de audit van vijf minuten die het waard is om vanochtend te doen.

Kern

Bij Mollie en Adyen is 200 OK permanent en chargeback-reversals verstoppen zich in opnieuw gefetchte objecten, niet in eigen event types. Stel de ack uit tot het ruwe event duurzaam in de queue staat.

FAQ

Stuurt Mollie een aparte webhook voor chargeback-reversal?

Nee. Dezelfde payment-webhook URL vuurt opnieuw, en je ontdekt de reversal door de payment opnieuw te fetchen met embed=chargebacks en reversedAt op het chargeback-object te lezen.

Wat is de juiste HTTP response body voor een Adyen-webhook?

De letterlijke string [accepted] met HTTP 200, content type text/plain. Geen JSON, geen 204. Alles anders triggert een full-batch retry, ook als de helft van de batch al netjes is verwerkt.

Hoe lang moet ik SEPA-webhook records bewaren?

Minstens dertien maanden. SEPA Core staat geschillen over ongeautoriseerde transacties toe tot dertien maanden na de oorspronkelijke incassodatum, en een late reversal mist de lookup bij elke kortere bewaartermijn.

Waarom delen mijn Adyen-autorisatie en chargeback dezelfde pspReference?

Adyen hergebruikt de oorspronkelijke pspReference door de hele dispute-levenscyclus, by design. Key je idempotency-tabel op (eventCode, pspReference), niet op pspReference alleen, anders klapt de chargeback in elkaar met de oorspronkelijke payment-record.

integrationsai agentsautomationarchitectureoperations

Iets bouwen?

Start een project