Step-by-step guide to migrate a Next.js SaaS from Stripe to Polar: payments abstraction, webhooks, sandbox, and shadow deploy.
Israel Palma
4 min read
If you have an indie SaaS selling to Europe, you've probably felt the pull to migrate from Stripe to
Polar. The main reason: Polar is a Merchant of Record and takes VAT off your hands.
The technical migration isn't as painful as it sounds. If your Stripe integration is well
modularized, it's ~2 hours of work. This guide explains how we'd do it in an existing Next.js app.
## Before migrating: what does NOT move
- **Active subscriptions**: don't migrate automatically. Polar doesn't import from Stripe. Existing
customers stay on Stripe until they renew or change plans.
- **Payment history**: not transferred. Leave it on Stripe as archive.
- **Saved payment methods**: not either. Polar will ask for the card again.
That's why migration works best with a young SaaS (< 100 subscriptions) or with a gradual switch:
new customers on Polar, old ones stay on Stripe until churn.
## Step 1: install Polar
```bash
bun add @polar-sh/sdk
```
Keep `stripe` for now; you'll remove it at the end.
## Step 2: environment variables
Keep Stripe's and add Polar's:
```
POLAR_ACCESS_TOKEN=polar_oat_...
POLAR_WEBHOOK_SECRET=whsec_...
POLAR_SERVER=production
```
## Step 3: create products in Polar Dashboard
Replicate the products you have in Stripe. Watch out for:
- **Prices**: Polar works in cents like Stripe. But if you have VAT-included prices in Stripe, in
Polar you put the net price and Polar adds VAT to the end customer. Decide if your visible pricing
changes.
- **Recurring intervals**: monthly, yearly. Polar supports them.
- **One-time products**: also supported, like Stripe.
## Step 4: abstract the payments logic
If you already have an abstraction layer like `PaymentService` or `lib/payments/`, you've got this
done. If not, now is the time.
`src/lib/payments/types.ts`:
```ts
export interface PaymentProvider {
createCheckout(params: CheckoutParams): Promise<{ url: string }>;
cancelSubscription(subscriptionId: string): Promise;
verifyWebhook(body: string, signature: string): Promise;
}
```
Implement `StripeProvider` and `PolarProvider`. Switch between them with an env var:
```ts
const provider =
process.env.PAYMENT_PROVIDER === 'polar' ? new PolarProvider() : new StripeProvider();
```
## Step 5: new Polar webhook endpoint
Stripe and Polar sign webhooks differently. Create a new endpoint, don't modify the Stripe one:
`src/app/api/webhooks/polar/route.ts`:
```ts
import { polar } from '@/lib/polar/client';
import { db } from '@/lib/db/client';
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('webhook-signature')!;
const event = polar.webhooks.verify({
body,
signature,
secret: process.env.POLAR_WEBHOOK_SECRET!,
});
// Reuse the logic from your Stripe webhook, adapted to Polar events
switch (event.type) {
case 'subscription.created':
// create in DB
break;
case 'subscription.canceled':
// mark as canceled
break;
}
return new Response('OK');
}
```
## Step 6: cohort strategy
In your DB, add a `paymentProvider` field to the subscriptions table:
```prisma
model Subscription {
id String @id
userId String
paymentProvider String // 'stripe' | 'polar'
externalId String // ID in Stripe or Polar
status String
...
}
```
When a user cancels and re-subscribes, their new subscription goes to Polar. Old ones stay on Stripe
until natural churn.
## Step 7: test in sandbox
Polar has a separate sandbox. Before touching production:
1. Create a sandbox account + products in sandbox
2. Set `POLAR_SERVER=sandbox`
3. Make a test purchase with card `4242 4242 4242 4242`
4. Verify the webhook arrives and DB updates
5. Verify cancellation
When everything works in sandbox → switch to `production`.
## Step 8: shadow deploy
Don't announce the migration on day 1. Deploy Polar to production with:
- "Pay with Polar" button hidden behind a feature flag
- Enable the flag for your test account
- Make a real purchase
- Verify Polar transfers the money to your bank account
- Enable for all new signups
## Common errors
**1. Webhook deduplication**: if you replay the same event, make sure your DB doesn't create
duplicate subscriptions. Index by `externalId`.
**2. VAT in visible pricing**: if Stripe showed "10€ VAT included", in Polar the customer will see
"10€ + VAT" because Polar calculates it. Either drop the net price or raise the visible one.
Communicate it.
**3. Refunds and disputes**: the logic is different. Read Polar's docs before processing your first
refund.
## Bottom line
Migrating Stripe → Polar is ~2 hours of code if you have the abstraction in place, or ~1 day from
scratch. The longest part is testing in sandbox and shadow-deploying.
In return: you forget about VAT, about OSS filings, about the accountant arguing what kind of
service a SaaS is. If you sell to Europe, it's worth it.
Enjoyed this article?
Subscribe for more tutorials and tips on building products with AI