Integrations
Mollie webhooks: 13 edge cases that bit our refund agent
On a Tuesday in May, a Haarlem subscription-box CFO asked why three customers were refunded twice. Mollie said every webhook was delivered. Our handler disagreed.

On a Tuesday in May, the CFO at a 21-person Haarlem subscription-box operator pinged us at 09:14: three customers had been refunded twice in the past three days. We opened Mollie. Every refund webhook from those three orders was green. Delivered, delivered, delivered. We opened our handler logs. Six of the nine events had never landed in application code. The dashboard and our service disagreed, and the dashboard was the one telling stories.
We had been hired to wire a refund-handling automation agent for this team: take an inbound support email, classify intent, issue the refund through Mollie, update the Klaviyo profile, post to a shared Slack channel, append to the ledger. The whole loop was three weeks old. The dashboard-versus-reality gap is what taught us most of what is in this field guide. Thirteen edge cases, ranked by how often each one lets Mollie mark an event delivered while the JSON body never reaches your handler. Those are the ones that bleed silently for weeks before anyone notices.
Mollie's misleading "delivered" tick
Mollie's contract for a webhook is one sentence long: if your endpoint returns a 2xx, the event is delivered. (See the Mollie webhook docs.) What happens after the 2xx is entirely your problem. Whether your body parser threw. Whether your queue was full. Whether your background worker crashed three seconds later. None of that is visible to Mollie. The "delivered" tick is not a delivery confirmation. It is an acknowledgement that a TCP socket somewhere said 200.
Five of our thirteen cases hid in exactly that gap.
Tier one: the body never touched your code
These are the worst because there is no error to log. There is no event to retry. The dashboard says delivered, your handler never ran, and you only find out when a customer counts their refunds.
1. The empty body parsed as JSON. Mollie webhooks are not JSON. They arrive as application/x-www-form-urlencoded with a single field: id=tr_5B8cwPMGnU6qLbRvkrChYR. A Node handler that does const { id } = await req.json() gets undefined or throws. An Express app with only bodyParser.json() mounted gets an empty req.body. Most teams 200 anyway because their middleware silently swallows the parse error. Delivered, on paper.
2. The 301 to HTTPS. If the webhook URL is misconfigured to http:// and Apache redirects to https:// with a 301, Mollie follows the redirect using GET. The body is dropped. The handler returns 200 because GET routes to your index. We saw this in a sandbox migration where someone copied a dev webhook URL into the live API key.
3. The Cloudflare WAF that 204s empty payloads. A managed rule meant to drop empty-body scrapers also drops Mollie's form-encoded body when the parser disagrees about content length. The 204 counts as 2xx. We watched four hours of refund webhooks vanish at the edge before we matched timestamps in the Cloudflare log against the Mollie dashboard.
4. The load-balancer rule that returns 200 for everything. AWS ALB listener rules that match /webhooks/* and respond with a fixed 200, usually a misconfigured rule left over from when someone tested the endpoint. The webhook never reaches the target group.
5. The reverse proxy that strips Content-Type. Nginx config that rewrites Content-Type: application/x-www-form-urlencoded to nothing on its way upstream. The Node app's body parser sees an unknown content type and skips the body. 200 anyway, because the route handler has a default response.
The fix for all five is the same, and it is not "configure the proxy correctly." It is: do not trust the webhook body for anything except a wake-up signal.
The webhook body is a doorbell, not a letter. Re-fetch the object from Mollie's API every time, using your own API key.
Tier two: the body arrived but lied
These cases the handler at least sees. They show up as mismatched state, double-issued refunds, or a ledger row that does not match the payment.
6. Refund webhooks come through the payment endpoint. There is no separate refund webhook URL. The body is still id=tr_xxxxx, the payment ID. To find out what changed about a refund, you fetch the payment, iterate payment.refunds, and diff against what you have stored. Teams that assume a refund webhook will tell them about a specific refund object spend an afternoon debugging an empty handler.
7. Status can move backwards. A paid payment can transition to refunded, then to charged_back weeks later. (Mollie's status changes reference lays out the matrix.) Handlers that gate on "is this status further along than the last one" reject the chargeback webhook and quietly leave the customer with their money and their box.
8. Chargebacks share the same URL. Same webhook URL, same body shape. You only know it is a chargeback by inspecting payment.chargebacks on re-fetch. We had ours classified as "weird refund the agent did not issue" for the first two days.
9. The pending refund that stays pending. SEPA refunds sit in pending for two to five business days. Mollie fires the webhook when the status flips to refunded. The dashboard, however, shows "refund issued" the moment the API call completes. Support reads the dashboard and tells the customer the money is on its way; the agent waits for the webhook before updating Klaviyo. The two stories diverge for days.
Tier three: async drift
These do not break the handler. They break the assumptions the handler is built on.
10. Test mode bleed. If the merchant ever uses their test API key from the same backend, test webhooks arrive at the same URL with tr_test_ IDs. Our refund agent happily tried to write production ledger rows from sandbox transactions for ninety minutes before a finance person noticed.
11. Multiple webhooks per refund. Partial refunds fire one webhook per partial. Three €10 refunds against a €60 payment produce three webhooks, all carrying the same payment ID, all arriving inside the same minute. Without idempotency on the operation, not on the webhook ID, you re-process the first refund twice.
12. The Order API webhook is a different beast. If the merchant turns on Klarna Pay Later or Riverty, those flow through Mollie's Order API. Refunds against orders fire the order webhook, not the payment webhook. The bodies look identical at a glance (id=ord_xxxxx versus id=tr_xxxxx), but every downstream call is different. We routed everything through one handler and discovered this when the agent kept "failing" on three orders a week.
13. Retries arriving out of order. Mollie retries failed webhooks for around twenty-four hours. A retry from yesterday can land after today's status change. Handlers that assume strict ordering write yesterday's state over today's. Idempotency on the operation, plus a freshness check against the re-fetched object, is the only thing that holds.
What we actually shipped
The pattern that ended the bleeding is short. The webhook handler does four things and nothing else: log receipt, verify the ID is well-formed, enqueue a job, return 200. Everything else, the API re-fetch, the diff, the side effects, happens in the worker. The 200 stops representing "we processed this" and starts representing "we have it in our queue."
// webhook.js
import express from 'express'
import { enqueue } from './queue.js'
const app = express()
app.use(express.urlencoded({ extended: false }))
app.post('/webhooks/mollie', async (req, res) => {
const { id } = req.body
if (!id || !/^(tr|ord)_[A-Za-z0-9]+$/.test(id)) {
// Still 200. A malformed retry should not loop forever.
return res.status(200).end()
}
await enqueue('mollie.refetch', { id, receivedAt: new Date().toISOString() })
res.status(200).end()
})
The express.urlencoded middleware is the part most teams get wrong on day one. The default Express body parser only handles JSON, Mollie sends form-encoded bodies, and mounting the JSON parser globally is exactly how case 1 happens. Mount the right parser, on the right route, before the handler ever runs. The regex on the ID is paranoid on purpose: it catches malformed retries from old code, payload probes from the wider internet, and anything that does not look like a Mollie identifier, all without forcing Mollie to retry the same garbage for twenty-four hours.
The worker is where the real work lives. It re-fetches the object, walks refunds and chargebacks, compares to a mollie_objects table keyed on id, and only fires side effects for transitions it has not already handled.
// worker.js
import { mollie } from './mollie.js'
import { db } from './db.js'
export async function handleRefetch({ id }) {
const isOrder = id.startsWith('ord_')
const obj = isOrder
? await mollie.orders.get(id, { embed: 'refunds,payments' })
: await mollie.payments.get(id, { embed: 'refunds,chargebacks' })
const refunds = obj._embedded?.refunds ?? []
for (const r of refunds) {
const key = `${id}:refund:${r.id}:${r.status}`
if (await db.opsLedger.findOne({ key })) continue
await fireRefundSideEffects(obj, r) // Klaviyo, Slack, ledger
await db.opsLedger.insert({ key, at: new Date().toISOString() })
}
const chargebacks = obj._embedded?.chargebacks ?? []
for (const c of chargebacks) {
const key = `${id}:chargeback:${c.id}`
if (await db.opsLedger.findOne({ key })) continue
await fireChargebackSideEffects(obj, c)
await db.opsLedger.insert({ key, at: new Date().toISOString() })
}
await db.mollieObjects.upsert({
id, snapshot: obj, seenAt: new Date().toISOString(),
})
}
The idempotency key is the combination of object ID, refund or chargeback ID, and current status. A retry of the same transition is a no-op. A new transition (pending to refunded) gets its own row. A chargeback later in the lifecycle gets its own row. Out-of-order retries cannot undo work because the ledger is per-transition, not global.
Two practical notes on the ledger. The status field has to be in the key because pending and refunded are different transitions of the same refund object; without it, the pending-to-refunded flip looks like a duplicate, the side effects never fire, and the customer never gets their Klaviyo update or their Slack-channel confirmation. And the snapshot column on mollie_objects is worth keeping even though it is not strictly required for correctness. The first time a support ticket arrives asking why something happened three weeks ago, having the exact API response Mollie returned at that moment turns a four-hour investigation into a ten-minute one. We store the full JSON, gzipped, with a six-month retention window.
Treat the webhook as a doorbell, the API as the source of truth, and the operation (not the event) as the unit of idempotency. That sentence is the entire field guide.
The metrics dashboard now plots one number we did not have before: the gap between webhook arrival time and worker completion time. When that gap grows, the queue is backing up and someone is about to wait for a refund longer than they should. The dashboard's green ticks no longer factor into anything we measure. We pair that with a daily reconciliation job that pages every mollie_objects row updated in the last twenty-four hours, re-fetches it, and alerts if the stored snapshot differs from what Mollie now returns. In four months of running it, the job has caught two cases the webhook pipeline missed, both of them chargebacks that landed during a brief queue-worker outage.
When we wired the refund-handling agent for this Haarlem subscription-box, the thing that finally settled the false-positive "delivered" problem was moving the JSON parsing out of the webhook handler entirely. The handler now does almost nothing. The agent, built on the same scaffolding we use for our other AI agents, does the fetching, the diffing, and the side effects, and it does them at its own pace. Double refunds stopped the day we shipped it.
If you maintain a Mollie integration and have never read your own handler back, do this today: log the raw Content-Type header and the raw body for the next ten webhooks. If either one surprises you, you have at least one of these thirteen.
Key takeaway
Treat the webhook body as a doorbell, the API as the source of truth, and the operation, not the event, as the unit of idempotency.
FAQ
Does Mollie sign its webhooks?
No. Mollie does not include a signature header. The accepted verification path is to re-fetch the object from Mollie's API using your own API key, treating the webhook body as a hint about which ID changed.
How long does Mollie retry a failed webhook?
Roughly twenty-four hours with backoff. After that the event drops off and the only way to learn about state changes is to poll the API or re-fetch on user action.
Why do refund webhooks arrive with the payment ID, not the refund ID?
Mollie sends one webhook per payment for all related changes. Refunds and chargebacks both fire it with the payment ID. Fetch the payment with refunds embedded and diff against your stored state.
Can I IP-whitelist Mollie at the firewall?
Mollie does not publish a stable IP range and the source addresses rotate. Whitelisting is fragile. Verify provenance by re-fetching the object with your API key, which is what proves it is real.