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_subscriptionsandbilling_purchases, andgetEntitlementdoes not care which provider wrote a row. Every row has aprovidercolumn (stripeorpolar). - 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
trialDaysin 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 oldprovider.ts, exportingstripeProvider), plusstripe.ts,stripe-objects.ts, thestripedependency andSTRIPE_*env vars. - Keep
src/app/api/webhooks/stripe/route.tscallingprocessWebhook(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.