Integrations
Mollie, Adyen, Buckaroo webhooks: fourteen quirks ranked
A 22-person Rotterdam travel agency wanted Monday mornings back. We built a payment-reconciliation agent for three Dutch PSPs and found fourteen webhook quirks that lie quietly under a 200 OK.

The bookkeeper at a 22-person reisbureau in Rotterdam had a Google Sheet 1,180 rows long, and the column she hated most was “PSP reference.” Every refund, partial refund, chargeback, and re-attempt arrived as a fresh row, and she stitched them by hand. The summer rush had just ended. She wanted Monday mornings back.
We were brought in to build the reconciliation agent that would replace that spreadsheet. It reads webhooks from the three PSPs the agency uses — Mollie, Adyen, Buckaroo — matches each event to a booking and a passenger, and writes the result into Exact Online. Plumbing-grade work. The kind we expected to be done in three weeks.
Six weeks in, we had fourteen open tickets, all the same shape: “Refund arrived, agent looked at it, agent picked the wrong order, agent told Exact.” None of the providers were lying. All of them were responding 200 OK and shipping signed, valid payloads. They were just shaped in fourteen different ways that did not match each other or, in some cases, themselves.
This is the cheatsheet, ranked from “will silently lose money” at the top to “annoying” at the bottom. If you are integrating a Dutch PSP and you care about reconciliation, save it.
How we ranked
Two failure modes are worse than the others. The first is order_id substitution: the webhook references the wrong booking, the agent posts to the wrong customer, and you find out three months later from a phone call. The second is silent field loss on retry: the webhook fires twice with the same status, the second one is missing the consumer's IBAN, and the bank reconciliation breaks because nobody can match the cents back to the customer. Both fail under a 200 OK. Both look fine in your logs. We weight those at the top.
The fourteen, worst first
1. Adyen CHARGEBACK uses originalReference, not merchantReference
The CHARGEBACK notification arrives with a pspReference for the chargeback itself and an originalReference pointing at the original authorisation. If you key your booking on merchantReference, you have to fetch the original auth to find it — the chargeback notification does not carry the booking ID in the obvious slot. We had a case where two bookings shared the same merchantReference prefix (a UI bug from a previous developer), and the agent assigned a €1,840 chargeback to a customer who had paid for a €290 city break. See the Adyen chargeback notification reference.
2. Mollie refund webhook drops the consumer-IBAN on retry
A refund webhook fires when a refund is created. If the refund stays in pending (which it always does for SEPA-routed iDEAL refunds), Mollie fires the same webhook again when the status changes to refunded. The payload from the first fire contains the consumer's IBAN under details.consumerAccount. The second fire often does not — the docs do not promise it, and in practice the field is null about a third of the time on retries. If your agent treats the second fire as the source of truth, you have just lost the bank reference.
3. Buckaroo partial refunds replace the transaction key
Buckaroo's push for a partial refund arrives with BRQ_TRANSACTIONS set to the partial-refund's own transaction GUID, and the original payment GUID buried under BRQ_RELATEDTRANSACTION_PARTIALPAYMENT. Naive handlers (and there are several open-source ones in PHP that do this) key on BRQ_TRANSACTIONS. The agent then opens a new ledger entry instead of crediting the original booking.
4. Mollie chargeback webhook keys on chargeback ID
The chargeback webhook in Mollie posts the chargeback ID to your URL, not the payment ID. You must fetch the chargeback, then fetch its parent payment, then fetch the payment's metadata. Three round trips before you know which booking is affected. If any of those round trips 429s — and during a card-network mass-chargeback event in March 2026 we saw exactly that — the agent stalls and the chargeback ages in your dead-letter queue.
5. Adyen notification batching can interleave success states
Adyen batches notifications into a single POST. Inside that batch, you can receive an AUTHORISATION with success=false followed by an AUTHORISATION with success=true for the same merchantReference, in the same envelope. The order is not guaranteed across retries. If you process them in array order on the retry, you flip the booking to “failed” after it was already “paid.”
6. Buckaroo BRQ_STATUSCODE 491 hides chargeback intent
Status code 491 means “pending input.” For chargebacks, Buckaroo will sit on this for up to 48 hours while it waits on the acquirer. During that window, no follow-up push arrives. We had agents marking bookings as “disputed but not yet costed,” which turned out useful, but only because we built it. The default is silence.
7. Mollie consumer details only populate after settlement
For iDEAL specifically, details.consumerName, details.consumerAccount, and details.consumerBic are populated only after the payment settles, which can be hours after status=paid. If you write to your ledger on paid, you write three null fields. If you wait for details to populate, you wait an unbounded amount of time. There is no settlement webhook for this — you poll.
8. Adyen AUTHORISATION_ADJUSTMENT keeps the pspReference
When a hotel partner adjusts the amount on a held card (Adyen's AUTHORISATION_ADJUSTMENT), the pspReference does not change. The amount in additionalData.acquirerAmount does. If your agent dedupes on pspReference, the second notification looks like a duplicate of the first and is dropped. We caught this on a €640 to €890 hotel adjustment that vanished from the ledger for nine days.
9. Buckaroo signature breaks when they add a new push parameter
Buckaroo's push signature is a SHA-1 (or SHA-512, depending on your shop config) of the sorted, concatenated push parameters plus your secret. If they add a new field on their side — they added BRQ_PAYMENT_METHOD_SCHEME in late 2025 — your old signature-verification code rejects valid pushes, because your sort no longer matches theirs. There is no version header.
10. Mollie webhook URL is per-payment, frozen at creation
You set the webhook URL when you create the payment. Six months later, after you have moved your reconciliation service to a new domain, a delayed chargeback fires against a payment created in the old world. It goes to the old URL. If the old URL 404s, Mollie retries for 24 hours and then gives up. We now keep a 410 Gone handler on every old URL that forwards to the new endpoint and logs the slip.
11. Adyen retries do not change eventDate
When Adyen retries a notification, the eventDate stays the same as the first attempt. If your dedupe logic is (merchantReference, eventCode, eventDate), that is fine. If it is (merchantReference, eventCode, receivedAt) — and several common libraries default to the latter — you process the same notification twice.
12. Buckaroo refund pushes carry no consumer IBAN
The refund push from Buckaroo references the consumer transaction but contains no IBAN of its own. You have to look up the original transaction's BRQ_SERVICE_IDEAL_CONSUMERIBAN field, which you only stored if you handled the original push correctly. Half the legacy PHP integrations we have rescued never stored it.
13. Mollie test webhooks come from a different IP range
If you IP-allowlist Mollie's webhook source (we have one client who insists), test mode pushes from Mollie's webhook docs sandbox come from a different range than live pushes. Your staging environment passes, production blocks the same webhook. Nothing fails loudly.
14. Adyen wants “[accepted]” as the response body
A 200 OK with an empty body is not enough. The standard Adyen webhook expects the literal body [accepted]. If you respond with {"ok":true} and a 200, Adyen treats it as a failure and retries. The retries succeed eventually, your ledger ends up with duplicates, and your dedupe logic from quirk #11 saves you only if you got it right.
The skeleton we settled on
After fourteen rewrites, the reconciliation agent's webhook handler boiled down to one normalisation step that fires before any business logic:
def normalise(provider, payload):
# Always resolve to (booking_id, event_type, amount_cents, party_iban)
booking = resolve_booking(provider, payload) # may fetch
event = canonical_event(provider, payload) # 6 types, not 40
amount = money_in_cents(provider, payload)
iban = consumer_iban(provider, payload) or \
lookup_original_iban(booking, event) # always fall back
return Event(booking, event, amount, iban)
The fallback on iban is the line that earned its keep. Every webhook gets a chance to fill it from the push; if the push is silent (Buckaroo refund, Mollie retry), we walk back to the original transaction we stored. That is the rule that stops the bank reconciliation from breaking.
If your reconciliation agent dedupes by “received timestamp” or “PSP reference alone,” you will silently lose chargebacks, partial refunds, and adjusted authorisations. Dedupe by (provider, event_type, primary_reference, event_date_from_payload) — never by clock time.
The smallest thing you can do today
Open your webhook handler. Find the line where you decide which booking an incoming event belongs to. If it reads a single field — merchantReference, BRQ_TRANSACTIONS, the URL parameter, anything — you have a candidate for quirk #1, #3, or #4. Print the last fifty incoming payloads where that field was the only thing you looked at, and check by hand whether any belong to a different booking than the agent thought. Half an hour. You will find at least one.
When we built this betaal-reconciliation agent for the Rotterdam reisbureau, the surprise was not that webhooks lie. It was how many of them lie quietly. We rebuilt the handler around the normalise-then-resolve pattern above, and that is the same shape we now use for every payment-side AI agent we deploy.
Key takeaway
Webhook reconciliation breaks under a 200 OK. Normalise every PSP event to (booking, type, amount, IBAN) and dedupe on the payload's event date, never the wall clock.
FAQ
Do I need a separate webhook handler per PSP?
Practically yes. The wire formats and retry semantics differ enough that one handler with branches becomes the bug. Branch early at the edge, then share a single normalisation step before any business logic runs.
Can I rely on the PSP's signature alone for deduplication?
No. Adyen and Buckaroo will retry valid signatures; Mollie's per-payment URL means duplicates can arrive months apart. Dedupe on (provider, event_type, primary_reference, event_date_from_payload).
Which quirk hits hardest if I do nothing about it?
Adyen's CHARGEBACK using originalReference instead of merchantReference. It can attribute a chargeback to the wrong customer, silently, under a 200 OK, and you only find out from a phone call months later.
Why not just use a third-party reconciliation tool?
Most off-the-shelf tools assume one PSP, one currency, one booking model. The moment you mix Mollie iDEAL with Adyen card and Buckaroo SEPA on the same booking, you are back to writing the merge layer yourself.