← Blog

Integrations

Webhooks van Mollie, Stripe en Buckaroo: NL cheatsheet

Je Mollie-webhook komt twee keer binnen, de Stripe-signature faalt op één seconde, en Buckaroo stuurt een push die je niet zag aankomen. De cheatsheet voor je eerste maand.

Jacob Molkenboer· Oprichter · A Brand New Company· 28 mrt 2024· 8 min
Drie verzegelde enveloppen met gekleurde lakzegels, groen lint, rode postzegel, koperen clip op ivoor bureau.

Het is 2:14 's nachts. Je bent een Nederlandse SaaS-oprichter, drie weken na launch, en je support-inbox loopt vol met vijf klanten die zeggen dat ze twee keer betaald hebben. Dat is niet zo. Mollie heeft je webhook drie keer afgevuurd in negen seconden omdat je eerste response 17 seconden duurde en eruit timede, je worker startte een nieuwe container, en drie subscriptions werden uitgeleverd op één payment ID. Het geld klopt. De staat van je database niet.

Elke payment processor op de Nederlandse markt heeft een eigen manier om je weekend te slopen. Mollie, Stripe en Buckaroo hebben elk andere ontwerpkeuzes gemaakt rond webhooks, en de valkuilen lopen niet over van de één naar de ander. Hieronder de cheatsheet die we onze eerste tien klanten hadden willen meegeven voordat ze live gingen.

Mollie: het callback-model

De webhook van Mollie is een notificatie, geen payload. Als een payment-status verandert, krijg je een POST met één veld: id. Meer niet. Om te weten wat er gebeurd is, bel je terug naar de Mollie-API met dat ID en lees je de huidige staat.

// Mollie webhook handler, PHP
$paymentId = $_POST['id'] ?? null;
if (!$paymentId) {
    http_response_code(400);
    exit;
}

$payment = $mollie->payments->get($paymentId);

switch ($payment->status) {
    case 'paid':
        // safe to fulfil, but check you haven't already
        $this->fulfilment->markPaid($payment->id, $payment->amount);
        break;
    case 'failed':
    case 'canceled':
    case 'expired':
        $this->fulfilment->markFailed($payment->id, $payment->status);
        break;
}

http_response_code(200);

Drie dingen bijten hier.

Ten eerste: de webhook komt binnen bij elke status-wijziging. open bij aanmaken, pending bij iDEAL na de bank-redirect, authorized bij creditcards in sommige flows, paid als het geld binnen is, en later refunded of charged_back. Als je handler alleen op paid checkt, mis je de chargeback die zes weken later binnenkomt en is je fraude-overzicht blind.

Ten tweede: Mollie probeert het opnieuw. Als je endpoint iets anders dan een 2xx teruggeeft, krijg je een nieuwe poging met backoff, tot ongeveer twee dagen aan retries. Dat klinkt vriendelijk, tot je endpoint traag is in plaats van stuk. Een handler van 30 seconden die wel netjes klaar is maar niet binnen de timeout antwoordt, wordt drie parallelle handlers die elk een subscription uitleveren.

Waarschuwing

De webhook-timeout van Mollie is 15 seconden. Alles wat zwaarder is dan een database-write en een enqueue hoort op een achtergrond-job, niet in de handler.

Ten derde: idempotency is jouw probleem. Mollie ondertekent de request body niet op een manier die je een unieke event-ID geeft. Het payment ID is wat je hebt. Bouw een unieke index op (payment_id, status) in je processed-events-tabel en laat de database de tweede insert weigeren. Vertrouw niet op je applicatielogica als enige schrijver.

Nog één detail dat mensen missen: de webhook-URL van Mollie moet publiek bereikbaar zijn over HTTPS. Tunnels werken voor lokaal werk, maar als je staging achter Basic Auth staat, faalt Mollie stilletjes en zie je de events pas als je in het dashboard kijkt. Hun webhook-documentatie beschrijft het retry-schema in detail.

Stripe: signatures, klokken en het verkeerde event

Stripe geeft je het tegenovergestelde probleem. De webhook-payload is het hele event. De pijn zit in het verifiëren.

Elke Stripe-webhook komt binnen met een Stripe-Signature header. Daarin zit een timestamp en één of meerdere HMAC-SHA256 signatures, berekend over timestamp.payload met de signing secret van je endpoint. De SDK doet de verificatie voor je, maar alleen als je de raw request body meegeeft. Frameworks die JSON automatisch parsen vóór je handler draait (Express met body-parser, NestJS met de default pipes, Laravel met middleware die aan de body komt) breken signature-verificatie op een manier die op een configuratiefout lijkt.

// Express, Stripe webhook
import express from 'express'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const app = express()

// Raw body for THIS route only, before any JSON parser
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'] as string
    let event: Stripe.Event
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET!
      )
    } catch (err) {
      return res.status(400).send(`Signature failed: ${(err as Error).message}`)
    }

    // your event handling here
    res.json({ received: true })
  }
)

Klok-skew is de volgende valkuil. De default tolerance window van Stripe is vijf minuten. Als de klok van je server wegloopt (goedkope VPS zonder NTP, container zonder juiste tijdbron, een Kubernetes-node met verkeerd ingestelde time sync), beginnen geldige signatures te falen omdat de timestamp buiten de tolerance valt. We zijn dit twee keer in het wild tegengekomen, en beide keren zat het team vier uur in de verkeerde webhook secret te zoeken.

Dan de vraag welk event je moet pakken. charge.succeeded en payment_intent.succeeded vuren allebei bij een geslaagde kaartbetaling, en nieuwe developers wiren ze allebei, om vervolgens deduplicatie-code te schrijven die het mis heeft. Stripe's eigen webhook-richtlijn is duidelijk: kies één event per business outcome en negeer de rest. Voor de meeste moderne Stripe-integraties is dat payment_intent.succeeded voor one-shot betalingen, invoice.paid voor subscriptions, en checkout.session.completed als je Checkout gebruikt.

Nog twee Stripe-punten om aan de muur te prikken:

  • Event-volgorde is niet gegarandeerd. Een charge.refunded kan binnenkomen vóór de oorspronkelijke charge.succeeded. Sorteer op het created-veld van het event als volgorde belangrijk is, en behandel je handler als een state machine, niet als een reeks stappen.
  • Webhook secret rotation. Als je roteert, zijn de oude en de nieuwe secret allebei geldig voor een tijdje. Configureer beide in je verifier (de SDK accepteert een array) en je roteert zonder downtime.

Buckaroo: status-soep en de push die te vroeg komt

Buckaroo noemt webhooks 'push notifications' en het model zit tussen Mollie en Stripe in. De payload is compleet, maar het status-oppervlak is groot. Waar Mollie zes statussen heeft en Stripe events, heeft Buckaroo brq_statuscode met twintig-plus subcodes, elk gekoppeld aan een brq_statusmessage.

De codes waar je echt om geeft, voor de meeste Nederlandse flows:

  • 190: success, het geld is van jou.
  • 490: failure, de kaart of bank van de klant heeft geweigerd.
  • 491: validation failure, er klopt iets niet in je request (niet de schuld van de klant).
  • 690: afgewezen door een check (3DS, fraude-regels).
  • 790: pending input, iDEAL-flows blijven hier hangen tot de klant het bij de bank afrondt.
  • 791: pending processing, normaal voor SEPA-incasso's die dagen nodig hebben om te klaren.
  • 792: pending op actie van de klant.
  • 793: on hold.

Als je code alleen vertakt op 190 en 490, ben je blind op de rest. We hebben iDEAL-transacties vijftien minuten op 790 zien staan omdat de bank-app van de klant was gecrasht, daarna ging het door naar 190, met een oprichter die overtuigd was dat zijn integratie stuk was omdat de orderpagina niets liet zien.

Kernpunt

Behandel de status code als input voor een state machine. Map elke code die je processor kan sturen naar één van {pending, paid, failed, refunded, chargeback, manual_review}, en wijs de rest aan de rand af.

De andere valkuil bij Buckaroo is timing. De push notification komt, en doet dat regelmatig, op je server aan vóórdat de browser van de klant terug is van de bank-redirect. Als je success-pagina de order-staat uit je database wil lezen en aanneemt dat die op dat moment al is bijgewerkt, race je de push. Als de push nog niet binnen is, race je ook. Bouw de success-pagina zodat die met allebei kan omgaan: render uit de order-staat als die bestaat, en val terug op een polling-indicator als die er niet is.

Signature-verificatie bij Buckaroo gebruikt HMAC-SHA256 met een secret key per website, en de te tekenen string concateneert de geposte velden in een specifieke volgorde. Hun developer-documentatie beschrijft het algoritme. De fout die we het vaakst zien: verifiëren tegen de geparste body in plaats van tegen de ruwe geposte velden. Dat werkt soms in test-mode en faalt in productie.

De idempotency-tabel die je op dag één had moeten bouwen

Over alle drie de processors heen voorkomt één patroon meer incidenten dan welke andere ook: één tabel die elk webhook-event dat je ooit hebt verwerkt vastlegt, met een unique constraint die dubbele verwerking onmogelijk maakt.

CREATE TABLE webhook_events (
  id              BIGSERIAL PRIMARY KEY,
  provider        TEXT NOT NULL,           -- 'mollie' | 'stripe' | 'buckaroo'
  external_id     TEXT NOT NULL,           -- event id or payment_id+status
  event_type      TEXT NOT NULL,
  payload         JSONB NOT NULL,
  received_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at    TIMESTAMPTZ,
  UNIQUE (provider, external_id)
);

De handler doet drie dingen op rij: insert de regel (faalt bij een duplicaat en kort de boel af), doe het werk, zet processed_at. Als het werk faalt, staat de regel er wel maar niet als processed, en heb je een schoon retry-oppervlak vanuit je eigen database in plaats van afhankelijk te zijn van de processor die het blijft proberen.

Voor Mollie, waar geen uniek event-ID is, gebruik je payment_id || ':' || status als external_id. Voor Stripe: event.id. Voor Buckaroo: brq_transactions || ':' || brq_statuscode. Verschillende providers, zelfde vorm.

Een audit van vijf minuten vóór je volgende deploy

Open je webhook-handler en check drie dingen. Antwoordt hij binnen twee seconden met een 2xx, met het zware werk verschoven naar een achtergrond-job. Levert hetzelfde event dat twee keer binnenkomt dezelfde database-staat op, afgedwongen door een unique constraint en niet door applicatielogica. Behandelt de handler statussen die je nu niet gebruikt als bekend maar genegeerd, zodat toekomstige statussen niet doorvallen naar een exception die op succes lijkt omdat er niets gelogd is.

Als één van die drie antwoorden 'nee' is, schrijf dan eerst de test die dat bewijst, voordat je de fix schrijft. De eerste keer dat het in productie stuk gaat, ben je blij dat je het deed.

Toen we de billing-laag bouwden voor een Nederlandse B2B-SaaS die Mollie en Stripe naast elkaar draait (Mollie voor SEPA, Stripe voor kaarten), was de valkuil die ons het hardst beet een Mollie-webhook die tijdens een door Stripe geleide subscription-migratie binnenkwam en met de Stripe-invoice botste. We hebben het opgelost met de tabel hierboven, gescoped per provider, plus een feature flag die de verwerking per provider pauzeert wanneer dat moet. Dat soort plumbing is waar het meeste van ons integratiewerk uit bestaat.

Kern

Insert het webhook event-id eerst in een tabel met een unique index, doe daarna het werk, markeer dan als processed. Idempotency is plumbing, geen applicatielogica.

FAQ

Waarom stuurt Mollie een webhook bij elke status-wijziging?

Zodat je een state machine kunt bouwen. iDEAL pending, paid, refunded en chargeback vuren allemaal, zodat je je order-staat accuraat houdt zonder de Mollie-API op een timer te pollen.

Hoe voorkom ik dat dezelfde Stripe-webhook twee keer wordt verwerkt?

Sla het Stripe event id op in een tabel met een unique constraint en insert het voordat je het werk doet. Stripe probeert het opnieuw bij non-2xx en identieke event ids komen boven als duplicate-key errors.

Wat is de veiligste manier om met de pending-statussen van Buckaroo om te gaan?

Map elke 79x-code naar een pending state, render de success-pagina in polling-mode, en laat de uiteindelijke success- of failure-push de order vooruitschuiven. Ga niet uit van sync.

Tekent Mollie zijn webhooks zoals Stripe dat doet?

Nee. Je verifieert door terug te bellen naar de Mollie-API met het payment id en die response te vertrouwen. Daarom moeten Mollie webhook-endpoints publiek bereikbaar zijn over HTTPS.

integrationssaase-commercearchitectureoperationsworkflow

Iets bouwen?

Start een project