← Blog

Integrations

Mollie and Adyen webhook quirks: 19 dunning gotchas

A 34-person Rotterdam subscription-box operator was losing chargeback reversals to silent 200 OKs. Here is the nineteen-quirk cheatsheet we pinned afterwards.

Jacob Molkenboer· Founder · A Brand New Company· 12 Jun 2026· 8 min
Cream wax-sealed envelope on linen blotter, chartreuse silk ribbon, brass paper clip, folded carbon receipt, ivory surface.

The Rotterdam subscription-box operator runs eight thousand SEPA mandates and a finance lead who can read PHP. We had shipped their dunning agent six weeks earlier. Every Tuesday morning, the same Slack message would land in our channel: a payment ID, a screenshot of the chargeback ledger, and one sentence. Why is this one back as paid and we did not see it?

The agent had returned 200 OK on every webhook. Mollie was happy. Adyen was happy. The agent's logs showed nineteen successfully processed notifications for that exact mandate that month. And yet the chargeback-reversal event that mattered, the one that should have told us to reinstate a paying customer and chase the failed top-up, had been quietly dropped on the floor.

This is the cheatsheet we pinned to that channel after we hunted down all nineteen quirks. It is biased toward the silent failure mode: events that return 200 and swallow a chargeback-reversal on a tokenised SEPA mandate. That was the one costing the operator real customers.

The shape of a silent 200

Both Mollie and Adyen treat 200 OK as a permanent acknowledgement. You said you got it. They will not send it again. If your handler returned 200 because the JSON parsed and the queue insert was wrapped in a try/catch that swallowed a duplicate-key violation, the event is gone. The retry policy is your only insurance, and you just cancelled it.

This is the failure mode behind every autonomous system that reports success on the audit log while the bill spirals in production. Silent success is the most expensive bug an agent can ship. A dunning agent that 200s a chargeback reversal and never reinstates the mandate burns a paying customer for the price of one try/catch.

Two rules came out of this engagement:

First, defer the 200 until the event is durably written to a queue you control. Not your business database. A queue. Databases have triggers, schemas, and migrations that can fail in interesting ways. Queues are deliberately dumb. Second, treat unknown event types as park-and-page, never as 200-and-drop. The number of times Adyen has shipped a new eventCode without anyone in the room remembering is not zero.

Mollie's lazy state model

Mollie's webhook body contains exactly one useful field: the payment ID, posted as application/x-www-form-urlencoded. To know what actually happened, you fetch the payment. The fetch is the source of truth, and the fetch is where the chargeback-reversal trail lives.

<?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);

Three things to notice. The chargeback reversal is not a separate webhook; Mollie pings the same URL and you have to re-fetch the payment with the chargebacks embedded to see reversedAt. The mandateId on the payment is the mandate used at the moment of the original charge; by the time the reversal arrives, your subscription may have switched to a new mandate after a bank reissue, and reinstating the wrong one is a quiet way to break trust. And settlement reports often surface chargebacks before the webhook does. Reconcile both directions, not just inbound.

If you have not been back to Mollie's docs in a year, the webhook reference and the chargebacks API are the two pages we keep open during a dunning rebuild.

Adyen's multi-event chargeback flow

Adyen posts a batch of notificationItems per request. The expected success body is the literal string [accepted], plain text, not JSON, not 204. Get that wrong and Adyen retries the whole batch, including the items you already processed cleanly.

// /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]');
});

The chargeback flow is three events that can arrive in any order and sometimes weeks apart. NOTIFICATION_OF_CHARGEBACK is the heads-up. CHARGEBACK is the debit. CHARGEBACK_REVERSED returns the funds. Adyen lays out the lifecycle clearly in their dispute notifications guide, and we still get it wrong every six months.

Two further quirks to bake in. additionalData is string-typed by default; booleans arrive as "true" and "false", expiry dates are strings, and even integer-looking codes are documented as strings. Coerce in one place at the top of your handler, never inline at the consumer. And then SECOND_CHARGEBACK. That is the trap. A reversed chargeback can be reopened by the issuing bank weeks later. The mandate is not safe just because you saw CHARGEBACK_REVERSED yesterday. Wait the full SEPA window before you promote the customer back to a higher trust tier.

There is also a race on tokenisation. RECURRING_CONTRACT confirms the token was stored, but it can arrive after the first AUTHORISATION on that token. Your handler will see a charge against a token you do not yet recognise. Park, do not reject.

The SEPA tail nobody plans for

SEPA Core direct debit gives the payer an unconditional eight-week reversal window for any reason at all. After that, an unauthorised transaction can still be disputed for up to thirteen months. Thirteen months is longer than the life expectancy of most subscription products.

Warning

A SEPA chargeback can land thirteen months after the original collection. Your dunning agent's idempotency table must keep mandate state for at least that long, or a late chargeback reversal will lookup-miss and your handler will 200 silently.

We learned this the expensive way. The Rotterdam operator had been pruning their webhook event log at ninety days because the table was getting fat. The first chargeback reversal that landed on a ninety-one-day-old payment found no parent record, the handler swallowed the foreign-key error, returned 200, and Adyen marked the event delivered. The customer stayed churned. We caught it on the next settlement reconciliation, three weeks later. The fix is simple. Keep the raw event for thirteen months. Keep the derived state forever. Storage is cheaper than a lost customer.

The cheatsheet

Pin this to your channel. We did.

Mollie

  1. The webhook body contains only id. Fetch the payment to know what happened.
  2. A webhook can fire while payment.status is still open. Handlers must be idempotent and re-entrant.
  3. Subscription webhooks fire on the same URL as one-off payments. Discriminate by subscriptionId on the fetched payment.
  4. Chargebacks do not get a dedicated event type. The payment status flips to charged_back and a chargeback object appears.
  5. Chargeback reversal is only visible via reversedAt on the embedded chargeback. Always fetch with embed=chargebacks.
  6. Mandate revocations by the payer surface only when the next charge attempt fails. There is no proactive mandate-revoked event.
  7. Mollie retries for about 24 hours on non-200. A 200 with a swallowed exception cancels the retry permanently.
  8. The mandateId on a payment is the mandate used at charge time. The subscription may now point elsewhere.
  9. Settlement reports can list chargebacks before the webhook arrives. Reconcile both ways.

Adyen

  1. Notifications arrive batched as notificationItems. One bad item must not fail the whole batch.
  2. The success body is the literal string [accepted]. Not JSON. Not 204.
  3. HMAC signs each NotificationRequestItem, not the envelope. Verify per item.
  4. success: true means the event was conveyed, not that the payment succeeded. Always read eventCode and the result fields.
  5. Chargeback flow is NOTIFICATION_OF_CHARGEBACK then CHARGEBACK then CHARGEBACK_REVERSED. Separate POSTs, possibly out of order.
  6. SECOND_CHARGEBACK can arrive after CHARGEBACK_REVERSED. The mandate is not safe yet.
  7. additionalData is mostly string-typed, including booleans. Coerce in one place.
  8. The same pspReference is reused for the authorisation and the chargeback. Idempotency must key on (eventCode, pspReference).
  9. New eventCode values are added without ceremony. Unknown events must park, not 200-and-drop.
  10. RECURRING_CONTRACT confirms tokenisation. It can arrive after the first AUTHORISATION on that token.

What we shipped

When we built the dunning agent for the Rotterdam operator, the thing we kept tripping over was the silent 200. We ended up moving every webhook handler behind a thin enqueue-only layer that defers the acknowledgement until the raw event is durably written to a Redis stream, and we added a nightly reconciliation job that compares the settlement file from each gateway against the derived ledger. The chargeback-reversal misses dropped to zero in the next billing cycle. Most of our AI agents work for subscription operators now starts at the webhook boundary, because that is where the money quietly leaks.

Open your webhook handler today, find the line that returns 200, and ask one question: is the event durably written to something that is not the same database that holds your business logic? If the answer is no, that is the five-minute audit worth doing this morning.

Key takeaway

On Mollie and Adyen, 200 OK is permanent and chargeback reversals hide inside re-fetched objects, not their own event types. Defer the ack until the raw event is durably queued.

FAQ

Does Mollie send a separate webhook for chargeback reversal?

No. The same payment webhook URL fires again, and you discover the reversal by re-fetching the payment with embed=chargebacks and reading reversedAt on the chargeback object.

What is the correct HTTP response body for an Adyen webhook?

The literal string [accepted] with HTTP 200, content type text/plain. Not JSON, not 204. Anything else triggers a full-batch retry even when half the batch processed cleanly.

How long do I need to keep SEPA webhook records?

At least thirteen months. SEPA Core allows unauthorised-transaction disputes up to thirteen months after the original collection date, and a late reversal will lookup-miss any shorter retention.

Why do my Adyen authorisation and chargeback share the same pspReference?

Adyen reuses the original pspReference across the dispute lifecycle by design. Key your idempotency table on (eventCode, pspReference), not pspReference alone, or the chargeback will collapse into the original payment record.

integrationsai agentsautomationarchitectureoperations

Building something?

Start a project