← Blog

Integrations

Mollie, Adyen, Buckaroo webhooks: 19 quirks from a rollout

At 02:14 on a Tuesday in March, the order-agent we built for a 24-person Utrecht D2C brand refunded the same €38,50 order eleven times. Mollie, Adyen, and Buckaroo each contributed.

Jacob Molkenboer· Founder · A Brand New Company· 20 Jun 2026· 9 min
Three brass discs, green ribbon through one, beside a creased receipt, red wax seal, dark leather blotter on ivory paper.

At 02:14 on a Tuesday in March, the order-agent we shipped six weeks earlier for a 24-person D2C brand near Utrecht Centraal refunded the same €38,50 jar of fermented hot sauce eleven times. The customer had filed a chargeback at her bank on the Friday. Her bank reversed it on the Monday. The agent — wired to Mollie for cards, Adyen for SEPA Direct Debit, and Buckaroo for iDEAL — caught the reversal and tried to cancel the refund. Then it caught the cancellation. Then it caught Mollie firing refund.created again because, in Mollie's view, the chargeback-flip looked like a brand-new state transition on the original refund.

By the time on-call paged, three more orders had drained the same way. By 09:00 the next morning, the team had a list of nineteen webhook behaviours that needed naming, owning, and writing down. This post is that list, ranked by which ones cost real money and which ones just generate noise.

How the cheatsheet got built

The brand ships about 40,000 orders a month, split roughly 55% iDEAL, 30% card, 15% SEPA Direct Debit on a wholesale arm whose ticket sizes routinely clear €2,500. Our brief was an order-agent that triages support email, issues refunds for the obvious cases, and escalates anything ambiguous to a human reviewer. The webhook layer was supposed to be the easy part. Three PSPs, three docs sites, one adapter each. Done by Friday.

It was not done by Friday. Within 72 hours of go-live we had eight duplicate refunds, two missing SEPA mandates on partial captures, and one HMAC signature mismatch that turned out to be a Cloudflare worker normalising JSON whitespace upstream. We froze writes, ran every webhook through a shadow queue for ten days, and diffed what each PSP actually sent against what its docs said it would send. The output is below, in three tiers.

Tier 1: refund-replay after a manual chargeback flip

These are the five that cost real money. All five fire after a dashboard resolution or a bank-side chargeback reversal and convince a naive handler that a new refund needs issuing. If your handler is missing idempotency on the event tuple, these are where the money walks out.

  1. Mollie · refund.created replays for up to 24 hours after a chargeback flip. The refund id is reused; only createdAt shifts. If your handler keys on payment id, you will refund twice. Key on (refund.id, status) and treat the second event as a no-op.
  2. Adyen · REFUND_WITH_DATA arrives after a won dispute. The originalReference field is sometimes null when the reversal hits via Schemes-Disputes. Match on merchantReference and ignore any refund where success=true but the original capture is already SETTLED.
  3. Buckaroo · brq_statuscode=190 followed by brq_statuscode=190. The second one carries a new brq_transactions id but the same brq_invoicenumber. Push v2 retries the original refund webhook because the chargeback flow does not carry the parent transaction id back to your endpoint.
  4. Mollie · refund stuck in processing emits the webhook on every status poll. If your handler advances state on every event rather than only on transitions, every poll looks like a fresh refund request.
  5. Adyen · NOTIFICATION_OF_CHARGEBACK immediately followed by CHARGEBACK_REVERSED can race. If your queue is FIFO but your handlers are concurrent, the reversed event can finalise before the chargeback is recorded, and reconciliation flips the order back to paid with no audit trail of what just happened.
Warning

If your handler is not idempotent on the tuple (event_id, status, amount_cents), a single dashboard chargeback flip will cost you the order twice. We have seen it cost the order eleven times.

The minimum viable fix is a one-table idempotency log, written transactionally with the 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 before you act. If the insert raises unique_violation, return 200 and walk away. Both Adyen and Mollie endorse this pattern in their webhook best practices and webhook overview, though neither doc makes the chargeback-replay case explicit, which is why people keep getting bitten by it. Pair the table with a dead-letter queue keyed on the same tuple; any event that fails handler logic three times lands there and the on-call notification fires, instead of the agent retrying into a duplicate.

Tier 2: SEPA mandate id dropped on partial captures over €2,500

These five only show up when your average order value spikes above €2,500. Our Utrecht brand has a wholesale arm whose B2B captures routinely clear that line. Below it, the payload includes the SEPA mandate id and the next direct debit run cleanly chains off the capture. Above it, all three PSPs find a different way to lose it, and the loss is silent: 200 OK, capture marked successful, no field, no error.

  1. Adyen · CAPTURE over €2,500 strips mandate.reference from additionalData. The capture itself succeeds, the webhook returns 200, the mandate id is gone. Re-fetch from /v68/payments/{pspReference}/details immediately after capture.
  2. Buckaroo · partial SEPA capture returns brq_service_sepadirectdebit_mandatereference as the empty string. Not null. Empty string. If you check if mandate_ref: you will skip the row; if you check if mandate_ref is not None: you will store an empty mandate and fail the next direct debit run with a generic upstream error two weeks later.
  3. Mollie · partial settlement webhook omits mandateId entirely. Documented behaviour, easy to miss. You must re-fetch /v2/payments/{id} after every settlement and persist the mandate against the capture row, not against the order.
  4. Adyen · captures over €2,500 on SEPA-corporate route through a different additionalData key. The mandate lives under sepa.mandateId rather than mandate.reference. The Adyen SEPA Direct Debit reference notes the corporate flow exists; it does not enumerate the key change.
  5. Buckaroo · the brq_mutationtype silently switches to Collecting for partial captures. The mandate id moves to brq_relatedtransaction, one level up in the payload. Your JSONPath breaks; your handler logs nothing because the path resolved to undefined and your nullish coalesce hid it.

The reconstruction code we ended up shipping is short and ugly. It is also the only thing that has kept the SEPA collection runs clean since April:

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: the nine that mostly make noise

Numbered because the cheatsheet is genuinely numbered. These are the entries the on-call doc points at when the pager goes off and nobody can remember which PSP does what.

  1. Mollie · webhook fires twice on first delivery. Once with status=open, once with status=paid, often inside the same second. Not a bug; the docs say so. Your handler must tolerate it.
  2. Adyen · HMAC signature breaks if your edge proxy reorders JSON keys. Cloudflare workers, AWS API Gateway transformations, anything that round-trips the body through JSON.parse → JSON.stringify will rewrite key order. Verify against the raw request bytes. The HMAC verification guide is explicit about this.
  3. Buckaroo · legacy push endpoints still accept SHA1 signatures. Push v2 requires SHA512. If you rotate keys and forget to bump the algorithm header, the old endpoint will keep verifying. Fail closed: reject anything not SHA512.
  4. Mollie · order webhooks send a payment id, not an order id, for Klarna partial captures. Your foreign key blows up unless you carry both.
  5. Adyen · split-payment PAUSED notifications arrive about 30 seconds after AUTHORISATION. If you finalise on AUTH, you skip the split entirely and your marketplace seller never gets paid.
  6. Buckaroo · the idempotency token sits in the body, not the header. brq_test looks like a header field; it is not. Read the body before you dedupe.
  7. Mollie · chargebacks endpoint reuses the chargeback id for the reversal. Only reversedAt distinguishes them. Treat reversedAt != null as a separate state, not a soft-delete.
  8. Adyen · pspReference and merchantReference can collide if you allow underscores. Stick to alphanumeric on the merchant side.
  9. Buckaroo · order numbers truncate at 10 chars on the legacy connector. Silent truncation; reconciliation against your warehouse will look correct until the eleventh character matters.

What changed after we wrote the list

Three things. One: every PSP got its own adapter with no shared types between them. The temptation to harmonise the payloads into a unified WebhookEvent shape was the original sin. The shapes are not the same and pretending they are is how mandate ids and parent transaction references disappear into the middle layer. Two: every side-effect now sits behind the idempotency table above, and the table is the single source of truth for whether the agent has already acted on an event. Three: SEPA mandate ids are re-fetched from the PSP API on every capture, never trusted from the webhook payload, and persisted against the capture row rather than against the order.

The duplicate-refund rate since the rewrite is zero. The SEPA collection failure rate is one in roughly 8,000 captures, all caused by genuine bank-side mandate revocations rather than us losing the id somewhere in transit. The team's on-call burden dropped from a 2am page roughly every other week to a single page in the last sixty days, which was unrelated to webhooks.

When we built this order-agent for the Utrecht brand, the thing that nearly sank the rollout was assuming three PSP webhooks could be merged into one event shape. They cannot. Our work on AI agents that touch money starts from the assumption that the payment layer lies, and the database is the only source of truth.

The smallest thing you can do today

Open your webhook handler. Find the line where you write the refund. Above it, add an INSERT … ON CONFLICT DO NOTHING against a table keyed on (psp, event_id, status, amount_cents). Deploy. That is the 80% fix; the rest of this list is the remaining 20%.

Key takeaway

If your webhook handler is not idempotent on (event_id, status, amount), one chargeback flip will refund the same order twice.

FAQ

Can I reuse one webhook handler for Mollie, Adyen, and Buckaroo?

Not safely. They differ on signature algorithm, retry cadence, and which events carry the parent transaction id. A thin adapter per PSP plus a shared idempotency table is the smallest correct setup.

Why does refund.created get retried after a manual chargeback flip?

Some PSPs treat the dashboard chargeback resolution as a new state transition on the original refund and replay the webhook. Without idempotency on (event_id, status), your agent will issue a second refund.

What's the safest way to capture the SEPA mandate id on a partial capture?

Don't trust the webhook payload above €2,500. Re-fetch the payment from the PSP API after every capture and persist the mandate id against the capture row, not the parent order.

integrationsautomationai agentse-commerceworkflowoperations

Building something?

Start a project