Stripe to Mollie Migration: What Actually Breaks A developer was abruptly deplatformed by Stripe after a risk model flagged their PDF-forensics tool as potentially fraudulent, despite providing extensive documentation of its legitimacy. The developer then migrated to Mollie, discovering that Stripe's true lock-in is not payment processing but its billing platform—subscriptions, invoicing, tax handling, and customer portal—all of which had to be rebuilt. The migration revealed that leaving Stripe means losing an entire billing infrastructure, not just a payment API. 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 https://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. If you're considering the same move, here's the whole article compressed: id .My criteria after the Stripe experience were narrow: 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 https://iurii.rogulia.fi/blog/subscription-billing-edge-cases — except there, Stripe was quietly handling the 70% I'm now responsible for. 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: js // 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: "5.00" , not 5 or 5.0 . The currency is explicit and inline. There is no price object to reference. 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. 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: js 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. 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 ; } 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. 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 } 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. 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 https://iurii.rogulia.fi/blog/stripe-webhooks-production . 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. } 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. If you're building any of this, the idempotency keys for API retries https://iurii.rogulia.fi/blog/idempotency-keys-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." / 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: id twice. Credits must not double. Activations must not double. If your onConflictDoNothing is wrong, you find out here. paid , customerMandates.page can 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 | 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. 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: 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. Stripe Tax handled the tax math and the registration thresholds. Mollie does none of it. So the tax engine is in-house: 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 } 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: js 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 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. 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: 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. The compressed lessons, in the order they matter: 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 https://www.linkedin.com/in/iuriirogulia/ or drop it in the comments. And if you want to see what all this billing machinery actually runs, it's htpbe.tech https://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 https://iurii.rogulia.fi/blog/stripe-webhooks-production · Subscription Billing: The Edge Cases Stripe Docs Skip https://iurii.rogulia.fi/blog/subscription-billing-edge-cases · Idempotency Keys for API Retries https://iurii.rogulia.fi/blog/idempotency-keys-api-retries · EU VAT Number Validation in Next.js https://iurii.rogulia.fi/blog/eu-vat-validation-nextjs