Integrations
Mollie, Adyen, Buckaroo webhooks: 15 refund quirks ranked
A Utrecht ticketing platform asked us to automate refunds across three PSPs. The list below is what survived chargeback retries, silent metadata drops, and a SEPA storno paid twice.

The email arrived at 23:14 on a Tuesday. The founder of a 24-person ticketing platform in Utrecht had 312 refunds in the queue, an ops lead on holiday in Naxos, and a Buckaroo SEPA-incasso storno that had just paid the same €87 refund out twice. They wanted the whole flow automated by month-end, across Mollie, Adyen, and Buckaroo.
We took the job. What follows is the cheatsheet that came out of that rollout: fifteen webhook quirks across the three Dutch payment service providers, ranked by how much money or sleep they cost us. The list is opinionated. It is also the document we now hand to every client doing PSP work, because almost none of these are in the happy-path docs.
The setup that exposed every quirk
The platform sells two product lines. Single-event tickets go through Mollie (iDEAL and card, sub-€100 average order). Season passes and B2B group bookings run on Adyen (higher ticket sizes, MOTO accepted). A small but persistent segment of older buyers prefer SEPA-incasso for season passes, which routes to Buckaroo, because Buckaroo's direct-debit handling is still the cleanest in NL.
Refund volume sits around 4 to 6 percent of orders, with spikes after weather-cancelled outdoor events. Before the rollout, two ops staff spent ten hours a week chasing refunds across three dashboards. The brief was: every refund initiated from the support tool fires the right PSP call, posts status back into the ledger, and surfaces failures in Slack before a customer complains. Standard stuff. The fifteen quirks below are why it took six weeks instead of two.
The five quirks that cost real money
These are ranked first because each one cost the client a measurable amount before we caught it. If you only read one section, read this one.
1. Buckaroo SEPA storno retries six times in 90 seconds
When a customer reverses a SEPA direct debit, Buckaroo's Push notification for status code 690 (Pay Failure) does not arrive once. It arrives six times in the first 90 seconds, then twice more over the next hour, then sporadically for 24 hours. There is no deduplication header. The Transaction key is identical every time; only BRQ_TIMESTAMP changes.
The first version of our handler treated each push as authoritative and issued a compensating credit. That fired six times. The fix is a database-level unique constraint on (provider, transaction_key, status_code), not application-level checking, because the retries land in parallel and your "have we seen this?" SELECT will race itself.
CREATE UNIQUE INDEX webhook_dedup_idx
ON psp_events (provider, transaction_key, status_code);
-- INSERT ... ON CONFLICT DO NOTHING is your friend.
-- Process the event only when the insert returned a new row.
INSERT INTO psp_events (provider, transaction_key, status_code, raw_body)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, transaction_key, status_code) DO NOTHING
RETURNING id;
Treat every PSP webhook handler as if it will receive each event between two and ten times. Idempotency is not a nice-to-have; it is the contract.
2. Adyen returns 200 OK while dropping additionalData on a SEPA reversal
For SEPA direct-debit reversals, Adyen's notification payload includes the additionalData block on the original AUTHORISATION but trims it on the subsequent CHARGEBACK_REVERSED or REFUND_FAILED notifications. If you stored your internal order_id only inside additionalData, the reversal event arrives with the pspReference matched correctly, an HTTP 200 OK expected back, and your order_id field empty.
The lookup fails silently. You return 200, Adyen marks the webhook delivered, and your support tool never learns the refund actually rolled back. The fix is to always persist the (pspReference → internal_order_id) mapping on the original payment, then key off pspReference for every subsequent event. The field expectations are in Adyen's notification reference: the table makes it explicit which fields are guaranteed per event type and which are not.
3. Mollie refund webhook fires twice when the refund was created in both Dashboard and API
If a support agent creates a refund from the Mollie Dashboard and your background reconciler also kicks off a refund call for the same payment within the same minute (because the support tool has not yet seen the dashboard action), Mollie creates two refunds. Both succeed. Both fire webhooks. The customer is refunded twice.
This sounds like a process problem, and it is, but it is also a webhook problem because there is no race-prevention API. The fix is a Redis lock keyed on the Mollie payment id with a 60-second TTL around any refund issuance, plus a policy that support never refunds directly from the Mollie Dashboard.
4. Adyen NOTIFICATION_OF_CHARGEBACK can arrive after CHARGEBACK
The two events are designed as a sequence: notification first, then the actual chargeback. In practice they can arrive out of order, or NOTIFICATION_OF_CHARGEBACK can be skipped entirely for some card schemes. If your state machine requires the notification before transitioning, certain disputes get stuck in limbo until a human intervenes.
Build the state machine to accept CHARGEBACK as a terminal event whether or not it was preceded by NOTIFICATION_OF_CHARGEBACK. Use the latter as a hint to start preparing defence documents, not as a gate.
5. Buckaroo Status_FailureURL fires for both real failures and user cancellations
Buckaroo posts to your Status_FailureURL when a payment fails AND when a user clicks "cancel" mid-flow. Both arrive with BRQ_STATUSCODE 490 (Failed). The distinguishing field is BRQ_STATUSCODE_DETAIL, which is 502 for a real bank refusal and S001 for a user cancellation. If you alert ops on every failure, you will train them to ignore the channel inside a week.
The five quirks that break reconciliation
6. Mollie webhooks contain only an ID, never the event payload
This is documented but constantly forgotten. Mollie's webhook body is x-www-form-urlencoded with a single field: id=tr_xxx. You re-fetch the payment from the API to get state. The reason is sensible (signed source of truth, no payload tampering) but it doubles your latency budget and means an API outage breaks webhook processing. See the Mollie webhook reference for the design rationale.
7. Adyen's .live field is the string "false" not a boolean
Easy to miss in code review. If you write if (notification.live) { ... }, that string evaluates truthy and your test events fire production logic. Use a strict equality check against the string "true", or parse it once at the edge of the handler and never trust the raw value downstream.
8. Buckaroo signs with SHA-512 by default but happily accepts SHA-1
The signature algorithm is configurable in the Buckaroo merchant panel, and SHA-1 is still a valid setting. If you inherited an old configuration, your verification code may be checking SHA-1 and silently rejecting SHA-512 pushes (or worse, accepting unsigned ones). Flip the toggle to SHA-512, then verify your code handles the algorithm header on both old and new flows.
9. Mollie chargeback notifications omit refund.id when a partial refund preceded
If a payment had a partial refund issued and then received a chargeback for the remaining amount, Mollie's chargeback webhook payload does not carry the related refund.id back. Your "link this chargeback to the refund that triggered it" logic returns null. Match on payment_id and chargeback_id instead, and accept that the refund linkage requires a separate API call.
10. Adyen batches multiple events in notificationItems[]
The Adyen notification webhook posts an array of events in one HTTP request. Sample code in some old blog posts processes only notificationItems[0]. Process the array. We have seen up to eleven events in a single batch during high-volume periods.
The five quirks that only annoyed
11. Buckaroo posts everything as x-www-form-urlencoded with brq_ prefixes
Every field arrives lowercased with the brq_ prefix. Your parser needs to be case-insensitive and prefix-stripping, because the response field naming convention in their docs uses Title_Case while the wire format does not.
12. Mollie's "test webhook" button uses tr_test as the payment ID
If you trigger a test webhook from the Mollie dashboard, the id field is the literal string tr_test, which does not resolve against the API. Your handler will 404 on the fetch and look broken in the dashboard's delivery log. Short-circuit when id === "tr_test" and return 200 immediately.
13. Adyen amount.value is in minor units, and "minor" is currency-dependent
€15 arrives as 1500 (two minor units). JPY 15 arrives as 15 (zero minor units). BHD 15 arrives as 15000 (three minor units). Use a currency-aware divisor, not a hardcoded /100. The exponent table is small and stable; bake it in.
14. Buckaroo SEPA TransactionType codes C001 and C002 are not in the main webhook reference
The TransactionType for SEPA-incasso debit is C001 and the refund counterpart is C002. These appear in the Direct Debit specific docs but not in the main webhook reference. If you wrote your handler from the main reference, your SEPA events will hit the "unknown transaction type" branch and be ignored.
15. Mollie refund.failed for SEPA can fire days after the refund
SEPA refunds can fail (insufficient funds at the receiving bank, closed account) up to five business days after Mollie reports the refund as queued. Keep the refund record open and write-locked for at least seven days. Do not close out the ledger entry on the queued status.
The reconciliation pattern that survived all three PSPs
After fifteen quirks worth of debugging, the architecture we landed on looks like this:
// Webhook entrypoint: dumb and fast.
app.post('/webhooks/:psp', async (req, res) => {
const event = normalize(req.params.psp, req.headers, req.body)
const inserted = await db.psp_events.insertIfNew(event)
res.status(200).send('OK') // ack immediately
if (inserted) await queue.publish('psp.event', event.id)
})
// Worker: slow, idempotent, re-runnable.
queue.consume('psp.event', async ({ id }) => {
const event = await db.psp_events.find(id)
await applyStateTransition(event) // pure function, no I/O
await updateOrderLedger(event) // idempotent UPSERT
})
Three properties matter. The webhook endpoint never does business logic, so a slow database does not cause Buckaroo to retry. Deduplication happens at insert time, not in application code. The worker is a pure consumer of the events table, which means we can replay any event by re-queuing its ID.
This pattern works for Stripe and Paddle too, but the three Dutch PSPs are where it earns its keep, because their retry behaviour is the most aggressive and their failure modes are the least uniform. When we built the refund automation for the Utrecht ticketing platform, the longest part was not writing the integrations: it was discovering that every PSP defines "delivered" differently. We now ship the dedup-at-insert pattern above as the first thing we install for any client doing integrations and process automation across multiple PSPs.
What to do tomorrow morning
If you run refunds across more than one PSP, spend twenty minutes tonight on one query. Group your last 90 days of webhook events by (provider, transaction_key, status_code) and count duplicates. The number will surprise you. That count is the size of the bug you have not yet been billed for.
Key takeaway
Treat every PSP webhook as if it will arrive between two and ten times. Idempotency belongs in your database constraint, not in your application code.
FAQ
Why so many quirks across only three providers?
Each PSP optimised their webhook contract for a different failure mode: signing, retries, batch delivery, SEPA reversals. The quirks are intentional design choices that interact poorly when one ledger consumes all three.
Is this specific to Dutch PSPs?
The retry and dedup patterns apply to any PSP. The specific quirks (Buckaroo SEPA storno volume, Adyen additionalData behaviour) show up most in NL because Dutch ticketing relies on iDEAL and SEPA-incasso.
Can I process webhooks synchronously and skip the queue?
Only at very low volume. With one Buckaroo SEPA event firing six times in 90 seconds, a synchronous handler holding a database lock will lock-spin and miss the retry budget. Use a queue.
Do I need a separate handler per PSP?
One endpoint per PSP for signature verification, but one normaliser feeding one event table. Three thin handlers, one pipeline, one worker.
Does this pattern work with Stripe?
Yes. Stripe's retry envelope is up to 3 days with exponential backoff, and idempotency keys are first-class. The dedup-at-insert pattern works out of the box and removes most of the edge cases.