Skip to content

Moving from Stripe to Polar without breaking existing customers

You cannot transfer live Stripe subscriptions to Polar. Sell new customers on Polar, keep Stripe's webhook alive for the ones you have, move them at renewal, and let the provider-neutral billing rows carry access through the whole overlap.

Polar4 min readships at docs/solutions/polar/migrate-a-stripe-catalogue-to-polar.md

Tags: polar · stripe · migration · subscriptions · lifetime-deal · billing

The question is always the same: "we are on Stripe, we want Polar to handle the tax, how do we move the subscriptions across?"

You do not. A subscription is a payment mandate between a customer and a merchant, and Polar is a different merchant. Every real migration is a re-subscribe, and the work is making that painless. Plan for an overlap of at least one full billing cycle where both providers are live.

What already works in your favour

Billing here keeps provider data out of your features:

  • Plans live in src/lib/pricing.ts, not in either provider.
  • Access is read from billing_subscriptions and billing_purchases, and getEntitlement does not care which provider wrote a row. Every row has a provider column (stripe or polar).
  • Feature gates call hasPlan(userId, "pro"). Nothing to rewrite.

So a Stripe customer keeps access after you switch, as long as something keeps their Stripe rows current. That is the whole trick.

Step 1: create the Polar products

Switch the payments battery to Polar and regenerate (or move the adapter files by hand), then:

bun run billing:sync-plans

It creates one Polar product per catalogue price and prints the BILLING_PRICE_* lines. Those env vars now hold Polar product ids. Keep your catalogue ids (pro-monthly, pro-lifetime) exactly as they were: they are stored on every existing row.

What does not map one to one:

  • One price per product. Stripe's product with monthly and yearly prices becomes two Polar products.
  • Coupons become Polar discounts, created again.
  • Trials come from trialDays in the catalogue on both providers, so nothing to move.

Step 2: keep Stripe's webhook alive

New checkouts now go to Polar. Existing Stripe subscribers still renew, cancel and get refunds on Stripe, and those changes must still reach their rows, or a cancelled Stripe customer stays entitled forever.

processWebhook(provider, request) takes the adapter as an argument, and the idempotency claim is keyed per provider. So during the overlap keep a second route:

  • Keep Stripe's adapter as src/lib/billing/stripe-provider.ts (the old provider.ts, exporting stripeProvider), plus stripe.ts, stripe-objects.ts, the stripe dependency and STRIPE_* env vars.
  • Keep src/app/api/webhooks/stripe/route.ts calling processWebhook(stripeProvider, request).

The Stripe adapter still names plans from Stripe's lookup keys (the catalogue ids billing:sync-plans set on each Stripe price), so its rows keep their plan even though BILLING_PRICE_* now points at Polar.

Step 3: move subscribers at renewal

The shared checkout refuses a new subscription while an entitled one exists, from either provider. That guard stops double billing, and it shapes the move:

Let them run out (recommended). Set every Stripe subscription to cancel at period end, and email each customer before that date with a link to /pricing. When the period ends, Stripe's webhook marks the row cancelled, the guard lifts, and the customer subscribes on Polar. No double charge, no refunds. It takes one full cycle, twelve months on yearly plans, and some customers will not come back.

Move them now. Cancel the Stripe subscription immediately with a prorated refund, then send the Polar checkout link. Faster, but the customer is briefly without a plan, and the refund must be prompt.

The Manage billing button now opens Polar's portal, which does not know Stripe subscriptions. Cancel those for the customer in Stripe (dashboard or API) instead of sending them to a portal.

Either way, track it: select provider, count(*) from billing_subscriptions where status in ('trialing','active','past_due') group by provider tells you how far through you are.

Lifetime deals carry over as they are

A Stripe lifetime purchase is a billing_purchases row with status paid. It keeps granting the plan after the switch, with nothing to migrate. Keep Stripe's webhook until its refund window has passed, so a refund or dispute still revokes it.

Step 4: delete Stripe in one commit

When the last Stripe subscription has ended and the refund window for the last Stripe purchase has closed, delete the Stripe adapter, its route, its env vars and the dependency together. Keep the rows: they are your purchase history. Keep read access to the Stripe dashboard for as long as your records must be kept.

Tell customers two things

  • The name on the charge changes. Their statement and invoice say Polar, because Polar is now the seller. Say so in the email, or support fills with fraud reports.
  • Invoices come from Polar now. Business customers reclaiming VAT need the new ones; the old Stripe invoices stay in Stripe.