Integrations
Mollie webhooks: 13 randgevallen die onze refund-agent raakten
Op een dinsdag in mei vroeg een CFO uit Haarlem waarom drie klanten dubbel waren terugbetaald. Mollie zei: elke webhook is bezorgd. Onze handler zei iets anders.

Op een dinsdag in mei stuurde de CFO van een Haarlems abonnementsdoos-bedrijf met 21 mensen ons om 09:14 een bericht: drie klanten waren in de afgelopen drie dagen dubbel terugbetaald. We openden Mollie. Elke refund-webhook van die drie orders was groen. Bezorgd, bezorgd, bezorgd. We openden onze handler-logs. Zes van de negen events waren nooit in de applicatiecode beland. Het dashboard en onze service spraken elkaar tegen, en het dashboard was degene die verhaaltjes vertelde.
We waren ingehuurd om voor dit team een automatiserings-agent te bouwen die refunds afhandelt: een binnenkomende support-mail oppakken, intentie classificeren, de refund via Mollie uitvoeren, het Klaviyo-profiel bijwerken, een bericht plaatsen in een gedeeld Slack-kanaal, een regel toevoegen aan het grootboek. De hele loop was drie weken oud. Het gat tussen dashboard en realiteit heeft ons het meeste geleerd van wat in deze gids staat. Dertien randgevallen, gerangschikt op hoe vaak elk geval ervoor zorgt dat Mollie een event als bezorgd markeert terwijl de JSON-body nooit bij je handler aankomt. Dat zijn de cases die wekenlang stilletjes bloeden voordat iemand het merkt.
Mollie's misleidende 'bezorgd'-vinkje
Het contract van Mollie voor een webhook is één zin lang: als je endpoint een 2xx teruggeeft, is het event bezorgd. (Zie de Mollie webhook-documentatie.) Wat er na die 2xx gebeurt, is volledig jouw probleem. Of je body-parser het begaf. Of je queue vol zat. Of je background-worker drie seconden later crashte. Niets daarvan is zichtbaar voor Mollie. Het 'bezorgd'-vinkje bevestigt geen aflevering. Het bevestigt dat ergens een TCP-socket 200 heeft gezegd.
Vijf van onze dertien cases verstopten zich precies in dat gat.
Niveau één: de body raakte je code nooit
Dit zijn de ergste, want er is geen error om te loggen. Geen event om opnieuw te proberen. Het dashboard zegt bezorgd, je handler heeft nooit gedraaid, en je komt er pas achter als een klant zijn refunds telt.
1. De lege body geparsed als JSON. Mollie-webhooks zijn geen JSON. Ze komen binnen als application/x-www-form-urlencoded met één veld: id=tr_5B8cwPMGnU6qLbRvkrChYR. Een Node-handler die const { id } = await req.json() doet, krijgt undefined of crasht. Een Express-app waar alleen bodyParser.json() aanstaat, krijgt een lege req.body. De meeste teams retourneren toch 200, omdat hun middleware de parse-error stilletjes inslikt. Bezorgd, op papier.
2. De 301 naar HTTPS. Als de webhook-URL verkeerd staat geconfigureerd op http:// en Apache redirect met een 301 naar https://, volgt Mollie de redirect met een GET. De body verdwijnt. De handler retourneert 200 omdat GET naar je index routeert. We zagen dit bij een sandbox-migratie waarbij iemand een dev-webhook-URL had gekopieerd in de live API-key.
3. De Cloudflare WAF die lege payloads met 204 afhandelt. Een managed rule die bedoeld is om scrapers met lege body weg te filteren, gooit ook Mollie's form-encoded body weg als de parser het oneens is over de content-length. De 204 telt als 2xx. We zagen vier uur aan refund-webhooks aan de edge verdwijnen voordat we tijdstempels in het Cloudflare-log konden matchen met het Mollie-dashboard.
4. De load-balancer-regel die overal 200 op teruggeeft. AWS ALB listener-rules die op /webhooks/* matchen en met een vaste 200 antwoorden. Meestal een verkeerd ingestelde regel die is blijven staan nadat iemand het endpoint testte. De webhook bereikt de target group nooit.
5. De reverse proxy die Content-Type weghaalt. Een Nginx-configuratie die Content-Type: application/x-www-form-urlencoded onderweg upstream naar niets herschrijft. De body-parser van de Node-app ziet een onbekend content-type en slaat de body over. Toch 200, omdat de route-handler een standaard response heeft.
De oplossing voor alle vijf is dezelfde, en die luidt niet 'configureer de proxy goed'. Hij luidt: vertrouw de webhook-body voor niets anders dan een wake-up-signaal.
De webhook-body is een deurbel, geen brief. Haal het object elke keer opnieuw op via de API van Mollie, met je eigen API-key.
Niveau twee: de body kwam aan, maar loog
Deze cases ziet de handler in elk geval. Ze duiken op als mismatch in state, dubbel uitgevoerde refunds, of een grootboekregel die niet bij de betaling past.
6. Refund-webhooks komen binnen via het payment-endpoint. Er is geen aparte refund-webhook-URL. De body blijft id=tr_xxxxx, het payment-ID. Om uit te zoeken wat er aan een refund is veranderd, haal je de payment op, loop je door payment.refunds, en vergelijk je met wat je hebt opgeslagen. Teams die ervan uitgaan dat een refund-webhook hen iets vertelt over een specifiek refund-object, zitten een middag te debuggen op een lege handler.
7. Status kan achteruit bewegen. Een paid betaling kan overgaan in refunded, en weken later in charged_back. (Mollie's status changes reference heeft de hele matrix.) Handlers die filteren op 'is deze status verder dan de vorige', wijzen de chargeback-webhook af en laten de klant stilletjes achter met zijn geld én zijn box.
8. Chargebacks delen dezelfde URL. Dezelfde webhook-URL, dezelfde body-vorm. Je weet pas dat het een chargeback is door payment.chargebacks te inspecteren als je het object opnieuw ophaalt. Wij hadden die de eerste twee dagen geclassificeerd als 'rare refund die de agent niet had uitgevoerd'.
9. De pending refund die pending blijft. SEPA-refunds blijven twee tot vijf werkdagen in pending. Mollie vuurt de webhook af zodra de status omslaat naar refunded. Het dashboard laat echter 'refund issued' zien op het moment dat de API-call klaar is. Support leest het dashboard en vertelt de klant dat het geld onderweg is; de agent wacht op de webhook voordat hij Klaviyo bijwerkt. De twee verhalen lopen dagenlang uiteen.
Niveau drie: async drift
Deze cases breken de handler niet. Ze breken de aannames waarop de handler is gebouwd.
10. Test-mode-lekkage. Als de merchant ooit zijn test-API-key gebruikt vanuit dezelfde backend, komen test-webhooks binnen op dezelfde URL met tr_test_-ID's. Onze refund-agent probeerde negentig minuten lang vrolijk productie-grootboekregels weg te schrijven op basis van sandbox-transacties, totdat iemand van finance het opmerkte.
11. Meerdere webhooks per refund. Bij partial refunds vuurt elke deelrefund één webhook af. Drie refunds van €10 tegen een betaling van €60 leveren drie webhooks op, alle drie met hetzelfde payment-ID, alle drie binnen dezelfde minuut. Zonder idempotency op de operatie (niet op het webhook-ID) verwerk je de eerste refund dubbel.
12. De Order API-webhook is een ander beest. Als de merchant Klarna Pay Later of Riverty aanzet, lopen die via de Order API van Mollie. Refunds op orders vuren de order-webhook af, niet de payment-webhook. De bodies lijken op het eerste gezicht identiek (id=ord_xxxxx tegenover id=tr_xxxxx), maar elke downstream-call is anders. Wij routeerden alles via één handler en ontdekten dit toen de agent drie orders per week bleef 'failen'.
13. Retries die in de verkeerde volgorde aankomen. Mollie probeert mislukte webhooks ongeveer vierentwintig uur lang opnieuw. Een retry van gisteren kan na de status-wijziging van vandaag binnenkomen. Handlers die strikte volgorde aannemen, overschrijven de state van vandaag met die van gisteren. Idempotency op de operatie, plus een vers-check tegen het opnieuw opgehaalde object, is het enige dat standhoudt.
Wat we uiteindelijk hebben uitgerold
Het patroon dat het bloeden stopte, is kort. De webhook-handler doet vier dingen en verder niets: ontvangst loggen, controleren of het ID welgevormd is, een job in de queue zetten, 200 retourneren. Al het andere (het opnieuw ophalen via de API, de diff, de side effects) gebeurt in de worker. De 200 staat niet langer voor 'wij hebben dit verwerkt', maar voor 'wij hebben het in onze 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()
})
De express.urlencoded-middleware is wat de meeste teams op dag één verkeerd doen. De standaard Express body-parser handelt alleen JSON af, Mollie stuurt form-encoded bodies, en de JSON-parser globaal mounten is precies hoe case 1 ontstaat. Mount de juiste parser, op de juiste route, voordat de handler überhaupt draait. De regex op het ID is bewust paranoïde: hij vangt malformed retries van oude code, payload-probes van het wijde internet, en alles wat er niet uitziet als een Mollie-identifier, zonder Mollie te dwingen dezelfde rommel vierentwintig uur lang opnieuw te sturen.
De worker is waar het echte werk zit. Hij haalt het object opnieuw op, loopt door refunds en chargebacks, vergelijkt met een mollie_objects-tabel met id als sleutel, en vuurt side effects alleen af voor transities die hij nog niet eerder heeft afgehandeld.
// 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(),
})
}
De idempotency-key is de combinatie van object-ID, refund- of chargeback-ID, en huidige status. Een retry van dezelfde transitie is een no-op. Een nieuwe transitie (pending naar refunded) krijgt zijn eigen rij. Een chargeback later in de levenscyclus krijgt zijn eigen rij. Retries in de verkeerde volgorde kunnen werk niet ongedaan maken omdat het grootboek per transitie is opgebouwd, niet globaal.
Twee praktische opmerkingen over het grootboek. Het status-veld moet in de key staan omdat pending en refunded verschillende transities zijn van hetzelfde refund-object; zonder dat lijkt de flip van pending naar refunded een duplicaat, worden de side effects nooit afgevuurd, en krijgt de klant nooit zijn Klaviyo-update of zijn bevestiging in het Slack-kanaal. En de snapshot-kolom op mollie_objects is het bewaren waard, ook al is hij niet strikt nodig voor correctheid. De eerste keer dat er een support-ticket binnenkomt met de vraag waarom er drie weken geleden iets is gebeurd, verandert het hebben van de exacte API-response die Mollie op dat moment teruggaf, een onderzoek van vier uur in een van tien minuten. Wij bewaren de volledige JSON, gzipped, met een bewaartermijn van zes maanden.
Behandel de webhook als deurbel, de API als bron van waarheid, en de operatie (niet het event) als eenheid van idempotency. Die zin is de hele gids.
Het metrics-dashboard plot nu één getal dat we eerder niet hadden: het gat tussen het moment dat de webhook binnenkwam en het moment dat de worker klaar was. Als dat gat groeit, loopt de queue vol en staat er iemand op het punt langer op zijn refund te wachten dan zou moeten. De groene vinkjes van het dashboard tellen niet meer mee in wat we meten. Daarnaast draaien we een dagelijkse reconciliation-job die elke mollie_objects-rij die in de laatste vierentwintig uur is bijgewerkt opnieuw ophaalt, en alarm slaat als de opgeslagen snapshot afwijkt van wat Mollie nu teruggeeft. In vier maanden draaien heeft die job twee cases gevangen die de webhook-pipeline had gemist, allebei chargebacks die binnenkwamen tijdens een korte uitval van een queue-worker.
Toen we de refund-afhandelende agent bouwden voor deze Haarlemse abonnementsdoos, was wat het 'bezorgd'-probleem uiteindelijk de wereld uit hielp: het JSON-parsen volledig uit de webhook-handler halen. De handler doet nu bijna niets. De agent, gebouwd op dezelfde scaffolding die we ook voor onze andere AI-agents gebruiken, doet het ophalen, de diff en de side effects, in zijn eigen tempo. Vanaf de dag dat we het uitrolden, stopten de dubbele refunds.
Als je een Mollie-integratie onderhoudt en je hebt je eigen handler nooit teruggelezen, doe dit dan vandaag: log de ruwe Content-Type-header en de ruwe body voor de volgende tien webhooks. Als één van beide je verbaast, heb je minstens één van deze dertien.
Kern
Behandel de webhook-body als deurbel, de API als bron van waarheid, en de operatie (niet het event) als eenheid van idempotency.
FAQ
Ondertekent Mollie zijn webhooks?
Nee. Mollie stuurt geen signature-header mee. De geaccepteerde manier om te verifiëren is het object opnieuw ophalen via de API van Mollie met je eigen API-key, en de webhook-body alleen behandelen als hint over welk ID veranderd is.
Hoe lang probeert Mollie een mislukte webhook opnieuw?
Ruwweg vierentwintig uur met backoff. Daarna valt het event eraf, en de enige manier om over state-wijzigingen te horen is via API-polling of door opnieuw op te halen bij een user-actie.
Waarom komen refund-webhooks binnen met het payment-ID, niet het refund-ID?
Mollie stuurt één webhook per payment voor alle gerelateerde wijzigingen. Zowel refunds als chargebacks vuren hem af met het payment-ID. Haal de payment op met refunds embedded en vergelijk met je opgeslagen state.
Kan ik Mollie via een IP-whitelist toelaten op de firewall?
Mollie publiceert geen vast IP-bereik en de bron-adressen rouleren. Whitelisting is broos. Verifieer de herkomst door het object opnieuw op te halen met je API-key. Dat is wat bewijst dat het echt is.