# Guide to Mollie Webhooks: Features and Best Practices

Mollie webhooks tell your systems when something changes in your Mollie account: a payment is paid, fails or expires, a payment link is used, a sales invoice is settled, a payout lands. If you're running checkout, order fulfillment or finance reconciliation on Mollie, webhooks are how your backend learns about those changes without polling the API.

This guide covers Mollie's two webhook systems (classic and next-gen), how to secure each one, the platform's delivery limits, and the best practices for production.

## What are Mollie webhooks?

Mollie webhooks are HTTP callbacks that notify your application when a resource changes state. Mollie runs two webhook systems side by side, and they work very differently.

Classic webhooks are attached to individual resources. You set a `webhookUrl` when you create a payment (or an order, subscription or payment link), and Mollie POSTs to it whenever that resource's status changes. The request carries no status, no event type and no signature: the body is a single form-encoded parameter, `id=tr_5B8cwPMGnU6qLbRvo7qEZo`. Your handler fetches the resource from the Mollie API to find out what happened.

Next-gen webhooks are account-level subscriptions. You register a URL once, choose the event types you want, and Mollie POSTs a JSON event to it. Each event has `resource: "event"`, an event `id`, a `type` such as `payment-link.paid`, the `entityId` of the affected object, `createdAt` and `_links`. With the full payload option, the event also carries a snapshot of the resource under `_embedded`. Next-gen deliveries are signed with an `X-Mollie-Signature` header.

## Mollie webhook features

| Feature | Details |
| --- | --- |
| Configuration | Classic: `webhookUrl` set per resource via the API. Next-gen: Dashboard (Developers > Webhooks) or API (`POST /v2/webhooks`) |
| Event selection | Classic: none, every status change of the resource is sent. Next-gen: a list of `eventTypes`, or `*` for all |
| Authentication | Classic: unsigned, confirm by fetching the resource. Next-gen: HMAC-SHA256 (hex) of the raw body in `X-Mollie-Signature`, formatted `sha256=<signature>` |
| Payload | Classic: `application/x-www-form-urlencoded` body with a single `id`. Next-gen: JSON event with `type` and `entityId`, plus an `_embedded` snapshot in full payload mode |
| Secret rotation | Next-gen: two signature headers on every event for 24 hours after a rotation |
| Timeout | 15 seconds |
| Retries | Up to 10 attempts over roughly 26 hours, with increasing intervals; anything other than `200 OK` counts as a failure |
| Failure handling | Next-gen: a webhook that keeps failing over 24 hours is marked `blocked` and delivery stops |
| Transport | Next-gen live mode requires an HTTPS URL; test mode has no such requirement |
| Limits | Next-gen: 2 webhook subscriptions per organization in test mode, up to 3 in live mode |

## Common events

Next-gen event names are the `type` values. Classic webhooks have no event type at all.

| Event | Fires when |
| --- | --- |
| `payment.paid` | A payment succeeds |
| `payment.authorized` | Funds are reserved for a pay-later or two-step payment |
| `payment.failed` | A payment attempt fails |
| `payment.expired` | A payment is not completed in time |
| `payment.canceled` | A payment is canceled before completion |
| `payment-link.paid` | A payment link is paid |
| `sales-invoice.issued` | A sales invoice is issued |
| `sales-invoice.paid` | A sales invoice is paid |
| `payout.completed` | A payout completes |
| `payout.failed` | A payout fails |
| `balance-transaction.created` | A new transaction is recorded on your balance |
| `refund.refunded` | A refund completes (beta) |
| `dispute.created` | A dispute is opened (beta) |

For next-gen webhooks, branch on the top-level `type` field and use `entityId` to locate the object. For classic webhooks, fetch the resource by `id` and branch on its `status` (`paid`, `authorized`, `failed`, `expired`, `canceled` and so on).

> Inspect Mollie webhooks as they arrive. Point Mollie at a [Hookdeck Console](https://console.hookdeck.com) URL to inspect and replay real deliveries — no account or setup required.

## Setting up Mollie webhooks

For classic webhooks, there is no dashboard setting. You pass `webhookUrl` each time you create a payment, and Mollie calls it on every status change for that payment, including refunds and chargebacks:

```javascript
const { createMollieClient } = require('@mollie/api-client');
const mollie = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY });

const payment = await mollie.payments.create({
  amount: { currency: 'EUR', value: '10.00' },
  description: 'Order #12345',
  redirectUrl: 'https://your-app.com/order/12345',
  webhookUrl: 'https://your-app.com/webhooks/mollie',
  metadata: { order_id: '12345' },
});

```

For next-gen webhooks, create a subscription in the dashboard under Developers > Webhooks, or via the API. The call requires an access token with the `webhooks.write` scope:

```bash
curl -X POST https://api.mollie.com/v2/webhooks \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -d "name=Order events" \
  -d "url=https://your-app.com/webhooks/mollie/events" \
  -d "eventTypes=payment-link.paid"

```

Pass `eventTypes` as a list or `*` for every event, and `testmode=true` to create a test-mode subscription. The response includes a `webhookSecret`: store it, because it is the key you verify signatures with. Live-mode subscriptions need an HTTPS URL.

For local development, use the [Hookdeck CLI](/docs/cli): `hookdeck listen 3000 mollie --path /webhooks/mollie` gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Use the generated URL as the `webhookUrl` on test payments, or as the URL of a test-mode next-gen subscription.

## Securing Mollie webhooks

The two systems are secured in different ways, so they need different handlers.

Next-gen webhooks carry `X-Mollie-Signature: sha256=<signature>`, where the signature is a hex-encoded HMAC-SHA256 of the unaltered request body, keyed with your webhook secret. Strip the `sha256=` prefix, compute the HMAC over the raw body, and compare with a timing-safe function. During a secret rotation the header is sent twice for 24 hours; Node joins duplicate headers into one comma-separated string, so accept the request if either value matches.

Classic webhooks have nothing to verify. Mollie's security model is fetch-to-confirm: read the `id`, fetch the payment with your API key, and act only on the status the API returns. A forged request can at most make you re-fetch a payment you already own.

```javascript
const crypto = require('crypto');
const express = require('express');
const { createMollieClient } = require('@mollie/api-client');

const app = express();
const mollie = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY });

function verifyMollieSignature(rawBody, header, secret) {
  if (!header) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  // During a rotation Mollie sends two headers: "sha256=<a>, sha256=<b>"
  return header.split(',').some((part) => {
    const signature = part.trim().replace(/^sha256=/, '');
    try {
      return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    } catch {
      return false; // Buffers of different lengths throw
    }
  });
}

// Next-gen webhooks: signed JSON events
app.post('/webhooks/mollie/events', express.raw({ type: '*/*' }), (req, res) => {
  const isValid = verifyMollieSignature(
    req.body,
    req.headers['x-mollie-signature'],
    process.env.MOLLIE_WEBHOOK_SECRET
  );
  if (!isValid) {
    return res.status(400).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  switch (event.type) {
    case 'payment-link.paid':
      // Look up event.entityId, fulfill the order
      break;
    case 'payout.failed':
      // Alert finance
      break;
  }

  res.status(200).send('OK');
});

// Classic webhooks: unsigned, form-encoded, id only
app.post('/webhooks/mollie', express.urlencoded({ extended: false }), async (req, res) => {
  const id = req.body && req.body.id;
  if (!id) {
    return res.status(400).send('Missing id');
  }

  let payment;
  try {
    payment = await mollie.payments.get(id);
  } catch (err) {
    if (err && err.statusCode === 404) {
      return res.status(200).send('OK'); // Unknown id: acknowledge, nothing to do
    }
    return res.status(500).send('Could not fetch payment'); // Let Mollie retry
  }

  switch (payment.status) {
    case 'paid':
      // Fulfill the order
      break;
    case 'expired':
    case 'failed':
    case 'canceled':
      // Release reserved stock
      break;
  }

  res.status(200).send('OK');
});

```

Note the body parsers: next-gen signatures need the raw bytes (`express.raw()`), while classic webhooks are form-encoded (`express.urlencoded()`), so a JSON parser breaks both.

> Make Mollie webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies deliveries at the edge, deduplicates, and durably queues every event with replay for anything that fails.

## Mollie webhook limitations and pain points

### Classic webhooks carry no signature and no data

The Problem: A classic webhook body is `id=tr_...` and nothing else. There is no signature to check, and no status or event type to act on, so every delivery costs an API round trip before you know what happened. If the Mollie API is slow or unreachable at that moment, you cannot process the webhook at all.

Why It Happens: Mollie designed classic webhooks around fetch-to-confirm. Because the status is never sent, a forged request cannot mark an order as paid, so a signature was never needed. Mollie also advises against IP allowlisting, as its webhook source IPs change.

Workarounds:

* Treat the body as a hint only and always act on the fetched `status`.
* Return `500` when the fetch fails for a transient reason so Mollie retries, and `200` for a `404` so it stops.

How Hookdeck Can Help: Hookdeck cannot verify classic webhooks, since there is no signature to check, but it gives them a durable entry point. Every delivery is logged, queued and [retried](/docs/retries) on your schedule, so a failed fetch on your side does not depend on Mollie's retry window, and you can replay any delivery once the Mollie API is reachable again.

### Two webhook systems with different contracts

The Problem: Classic and next-gen webhooks differ in content type, payload, security and event coverage. Classic covers payments, orders, subscriptions and payment links per resource. Next-gen adds payouts, sales invoices, balance transactions and business account transfers, with refunds, chargebacks and disputes still in beta. Many integrations end up running both.

Why It Happens: Next-gen webhooks were introduced alongside the existing per-resource model rather than replacing it, so both remain in use.

Workarounds:

* Give each system its own route and body parser, as in the example above.
* Keep classic webhooks for resources that next-gen does not yet cover, and move the rest to subscriptions.

How Hookdeck Can Help: Create one Hookdeck source per system. Turn on HMAC verification for the next-gen source (Hookdeck's Mollie source type verifies `X-Mollie-Signature`) and leave it off for the classic source, since unsigned `id=tr_...` posts would fail verification. Then use [filters](/docs/filters) on the next-gen `type` field to route event families to different destinations.

### Failed next-gen webhooks get blocked

The Problem: Mollie waits 15 seconds for a response and retries up to 10 times over roughly 26 hours. If a next-gen webhook keeps failing across that period, Mollie marks it `blocked` and stops delivering to it entirely.

Why It Happens: Mollie expects `200 OK` quickly and treats slow or non-200 responses as failures. Repeated failures are taken as a sign the endpoint is gone.

Workarounds:

* Return `200` as soon as the signature checks out, and do the work in a background job.
* Monitor the webhook's `status` in the dashboard or via the API, and re-enable it after an incident.

How Hookdeck Can Help: Hookdeck accepts each delivery and responds immediately, so Mollie sees a healthy endpoint even while your service is down. Events wait in a durable queue, retry against your service on your own schedule, and [Issues](/docs/issues) alert you to failures, with bulk replay once the fix is deployed.

### Secret rotation sends two signatures

The Problem: When you rotate a next-gen webhook secret, every event carries two signature headers for 24 hours. A handler that treats the header as a single value fails verification for the whole rotation window.

Why It Happens: Mollie signs with both the old and new secrets during rotation so you can switch your stored secret without dropping events. Most frameworks join repeated headers into one comma-separated value.

Workarounds:

* Split the header on commas and accept the request if any value matches.
* Update the stored secret within the 24-hour window, after which the old one stops working.

How Hookdeck Can Help: Hookdeck's HMAC verification splits the combined `sha256=<a>, sha256=<b>` header, so verification keeps passing through the rotation and you only update the secret in one place.

## Best practices

### Fetch to confirm on classic webhooks

Never take a classic webhook body as proof of anything. Fetch the payment with your API key, read `status`, and act on that. Return `200` for ids Mollie doesn't recognize, so unknown or deleted ids don't trigger a day of retries.

### Verify next-gen signatures against the raw body

Compute the HMAC over the unaltered body with `.digest('hex')`, strip the `sha256=` prefix from the header, compare with `crypto.timingSafeEqual`, and handle the two-header case during rotation.

### Acknowledge fast, process asynchronously

With a 15-second timeout and a `blocked` state for persistently failing next-gen webhooks, slow handlers carry real risk. Return `200` once the request is accepted and move fulfillment, emails and ledger updates to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Mollie may call your webhook more than once for the same status, and every retry is another delivery. Key next-gen processing on the event `id`, and make classic status handling safe to repeat so a second `paid` never ships an order twice. Don't deduplicate classic webhooks on the body: every status change for a payment sends the identical `id=tr_...`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Test with test mode

Use a `test_` API key and Mollie's test checkout to drive payments into each status, and a test-mode next-gen subscription to receive events without an HTTPS requirement. Switching to live changes the key and secret, not the handler code.

## Conclusion

Mollie gives you two ways to receive webhooks. Classic webhooks attach to each payment, send only an id, and rely on you fetching the resource to learn its status. Next-gen webhooks are account-level subscriptions that send typed JSON events signed with HMAC-SHA256 in `X-Mollie-Signature`. Most production integrations need to handle both, each with its own parser and its own security check.

Delivery stops at 10 attempts over about 26 hours, and a next-gen webhook that keeps failing is blocked, so an endpoint that acknowledges quickly and processes in the background protects you from losing events. [Hookdeck Event Gateway](/event-gateway) verifies next-gen signatures, queues classic and next-gen deliveries durably, and lets you replay anything that fails, so your handlers stay small and your event feed stays on.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Mollie webhooks reliably in minutes.