Stripe to Mollie Migration: What Actually Breaks
Stripe to Mollie Migration: What Actually Breaks
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()!);
Two things to internalise here, because they recur everywhere in Mollie:
- The amount is a string with exactly two decimals.
"5.00", not5or5.0. - The currency is explicit and inline. There is no price object to reference.
- 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",
},
});
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));
}
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);
}
}
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));
}
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 sub
Comments
No comments yet. Start the discussion.