← Blog

Integrations

Mollie, Stripe and Buckaroo webhooks: a Dutch cheatsheet

Your Mollie webhook lands twice, the Stripe signature fails by one second, and Buckaroo sends a push status you weren't expecting. The cheatsheet for your first month.

Jacob Molkenboer· Founder · A Brand New Company· 4 Jun 2026· 8 min
Three sealed envelopes with coloured wax seals, green ribbon, red stamp, brass clip on ivory paper desk.

It is 2:14 in the morning. You are a Dutch SaaS founder, three weeks past launch, and your support inbox just lit up with five customers claiming they paid twice. They didn't. Mollie fired your webhook three times in nine seconds because your first response took 17 seconds and timed out, your worker spun up a fresh container, and three subscriptions got provisioned against one payment ID. The money is fine. The state of your database is not.

Every payment processor on the Dutch market has its own way of breaking your weekend. Mollie, Stripe and Buckaroo each made different design choices about webhooks, and the gotchas don't transfer between them. What follows is the cheatsheet we wish we had handed our first ten clients before they shipped.

Mollie: the callback model

Mollie's webhook is a notification, not a payload. When a payment status changes, you get a POST with one field: id. That is it. To know what happened, you call back to Mollie's API with that ID and read the current state.

// 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);

Three things bite people here.

First, the webhook arrives for every status change. open at creation, pending for iDEAL after the bank redirect, authorized for credit cards in some flows, paid when the money lands, and later refunded or charged_back. If your handler only checks for paid, you miss the chargeback that comes in six weeks later and your fraud reporting goes blind.

Second, Mollie retries. If your endpoint returns anything other than a 2xx, Mollie hits you again with backoff, up to roughly two days of retries. That sounds friendly until your endpoint is slow rather than broken. A 30-second handler that finishes successfully but doesn't respond inside the timeout becomes three duplicate handlers running in parallel, each of which provisions a subscription.

Warning

Mollie's webhook timeout is 15 seconds. Anything heavier than a database write and an enqueue goes on a background job, not in the handler.

Third, idempotency is your problem. Mollie does not sign the request body in a way that gives you a unique event ID. The payment ID is what you have. Build a unique index on (payment_id, status) in your processed-events table and let the database refuse the second insert. Don't trust your application logic to be the only writer.

One more detail people miss: Mollie's webhook URL must be publicly reachable over HTTPS. Tunnels work for local dev, but if you run staging behind Basic Auth, Mollie fails silently and you do not see the events until you check the dashboard. Their webhook documentation covers the retry schedule in detail.

Stripe: signatures, clocks and the wrong event

Stripe gives you the opposite problem. The webhook payload is the full event. The pain is verifying it.

Every Stripe webhook arrives with a Stripe-Signature header. It contains a timestamp and one or more HMAC-SHA256 signatures computed against timestamp.payload using your endpoint's signing secret. The SDK does the verification for you, but only if you give it the raw request body. Frameworks that auto-parse JSON before your handler runs (Express with body-parser, NestJS with default pipes, Laravel with middleware that touches the body) break signature verification in a way that looks like a configuration error.

// 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 })
  }
)

Clock skew is the next trap. Stripe's default tolerance window is five minutes. If your server's clock drifts (cheap VPS without NTP, container without the right time source, a Kubernetes node with a misconfigured time sync), valid signatures start failing as the timestamp falls outside tolerance. We have seen this twice in the wild, and both times the team spent four hours blaming the wrong webhook secret.

Then there is the question of which event to listen to. charge.succeeded and payment_intent.succeeded both fire for a successful card payment, and new developers wire both, then write deduplication code that gets it wrong. Stripe's own webhook guidance is clear: pick one event per business outcome and ignore the others. For most modern Stripe integrations that is payment_intent.succeeded for one-shot payments, invoice.paid for subscriptions, and checkout.session.completed if you use Checkout.

Two more Stripe items worth pinning to the wall:

  • Event order is not guaranteed. A charge.refunded can land before the original charge.succeeded. Sort by the created field on the event when ordering matters, and treat your handler as a state machine, not a sequence of steps.
  • Webhook secret rotation. When you rotate, both the old and new secrets are valid for a window. Configure both in your verifier (the SDK accepts an array) and you can rotate without downtime.

Buckaroo: status soup and the push that arrives early

Buckaroo calls webhooks "push notifications" and the model sits between Mollie and Stripe. The payload is full, but the status surface is large. Where Mollie has six statuses and Stripe has events, Buckaroo has brq_statuscode with twenty-plus subcodes, each mapped to a brq_statusmessage.

The codes you actually care about, for most Dutch flows:

  • 190: success, money is yours.
  • 490: failure, the customer's card or bank declined.
  • 491: validation failure, something in your request was wrong (not the customer's fault).
  • 690: rejected by a check (3DS, fraud rules).
  • 790: pending input, iDEAL flows sit here until the user finishes at their bank.
  • 791: pending processing, common for SEPA direct debits that take days to clear.
  • 792: pending on customer action.
  • 793: on hold.

If your code only branches on 190 and 490 you are flying blind on the rest. We have seen iDEAL transactions sit at 790 for fifteen minutes because the customer's bank app crashed, then resolve to 190, with the founder convinced their integration was broken because the order page showed nothing.

Takeaway

Treat the status code as a state machine input. Map every code your processor can emit to one of {pending, paid, failed, refunded, chargeback, manual_review}, and reject the rest at the edge.

Buckaroo's other trap is timing. The push notification can, and regularly does, arrive at your server before the customer's browser returns from the bank redirect. If your success page tries to read the order state from your database and assumes it has been updated by then, you race the push. If the push hasn't arrived yet, you also race. Build the success page to handle both: render from the order state if it exists, and fall back to a polling indicator if it does not.

Signature verification on Buckaroo uses HMAC-SHA256 with a secret key per website, and the signed string concatenates the POSTed fields in a specific order. Their developer documentation covers the algorithm. The mistake we see most often is verifying against the parsed body rather than the raw posted fields, which sometimes works in test mode and fails in production.

The idempotency table you should have built on day one

Across all three processors, one pattern saves more incidents than any other: a single table that records every webhook event you have ever processed, with a unique constraint that makes duplicate processing impossible.

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)
);

The handler does three things in order: insert the row (which fails on duplicate and short-circuits), do the work, set processed_at. If the work fails, the row is there but unprocessed, and you have a clean retry surface from your own database rather than relying on the processor to keep retrying.

For Mollie, where there is no unique event ID, use payment_id || ':' || status as the external_id. For Stripe, use event.id. For Buckaroo, use brq_transactions || ':' || brq_statuscode. Different providers, same shape.

A five-minute audit before the next deploy

Open your webhook handler and check three things. Does it respond with a 2xx in under two seconds, with the heavy work moved to a background job. Does the same event arriving twice produce the same database state, verified by a unique constraint and not by application logic. Does the handler treat statuses you don't currently use as known but ignored, so future statuses don't fall through to an exception that looks like success because nothing logged.

If any of those three answers is no, write the test that proves it before you write the fix. The first time it breaks in production, you will be glad you did.

When we built the billing layer for a Dutch B2B SaaS that runs Mollie and Stripe in parallel (Mollie for SEPA, Stripe for cards), the gotcha that bit us hardest was a Mollie webhook firing during a Stripe-led subscription migration and racing the Stripe invoice. We solved it with the table above, scoped per provider, plus a feature flag that pauses processing per provider when we need to. That kind of plumbing is what most of our integration work looks like.

Key takeaway

Insert the webhook event id into a unique-indexed table first, then do the work, then mark processed. Idempotency is plumbing, not application logic.

FAQ

Why does Mollie send a webhook for every status change?

So you can build a state machine. iDEAL pending, paid, refunded and chargeback all fire so you can keep your order state accurate without polling Mollie's API on a timer.

How do I stop the same Stripe webhook being processed twice?

Store the Stripe event id in a table with a unique constraint and insert it before you do the work. Stripe retries on non-2xx and identical event ids surface as duplicate-key errors.

What is the safest way to handle Buckaroo's pending statuses?

Map every 79x code to a pending state, render the success page in a polling mode, and let the eventual success or failure push transition the order forward. Do not assume sync.

Does Mollie sign its webhooks like Stripe does?

No. You verify by calling back to the Mollie API with the payment id and trusting that response. That is why Mollie webhook endpoints must be publicly reachable over HTTPS.

integrationssaase-commercearchitectureoperationsworkflow

Building something?

Start a project