← Blog

Integrations

Mollie refund double-fires: the idempotency key we missed

On a Friday night, a Dutch fashion brand's refund queue started double-firing. Eleven hours later we caught it. Here is what broke and the key that fixes it.

Jacob Molkenboer· Founder · A Brand New Company· 5 Jun 2026· 9 min
Two brass postage tokens on a cream index card with a green wax seal, ledger pen and folded receipt on a worn desk blotter.

The 22:47 Slack message

At 22:47 on a Friday in March, a finance-ops lead at a Dutch fashion brand pinged us. "Did you push something today? Our Mollie dashboard shows two refunds per order on every return we processed." Her screenshot was unambiguous. 412 returns. 824 refund rows. Same amounts, same IBANs, same products, fired roughly 90 seconds apart.

The brand had been live on a Make scenario we wrote eleven months earlier. It watched their returns portal, validated the RMA, then called Mollie to issue the refund. It had processed 38,000 returns without a hiccup. Until that afternoon, when it started double-firing on every single one.

This is the incident walkthrough. What the scenario did, why it broke, how we found out eleven hours later than we should have, and the four-line fix that would have made the whole thing impossible from day one.

The architecture

The setup was boring on purpose. The returns portal POSTs an event to a Make webhook. Make pulls the original Mollie payment ID from a Google Sheet, validates the refund amount against the order line, then calls Mollie's refund endpoint. On success it writes a row to the sheet and emails the customer.

The refund call looked roughly like this:

POST https://api.mollie.com/v2/payments/tr_WDqYK6vjvE/refunds
Authorization: Bearer live_*****
Content-Type: application/json

{
  "amount": { "currency": "EUR", "value": "59.95" },
  "description": "Return RMA-48211"
}

No idempotency header. No request hash. Make's built-in HTTP module does not add one by default, and we never asked for it. In eleven months of clean traffic, we never had to.

The failure mode

On the day of the incident, Mollie had a brief slowdown on the refunds endpoint. Median response time went from roughly 180ms to roughly 14 seconds for about ninety minutes. Make's HTTP module has a default request timeout of 40 seconds, but this scenario was configured with a tighter 8-second timeout because we wanted fast failure on the customer-facing webhook.

So Make made the refund call. Mollie received it, accepted it, started processing. After 8 seconds Mollie had not yet returned a 201. Make's HTTP module raised a timeout error. The scenario was wired to retry on transient HTTP errors. It retried. Mollie accepted the second call as a brand new request, because nothing in the payload identified it as a replay. The first call eventually completed. Then the retry completed. Two refunds on the same return.

This is the simplest failure mode in any payment integration. We knew it existed. We have written about it for other clients. We had not written it into this scenario.

Warning

If your automation tool retries failed HTTP calls and your payment provider supports idempotency keys, the absence of those keys is not a stylistic choice. It is a latent bug waiting for one slow afternoon at your provider.

The eleven hours we lost

The first double-refund hit at 11:52. Finance-ops noticed at 22:47. Eleven hours and change. Worth saying out loud: not a single layer of our monitoring caught this.

Why not?

The Make scenario reported success on every run, because both the original call and the retry returned 201. From Make's perspective, the scenario completed correctly 412 times that day. Our error-rate alert was wired to scenario failures. There were none.

The Google Sheet did flag duplicate refund rows, but only on a daily reconciliation script that ran at 02:00. It would have caught the incident, but only after another four hours of bleeding.

Mollie's dashboard showed the doubles instantly. Nobody was looking at it.

The lesson, separate from the idempotency fix: success-rate monitoring on the automation layer tells you nothing about correctness at the destination. We now reconcile against the Mollie API every fifteen minutes, not once a day.

The four-line fix

Mollie's API has supported an Idempotency-Key header for years. Send the same key twice, the second call returns the original response without creating a new refund. Their docs are explicit: "the resource will not be created or processed more than once." The same pattern is the canonical answer at Stripe, Adyen, and every serious payment API.

The fix is generating a stable key per intended refund (not per HTTP call) and sending it with the request. In Make this is a single extra header on the HTTP module, sourced from a deterministic field that survives retries. We use the RMA number plus the refund line index, hashed.

POST https://api.mollie.com/v2/payments/tr_WDqYK6vjvE/refunds
Authorization: Bearer live_*****
Idempotency-Key: rma-48211-line-1-9c2f8b
Content-Type: application/json

{
  "amount": { "currency": "EUR", "value": "59.95" },
  "description": "Return RMA-48211"
}

If you are calling Mollie from Node or PHP rather than Make, the principle is identical. Here is the Node version with the official client:

import createMollieClient from "@mollie/api-client";
import crypto from "node:crypto";

const mollie = createMollieClient({ apiKey: process.env.MOLLIE_KEY });

async function refundReturn(rma, paymentId, amount) {
  const key = crypto
    .createHash("sha1")
    .update(`refund:${rma}:${amount.value}`)
    .digest("hex")
    .slice(0, 16);

  return mollie.payment_refunds.create({
    paymentId,
    amount,
    description: `Return ${rma}`,
    idempotencyKey: key,
  });
}

The key derives from data that is stable across retries. Do not use a fresh UUID generated inside the retry block, that defeats the entire mechanism. Do not use a timestamp. Use the business-level identifier of the thing you are trying to do once.

Why Make's defaults make this easy to miss

Make's HTTP module is fantastic, and that is part of the problem. You point it at any REST endpoint, set the auth, paste the JSON body, and ship. The Headers section is collapsed by default. There is no warning when you POST to a payment endpoint without idempotency, because Make does not know that endpoint is special. The provider knows. The tool does not.

The same is true of Zapier, n8n, and every low-code automation runner. They are payload couriers. The semantics of "this call must run exactly once" live in your head and in the provider's docs, not in the workflow editor. If you build payment flows in these tools, that semantic gap is where your incidents will come from.

Where this hides in your stack

Mollie refunds are the obvious place. Less obvious places we have seen the same bug in client systems this year:

  • Stripe charges fired from a Zapier scenario where the trigger is a Shopify webhook that Shopify itself sometimes redelivers.
  • WooCommerce order-completed webhooks consumed by a Make scenario that posts to a third-party fulfilment API with no dedupe on the destination side.
  • Transactional email steps that send to the customer twice when the upstream provider is slow, no X-Idempotency-Key on the mail provider.
  • Internal "mark invoice paid" calls in an ERP, called by a queue worker that retries on any 5xx.

Anywhere a retry meets a side-effecting POST without a key, you have this bug. It will sit quiet until your provider has a slow ten minutes.

A five-minute audit you can run today

If you run any automation that issues refunds, charges, mails, or messages, do this before you close the laptop tonight:

  1. List every step in every scenario that POSTs to a third-party API with a side effect.
  2. For each, check whether the API supports an idempotency header. Mollie, Stripe, Adyen, GoCardless, SendGrid, Postmark, and Twilio all do. Read their docs, not your memory.
  3. For each that supports it, check whether your scenario sends one. In Make this is the Headers section of the HTTP module. In code, grep for the SDK option.
  4. For each that supports it but you are not sending, write down the business-level identifier that uniquely names the action. Refund of RMA-48211. Charge for invoice 2026-0488. Welcome email for user 9132.
  5. Add the header. Hash the identifier if it is sensitive. Ship.

This audit usually takes longer than five minutes because step three surfaces uncomfortable answers. That is the point.

Takeaway

Idempotency keys are not an optimisation. They are the difference between a retry that heals and a retry that doubles your liability.

What we changed after the incident

The brand was made whole. Mollie's support team helped us identify all duplicate refund IDs from the affected window, and we cancelled the 412 extras through their dashboard within two working days. No customer ended up with two refunds on their statement.

We changed three things in the scenario. One, every Mollie call now carries a derived idempotency key sourced from the RMA. Two, the scenario timeout is back to Mollie's recommended 40 seconds, because optimising for fast failure on a payment call was the wrong instinct. Three, a separate Make scenario polls Mollie every fifteen minutes and reconciles refund counts against the returns portal. Mismatch triggers a Slack page, not an email.

When we built the returns-automation for this client, the gap was that we shipped the scenario without the idempotency header because Make made it easy not to. The fix was four lines and one header. The reason it took eleven hours to detect was that nothing downstream of Make was watching the actual money. If you run process automation that touches payments, the reconciliation layer is not optional.

The smallest thing worth doing today: open the HTTP module in your most expensive automation, find the side-effecting POST, and add one header. Pick the business identifier, hash it, ship it. The audit comes second.

Key takeaway

Idempotency keys are not an optimisation. They are the difference between a retry that heals and a retry that doubles your liability.

FAQ

What does an idempotency key actually do?

It tells the API that a request with the same key as a prior one is a replay, not a new action. The provider returns the original response and creates no new resource.

Does Make.com support idempotency keys?

Yes. Add the provider's idempotency header (e.g. Idempotency-Key for Mollie and Stripe) in the Headers section of the HTTP module. The value should be derived from a stable business identifier, not a fresh UUID.

What should I use as the key value?

The identifier of the thing you are trying to do once: an RMA number, an invoice ID, a user-action ID. Hash it if it is sensitive. Never use a timestamp or a UUID generated inside the retry.

How do I find double-fires that already happened?

Query your provider's API for refunds (or charges, or messages) grouped by amount and source ID within a tight time window. Mollie's list-refunds endpoint plus a 5-minute bucket will surface them quickly.

integrationsautomatione-commerceworkflowcase studyprocess automation

Building something?

Start a project