Stripe to Mollie Migration: What Actually Breaks

typescript dev.to

I did not switch to Mollie because I love it. I switched because one morning I woke up to this email from Stripe:

We recently identified payments on your Stripe account for htpbe.tech that don't appear to have been authorised by the customer. This means that the owner of the card or bank account didn't consent to these payments.

The product is htpbe.tech — a tool that detects whether a PDF has been edited. The name is the question: Has This PDF Been Edited? It's forensic document analysis. To a risk classifier that reads keywords, "fake document detection" sits one embedding away from "fake document creation." I'm fairly sure that's how I got flagged — not by a human, by a model that saw the wrong neighbourhood.

The email gave me five business days to appeal: fill in a form on the dashboard, explain the business, ask for another review. So of course I believed it was a misunderstanding, and I sent everything. A fully legal Finnish company. A registered developer. Stripe's own certification courses. Tax records. There is nothing fraudulent in this business to find — it's about as clean as a business gets.

Eight seconds after the upload finished, the verdict came back:

Unfortunately, after conducting a further review of your account, we've determined that we still won't be able to support htpbe.tech moving forward.

Eight seconds. Impressive turnaround for "a further review" of a folder of business documents.

What followed was a one-sided correspondence. I wrote; Stripe mostly didn't. Across several emails I got about ten automated "we have received your message" acknowledgements and exactly one reply from an actual person, who told me the account was "about to be reinstated." Then silence. I asked the only question that mattered to me — is the account closed for good, or are you still reviewing it? I need to know what to do next — and never got an answer to it. Ever.

Then the cherry on top: Stripe refunded the last five days of payments back to my customers. So my paying customers got the service and their money back. We sorted that out between ourselves — my customers turned out to be considerably easier to deal with than Stripe was.

I'm not writing this to litigate Stripe's risk policy; they can offboard whoever they want. I'm writing it because of what the next two weeks taught me, starting the moment the payments stopped:

Stripe is not a payment processor. Stripe is a billing platform. When you leave, you don't lose a charge API. You lose Subscriptions, the Customer Portal, Stripe Tax, Invoicing, smart retries, and proration — all the things you forgot were Stripe's because they were always just there. Every one of those is now your problem to rebuild.

That's the real story of moving to Mollie. The payments were the easy part.

TL;DR

If you're considering the same move, here's the whole article compressed:

  • Mollie is EU-based and keeps me the seller of record — that's why I moved, not to save on fees. It's a thin payment layer, not a billing platform.
  • One-off payments took a day. Subscriptions took two weeks.
  • Mollie has no subscription object the way Stripe does. You build recurring billing out of a Customer + a Mandate (permission to charge a card).
  • The webhook sends only an id. No event type, no payload, no signature. You go and re-read the resource yourself.
  • No proration, no Customer Portal, no automatic VAT, no dunning, no invoices. I wrote all of them by hand.
  • Existing subscribers can't be migrated. A card mandate does not transfer between providers. Your subscribers have to re-subscribe, and not all of them will.

Why Mollie (and What Else I Looked At)

My criteria after the Stripe experience were narrow:

  • EU-resident provider, EU data residency
  • Card payments, plus the local EU methods (iDEAL, Bancontact, SEPA)
  • I stay the seller of record — I keep control of the relationship, the invoice, the VAT
  • Transparent pricing, no opaque "platform fee"
  • And, critically, no second high-risk moderation gauntlet waiting to disable me again

That last point quietly eliminated half the market.

Paddle / Lemon Squeezy (Merchant of Record). Good products. The MoR takes the VAT burden and the fraud risk off your plate entirely — they become the seller of record, they remit the tax. But that's exactly the trade I didn't want. You hand them control of the checkout, the invoice, the customer relationship, and the pricing. And you're back on someone else's platform, subject to someone else's moderation — the precise situation I had just been ejected from. Higher percentage, less control, same single point of failure. No.

Chargebee / Recurly. Real billing engines that sit on top of a payment processor. Powerful. Also another integration to own, another vendor, and overkill for my volume. If I were doing complex enterprise billing with seats and metering and revenue recognition, maybe. I'm not.

Mollie standalone. EU-based, I keep the entire billing layer and stay seller of record. Fees weren't the reason — Mollie's per-transaction card rate is roughly on par with Stripe's, if anything a touch higher. What I was buying was jurisdiction and control, not a discount. The cost is the obvious one: I have to write the billing layer. I picked it with my eyes open.

This is the same trade-off space I wrote about in subscription billing edge cases — except there, Stripe was quietly handling the 70% I'm now responsible for.

One-Off Payments: The Easy Day

On Stripe, a one-off charge is a Checkout Session in payment mode. You point it at a Price ID from your catalogue, switch on automatic_tax and tax_id_collection, and Stripe handles the price, the VAT, the hosted page, and the receipt.

Mollie has none of that scaffolding. There's no price catalogue. You create a Payment, and you put the amount inline on every single charge:

// lib/billing/mollie.ts
import { createMollieClient } from "@mollie/api-client";

export const mollie = createMollieClient({
  apiKey: process.env.MOLLIE_API_KEY!,
});

// A one-off credit purchase
const payment = await mollie.payments.create({
  customerId,
  amount: { currency: "USD", value: "5.00" }, // always a string, always 2 decimals
  description: "HTPBE — 100 credits",
  redirectUrl: `${BASE_URL}/billing/return`,
  webhookUrl: `${BASE_URL}/api/mollie/webhook`,
  metadata: {
    userId,
    purchaseKind: "web_batch",
    credits: 100,
  },
});

// Send the customer to the hosted checkout
return Response.redirect(payment.getCheckoutUrl()!);
Enter fullscreen mode Exit fullscreen mode

Two things to internalise here, because they recur everywhere in Mollie:

  1. The amount is a string with exactly two decimals. "5.00", not 5 or 5.0. The currency is explicit and inline. There is no price object to reference.
  2. You charge gross. Mollie does not compute tax. The value you send is net + VAT, calculated by you, frozen by you. More on that below.

(The currency in these examples isn't hardcoded — it's the customer's pinned billingCurrency. The first purchase pins it, because Mollie mandates are currency-bound; after that, every charge for that customer reuses it. I support USD, EUR, and GBP, which is why the snippets here vary.)

And the one that bites people: Mollie does not tell you what was paid for. Stripe hands you line items. Mollie hands you a payment with whatever metadata you put on it. So the metadata is load-bearing — userId, purchaseKind, credits — and you re-verify it against your own database when the webhook fires. The payment object is the receipt; your metadata is the order.

That was a day's work. Then I started on subscriptions.

Subscriptions: Where the Two Weeks Went

On Stripe, a subscription is a first-class object. You attach a Price, you get trials, proration, smart retries, and a clean stream of typed events. It's a product.

On Mollie, a subscription is something you assemble from primitives. The central concept is the mandate: a stored permission to charge a customer's card. And you can't just create one — a mandate is born from a successful payment.

The sequence is the whole game:

import { SequenceType } from "@mollie/api-client";

// Step 1: a "first" payment. This is a real charge that ALSO creates a mandate.
const firstPayment = await mollie.payments.create({
  customerId,
  sequenceType: SequenceType.first,
  amount: { currency: "EUR", value: "19.00" },
  description: "HTPBE Pro — first month",
  redirectUrl: `${BASE_URL}/billing/return`,
  webhookUrl: `${BASE_URL}/api/mollie/webhook`,
  metadata: { userId, plan: "pro" },
});
Enter fullscreen mode Exit fullscreen mode

When that payment reaches paid, a mandate exists. Now — and only now — can you create the recurring subscription. You go and find the valid mandate, then bind a subscription to it:

// Step 2: after the first payment is paid, activate the subscription.
async function activateSubscription(userId: string, customerId: string) {
  const mandates = await mollie.customerMandates.page({ customerId });
  const validMandate = mandates.find((m) => m.status === "valid");
  if (!validMandate) {
    // Mandate not ready yet — throw, let Mollie retry the webhook (see the race below)
    throw new Error(`No valid mandate for customer ${customerId}`);
  }

  const subscription = await mollie.customerSubscriptions.create({
    customerId,
    amount: { currency: "EUR", value: "19.00" },
    interval: "1 month",
    mandateId: validMandate.id,
    webhookUrl: `${BASE_URL}/api/mollie/webhook`,
    metadata: { userId, plan: "pro" },
  });

  await db
    .update(users)
    .set({
      mollieMandateId: validMandate.id,
      mollieSubscriptionId: subscription.id,
      subscriptionStatus: "active",
    })
    .where(eq(users.id, userId));
}
Enter fullscreen mode Exit fullscreen mode

Save that mandateId. You will need it for every off-cycle charge you make for the rest of this customer's life. Now here is the list of things Stripe gave me for free that I had to build.

Proration — by hand

Mollie has no proration. When a customer upgrades mid-cycle, you owe them the difference for the remaining days, and Mollie won't compute it. So I do:

// Upgrade: charge the prorated difference off-session against the mandate.
async function upgrade(user: User, newPrice: number, oldPrice: number) {
  const now = Date.now();
  const remainingFraction =
    (user.currentPeriodEnd.getTime() - now) /
    (user.currentPeriodEnd.getTime() - user.currentPeriodStart.getTime());

  const netDiff = (newPrice - oldPrice) * remainingFraction;
  const { grossCents } = computeChargeVat(netDiff, user.country, user.vatIdValid);

  const payment = await mollie.payments.create({
    customerId: user.mollieCustomerId,
    mandateId: user.mollieMandateId, // off-session, no customer present
    sequenceType: SequenceType.recurring,
    amount: { currency: user.billingCurrency, value: toMollieValue(grossCents) },
    description: "Plan upgrade — prorated difference",
    webhookUrl: `${BASE_URL}/api/mollie/webhook`,
    idempotencyKey: `upgrade:${user.id}:${user.currentPeriodEnd.getTime()}`, // no double-charge on retry
  });

  // If the off-session charge is already paid synchronously, finalise now;
  // otherwise the webhook finalises it.
  if (payment.status === "paid") {
    await finaliseUpgrade(user, payment);
  }
}
Enter fullscreen mode Exit fullscreen mode

A downgrade is the opposite shape and the opposite timing. You don't charge anything now — you let the current period finish, then swap the subscription. Because Mollie subscriptions are immutable in their amount, "swap" means cancel the current one (the mandate survives the cancellation) and create a new one with a future start date:

// Downgrade: cancel current, recreate with a future start date at period end.
async function downgrade(user: User, newPlan: Plan) {
  await cancelMollieSubscription(user.mollieSubscriptionId); // mandate survives

  await mollie.customerSubscriptions.create({
    customerId: user.mollieCustomerId,
    amount: { currency: user.billingCurrency, value: newPlan.grossValue },
    interval: "1 month",
    startDate: formatDate(user.currentPeriodEnd), // "YYYY-MM-DD", the future
    mandateId: user.mollieMandateId,
    webhookUrl: `${BASE_URL}/api/mollie/webhook`,
    metadata: { userId: user.id, plan: newPlan.id },
  });

  await db.update(users).set({ pendingSubscriptionPlan: newPlan.id }).where(eq(users.id, user.id));
}
Enter fullscreen mode Exit fullscreen mode

No pre-charge webhook — which is a VAT problem

Stripe fires invoice.upcoming before a renewal, so you can recompute tax or apply changes ahead of the charge. Mollie has nothing equivalent. You cannot intercept "the day before renewal" to re-check a customer's VAT status before the money moves. I solved it with a periodic reconciliation job instead of an event — which works, but it's a worse model and you should know it's missing before you design around it.

Dunning — yours, and Mollie's default is brutal

This is the one that genuinely surprised me. On Stripe, a failed renewal triggers smart retries over several days, dunning emails, grace periods. On Mollie:

After one failed charge, Mollie cancels the subscription and kills the mandate. No retries.

One miss and the customer's payment authority is gone. There's no recovery by retry — recovery means the customer must re-authorise their card from scratch, which means a new first payment, which means a new mandate. You are back at step one of the whole sequence.

So dunning is entirely mine:

// On a failed recurring charge: enter a grace window, email once, tear down later.
async function handleFailedRenewal(user: User) {
  await db
    .update(users)
    .set({
      subscriptionStatus: "past_due",
      gracePeriodEndsAt: addDays(new Date(), 7), // 7-day grace
    })
    .where(eq(users.id, user.id));

  // dunningEmailSentAt is the idempotency guard — exactly one email per failure
  if (!user.dunningEmailSentAt) {
    await sendDunningEmail(user);
    await db.update(users).set({ dunningEmailSentAt: new Date() }).where(eq(users.id, user.id));
  }
}

// A cron job finalises subscriptions that never recovered.
async function finalizePastDueSubscriptions() {
  const expired = await db.query.users.findMany({
    where: and(eq(users.subscriptionStatus, "past_due"), lt(users.gracePeriodEndsAt, new Date())),
  });
  for (const user of expired) {
    await tearDownSubscription(user); // revoke access, mark canceled
  }
}

// Recovery is a brand-new first payment — the old mandate is dead.
async function recover(user: User) {
  return createSubscriptionFirstPayment(user); // SequenceType.first, again
}
Enter fullscreen mode Exit fullscreen mode

Seven-day grace, one email guarded by dunningEmailSentAt, a cron sweep, and a recovery path that loops all the way back to a first payment. None of this exists in Mollie. All of it exists in Stripe. That gap is most of the two weeks.

Webhooks: The Real Conceptual Difference

If you take one thing from this article, take this part.

Stripe webhooks are rich, typed events with a full payload and a signature. You receive customer.subscription.updated with the entire object and you trust it (after verifying the signature). I covered that model in Stripe webhooks done right.

Mollie's webhook is the opposite philosophy. It is a POST with a single form-encoded field: id. No type. No payload. No signature. Just an id.

// app/api/mollie/webhook/route.ts
export async function POST(request: Request) {
  const form = await request.formData();
  const id = form.get("id")?.toString();
  if (!id) return new Response("missing id", { status: 400 });

  // Route by id prefix — that's the only routing signal you get.
  if (id.startsWith("tr_")) {
    await reconcilePayment(id);
  } else if (id.startsWith("sub_")) {
    await reconcileSubscription(id);
  }

  return new Response("ok", { status: 200 });
}

async function reconcilePayment(id: string) {
  // You don't trust a payload — there isn't one. You go re-read the truth.
  const payment = await mollie.payments.get(id);
  // ... apply state based on payment.status, payment.metadata, etc.
}
Enter fullscreen mode Exit fullscreen mode

The pattern has a name worth saying out loud: reconcile-by-id. The webhook is not "apply this event." The webhook is "this resource changed — go re-read it and make your world agree with the provider's."

I resisted it at first. It's more HTTP round-trips, and it feels primitive next to Stripe's typed firehose. But it's safer for idempotency. There is no payload to be stale, replayed, or reordered against. Two deliveries of the same id converge on the same state, because both go and fetch the same authoritative resource. The webhook carries no trust because it carries no data. That's a feature.

You still need the usual idempotency guards underneath it, because re-reading the truth doesn't stop you from applying it twice:

// Idempotency: unique constraint on the Mollie payment id, ignore conflicts.
await db
  .insert(molliePayments)
  .values({ molliePaymentId: payment.id, userId, amountCents, ... })
  .onConflictDoNothing();

// Presence guard: don't recreate a subscription that already exists.
if (user.mollieSubscriptionId) return;

// Out-of-order guard: a "recurring" charge webhook can arrive BEFORE
// the activation finished. Handle the case where the subscription
// isn't in your DB yet, and let Mollie redeliver.
Enter fullscreen mode Exit fullscreen mode

If you're building any of this, the idempotency keys for API retries piece goes deeper on the claim-and-replay mechanics.

slug="api-integrations"
text="Building recurring billing on a thin payment provider — mandates, reconcile-by-id webhooks, proration, dunning, VAT? I've shipped this exact stack to production."
/>

Testing — The Part That's Not in the Docs

Mollie's keys are test_ / live_ prefixed, and there's a separate test mode in the dashboard. Standard stuff. What's missing matters more.

There is no Mollie CLI. No stripe listen, no stripe trigger. You cannot forward webhooks to localhost out of the box. You stand up a tunnel (cloudflared or ngrok) or you test against a staging deployment. Plan for that on day one.

The test matrix I actually ran, because every item here failed at least once during development:

  • Webhook idempotency. Deliver the same id twice. Credits must not double. Activations must not double. If your onConflictDoNothing is wrong, you find out here.
  • Multi-currency. A first payment in EUR, USD, and GBP. The currency pins to the customer on first purchase (Mollie mandates are currency-bound) and must never silently change afterwards.
  • The VAT matrix. Finland B2C, EU B2C, EU B2B with a valid VAT number (reverse charge), and non-EU. Four different treatments, four different invoice snapshots.
  • The mandate timing race. This one cost me an evening. After a card-update verification payment turns paid, customerMandates.page can still report the new mandate as pending for a second or two before it flips to valid. If your code requires valid and the mandate is pending, it throws. That's correct — let it throw, let the webhook return 500, and let Mollie redeliver on its retry schedule. The mandate flips to valid within a second or two of the payment, so the redelivery finds it ready. Do not silently proceed without a valid mandate; do not swallow the error. Fail loud, idempotent on replay.

Stripe vs Mollie at a Glance

Capability Stripe Mollie
Price catalogue Product / Price IDs None — amount inline on every charge
Subscription First-class object Customer + Mandate + first payment, assembled by you
Webhook Typed events with full payload Only an id — you re-read the resource
Proration Built in By hand
Dunning / retries Smart retries over days None — 1 failure cancels the subscription and the mandate
Customer Portal Hosted, free None — you build it
Tax Stripe Tax None — you compute VAT yourself
Invoices Generated by Stripe None — you generate them
Local webhook test Stripe CLI (listen / trigger) None — tunnel (cloudflared / ngrok) or staging

Read that table as a build list. Every "None" in the right column is something I wrote.

Customer Portal: Written From Scratch

On Stripe, billing_portal.sessions.create hands you a hosted page where customers change plans, update cards, cancel, and download invoices. It's free and it's done.

Mollie has nothing here. So I built the whole portal:

  • A plan grid with inline plan switching
  • Upgrade applies immediately with proration; downgrade is scheduled for period end, with a "scheduled change" banner and a "keep my current plan" button to cancel the pending switch
  • Cancellation takes effect at the end of the paid period, not immediately
  • Card update — which, because of how mandates work, means creating a new mandate via a small verification payment, then pointing the subscription at it
  • Order history, invoices, and credit notes

The alternative was a billing add-on (Chargebee et al.) or a Merchant of Record — both of which hand you a portal for free. I chose to build it deliberately, so I wouldn't drag in another platform and so I'd stay the seller of record. That's the trade. It's not free. I'd make it again given what triggered this whole migration.

VAT and Invoices: Also Mine

Stripe Tax handled the tax math and the registration thresholds. Mollie does none of it. So the tax engine is in-house:

  • Prices are stored net; VAT is added on top; the customer is charged gross
  • Reverse charge for EU B2B with a valid VAT number — validated through VIES, the same validation I built for vatnode and wrote up in EU VAT validation in Next.js
  • The invoice is an immutable snapshot frozen at the moment of payment

The treatment logic:

// lib/billing/vat.ts
function determineVat(country: string, vatIdValid: boolean): VatTreatment {
  if (!isEuCountry(country)) {
    return { rateBps: 0, treatment: "export" }; // outside EU — 0%
  }
  if (country === "FI") {
    return { rateBps: 2550, treatment: "domestic" }; // FI 25.5% (since Sep 2024)
  }
  if (vatIdValid) {
    return { rateBps: 0, treatment: "reverse_charge" }; // Art. 196 VAT Directive
  }
  return { rateBps: 2550, treatment: "standard" }; // EU B2C, no valid ID
}
Enter fullscreen mode Exit fullscreen mode

One detail that matters legally and technically: VIES validation is fail-closed. If the VIES service errors or times out, the VAT number is treated as not valid, and VAT is charged. The safe failure for tax is to collect, not to skip. Never reverse-charge on an unverified id because a government API had a bad afternoon.

And the invoice snapshot is genuinely immutable. At payment time I freeze the buyer, the full VAT breakdown, the currency, and a timestamp, and store it on the payment row:

const invoiceSnapshot = {
  buyer: { name, address, country, vatId },
  vat: { netCents, rateBps, vatAmountCents, grossCents, treatment },
  currency,
  capturedAt: new Date().toISOString(),
};
// stored on web_purchases.invoice_snapshot / mollie_payments.invoice_snapshot
Enter fullscreen mode Exit fullscreen mode

The PDF is always rendered from the snapshot, never from the live customer profile. If a customer updates their address next month, last month's invoice does not change. That's not a nicety — it's a requirement. An invoice is a legal record of a moment, and it has to stay that moment.

The grab here: with no pre-charge webhook, I can't recompute VAT "the day before" a renewal. A customer who gains or loses a valid VAT number mid-subscription is reconciled by a periodic job, not at charge time. It works. It's not elegant.

Migrating Existing Subscribers: The Non-Obvious Wall

Here's the one nobody warns you about.

A card mandate does not transfer between payment providers. You cannot export your Stripe mandates and import them into Mollie. Not because of a missing feature — because of PCI and the law. The card authority lives with the provider that captured it. Your active Stripe subscriptions cannot be re-pointed at Mollie automatically, at all.

So there is no clean migration. There's only this:

  1. Let each existing subscriber's paid period run out on Stripe
  2. Turn off auto-renewal on the old provider so nobody gets double-charged
  3. Email each customer personally — explain the move, send them to the new Mollie checkout to re-subscribe, and offer something for the inconvenience (I gave a bonus)

And the honest part: budget for churn. Re-subscribing is a manual action you're asking the customer to take. Conversion on that is not 100% and it's not close. Some subscribers will simply not get around to it, and you'll lose them in the move. Price that into your decision before you start, because no amount of engineering fixes it.

What I'd Tell Myself Before Starting

The compressed lessons, in the order they matter:

  • Mollie is not "Stripe minus the fees." It is a payment layer without a billing platform attached. Everything Stripe did silently — portal, proration, dunning, tax, invoices, retries — is now your codebase.
  • Budget a month, not a week. Roughly 80% of the effort went into rebuilding what Stripe gave away for free. The actual payments were the first day.
  • The thin webhook is a feature, not a bug. Move to reconcile-by-id and stop trusting payloads. It's safer for idempotency, and once it clicks you won't want to go back.
  • If control and seller-of-record status aren't critical to you, use a Merchant of Record. Paddle or Lemon Squeezy would have saved me these two weeks. I had specific reasons not to. You might not.
  • Never single-thread a critical money flow through one provider. "Disabled in one afternoon, no warning, no appeal" is not a hypothetical. It happened to me. Build so that swapping the payment layer is a project, not an existential event.

That last one is the real takeaway, and it's free for you because it was expensive for me. The day Stripe stopped accepting payments, the product didn't break — but the business did, for two weeks, while I rebuilt the billing layer I'd never realised I was renting.

If you're going through the same thing — a provider that disabled you, or a migration onto something thinner — find me on LinkedIn or drop it in the comments. And if you want to see what all this billing machinery actually runs, it's htpbe.tech — the PDF that started the whole mess.

P.S. — for Stripe, if anyone there ever wants to actually look at this. I'm not writing anonymously and I'm not embellishing. These are the email IDs from the correspondence, so the case is traceable on your side. All but one are automated "we have received your message" acknowledgements; the single human reply said the account was about to be reinstated, and then nothing.

em_puyxkrxxxhnrtlmbi65blgbkmdyau4, em_k5hqyupzmkgjudmj8l9ud1juunadig, em_h27b6vy3lled1ppzrwxzcbwzmyuspu, em_s7ct3p5vjshdjr9ebedztewtauiya1, em_rloxuo4dhwhsbjyregqthrcxrktsry, em_6tbmams7h6jtmqntqi9kogm8ecruz8, em_vuyn3fo9qxpfqhyersnebx3znkmbwr, em_dqykhirzrbxxmyyytsgjxi4fyhwx0r, em_npr5g7ddczmdopy5u6o6yplsvl4krj, em_oght8svykl7mzt8ntcfbhtqyfzicu8


Related posts: Stripe Webhooks: Idempotency, Retries, and Queue Setup · Subscription Billing: The Edge Cases Stripe Docs Skip · Idempotency Keys for API Retries · EU VAT Number Validation in Next.js

Source: dev.to

arrow_back Back to Tutorials