| name | polar-migration |
| description | Guide for migrating to Polar from other payment platforms (Stripe Billing, Paddle, Lemon Squeezy, Gumroad). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Migrating customer data and subscriptions; (3) Running parallel systems during transition; (4) Mapping products and pricing between platforms; (5) Handling payment method migration; (6) Communicating migration to customers; (7) Testing migration in sandbox before production.
|
Polar Migration Guide
Migrate to Polar from other payment platforms while minimizing disruption to your customers and revenue.
Migration Overview
What Can Be Migrated
| Data Type | Migration Method |
|---|
| Customer emails/names | API import |
| Product catalog | Manual recreation |
| Active subscriptions | New subscription creation |
| Payment methods | Customer re-entry required* |
| Historical orders | Not migrated (keep in old system) |
| License keys | New keys generated |
*Payment method tokens cannot be transferred between processors due to PCI compliance. Customers will need to re-enter payment details.
Migration Strategies
1. Hard Cutover
- Cancel all subscriptions in old system
- Create new subscriptions in Polar
- Best for: Small customer base, simple products
2. Gradual Migration
- Run both systems in parallel
- Migrate customers as they renew
- Best for: Large customer base, minimizing risk
3. New Customers Only
- Keep existing customers on old system
- New customers use Polar
- Best for: Testing Polar before full commitment
Pre-Migration Checklist
1. Set Up Polar
npm install @polar-sh/sdk
2. Map Your Products
Create equivalent products in Polar:
import { Polar } from "@polar-sh/sdk";
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: "sandbox",
});
const basicPlan = await polar.products.create({
name: "Basic Plan",
organizationId: "org_xxx",
description: "Basic features",
prices: [
{
type: "recurring",
recurringInterval: "month",
amountType: "fixed",
priceAmount: 900,
priceCurrency: "usd",
},
{
type: "recurring",
recurringInterval: "year",
amountType: "fixed",
priceAmount: 9000,
priceCurrency: "usd",
},
],
});
3. Map Benefits/Entitlements
const licenseBenefit = await polar.benefits.create({
type: "license_keys",
description: "Software License",
organizationId: "org_xxx",
properties: {
prefix: "PRO",
activations: { limit: 3 },
},
});
4. Set Up Webhooks
import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks";
export async function POST(request: Request): Promise<Response> {
const body = await request.text();
let event: ReturnType<typeof validateEvent>;
try {
event = validateEvent(
body,
{
"webhook-id": request.headers.get("webhook-id") ?? "",
"webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
"webhook-signature": request.headers.get("webhook-signature") ?? "",
},
process.env.POLAR_WEBHOOK_SECRET!,
);
} catch (error) {
if (error instanceof WebhookVerificationError) {
return Response.json({ received: false }, { status: 403 });
}
throw error;
}
switch (event.type) {
case "order.paid":
await grantAccess(event.data.customer.externalId, event.data.productId);
break;
case "subscription.canceled":
await scheduleAccessRemoval(event.data.customer.externalId, event.data.endsAt);
break;
}
return Response.json({ received: true });
}
See the polar-integration skill for the full webhook recipe (event types, framework variations, raw-body requirement, idempotency).
Migration from Stripe Billing
Export Customer Data
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function exportStripeCustomers() {
const customers: ExportedCustomer[] = [];
for await (const subscription of stripe.subscriptions.list({ status: "active" })) {
const customer = await stripe.customers.retrieve(subscription.customer as string);
if (customer.deleted) continue;
customers.push({
stripeCustomerId: customer.id,
email: customer.email!,
name: customer.name || undefined,
subscriptionId: subscription.id,
productId: subscription.items.data[0].price.product as string,
priceId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
}
return customers;
}
Create Customers in Polar
const productMap: Record<string, string> = {
"prod_stripe_basic": "prod_polar_basic",
"prod_stripe_pro": "prod_polar_pro",
};
async function createPolarCustomers(customers: ExportedCustomer[]) {
for (const customer of customers) {
const polarCustomer = await polar.customers.create({
organizationId: "org_xxx",
email: customer.email,
name: customer.name,
externalId: customer.stripeCustomerId,
});
console.log(`Created Polar customer: ${polarCustomer.id} for ${customer.email}`);
}
}
Migrate Subscriptions (Gradual)
async function migrateSubscription(customer: ExportedCustomer) {
const polarProductId = productMap[customer.productId];
const checkout = await polar.checkouts.create({
products: [polarProductId],
customerEmail: customer.email,
externalCustomerId: customer.stripeCustomerId,
successUrl: `https://yoursite.com/migration-complete?customer=${customer.stripeCustomerId}`,
discountId: "migration_discount_xxx",
});
await sendMigrationEmail(customer.email, {
checkoutUrl: checkout.url,
currentPeriodEnd: customer.currentPeriodEnd,
});
return checkout;
}
Handle Migration Completion
case "order.paid": {
const order = event.data;
const stripeCustomerId = order.customer.externalId;
if (stripeCustomerId?.startsWith("cus_")) {
const stripeSubscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
status: "active",
});
for (const sub of stripeSubscriptions.data) {
await stripe.subscriptions.update(sub.id, {
cancel_at_period_end: true,
});
}
await db.customer.update({
where: { stripeId: stripeCustomerId },
data: {
migratedToPolar: true,
polarCustomerId: order.customer.id,
},
});
}
break;
}
Migration from Paddle
Export Paddle Data
async function exportPaddleSubscribers() {
const response = await fetch("https://vendors.paddle.com/api/2.0/subscription/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
vendor_id: process.env.PADDLE_VENDOR_ID,
vendor_auth_code: process.env.PADDLE_AUTH_CODE,
state: "active",
}),
});
const data = await response.json();
return data.response;
}
Map Paddle to Polar
const paddleToPolar = {
products: {
"paddle_12345": "polar_basic",
"paddle_12346": "polar_pro",
},
};
async function migratePaddleCustomer(paddleUser: PaddleSubscriber) {
const checkout = await polar.checkouts.create({
products: [paddleToPolar.products[paddleUser.plan_id]],
customerEmail: paddleUser.user_email,
externalCustomerId: `paddle_${paddleUser.user_id}`,
successUrl: "https://yoursite.com/migrated",
});
return checkout;
}
Migration from Lemon Squeezy
async function exportLemonSqueezyCustomers() {
const response = await fetch("https://api.lemonsqueezy.com/v1/subscriptions", {
headers: {
Authorization: `Bearer ${process.env.LEMON_SQUEEZY_API_KEY}`,
},
});
const { data } = await response.json();
return data.filter((sub: any) => sub.attributes.status === "active");
}
Customer Communication
Migration Email Template
const migrationEmailTemplate = {
subject: "Action Required: Update Your Payment Method",
body: `
Hi {{customer_name}},
We're upgrading our payment system to provide you with a better experience.
**What you need to do:**
Click the link below to update your payment method. This will only take a minute.
[Update Payment Method]({{checkout_url}})
**What's changing:**
- More payment options
- Better invoice management
- Improved customer portal
**What's NOT changing:**
- Your subscription price
- Your features and access
- Your billing date
**Timeline:**
Please complete this by {{deadline}} to ensure uninterrupted service.
Your current subscription will continue until {{current_period_end}}, then
seamlessly transition to our new system.
Questions? Reply to this email.
Thanks,
{{company_name}}
`,
};
async function sendMigrationEmail(email: string, data: MigrationData) {
await emailService.send({
to: email,
subject: migrationEmailTemplate.subject,
body: renderTemplate(migrationEmailTemplate.body, {
customer_name: data.name || "there",
checkout_url: data.checkoutUrl,
deadline: formatDate(data.deadline),
current_period_end: formatDate(data.currentPeriodEnd),
company_name: "Your Company",
}),
});
}
Reminder Sequence
const reminderSchedule = [
{ daysBeforeDeadline: 14, template: "initial" },
{ daysBeforeDeadline: 7, template: "reminder" },
{ daysBeforeDeadline: 3, template: "urgent" },
{ daysBeforeDeadline: 1, template: "final" },
];
async function scheduleReminders(customer: Customer, deadline: Date) {
for (const reminder of reminderSchedule) {
const sendAt = subDays(deadline, reminder.daysBeforeDeadline);
await scheduleEmail({
to: customer.email,
template: reminder.template,
sendAt,
data: { checkoutUrl: customer.migrationCheckoutUrl },
});
}
}
Parallel Running
Router Pattern
async function getPaymentSystem(userId: string): Promise<"stripe" | "polar"> {
const user = await db.user.findUnique({ where: { id: userId } });
if (user?.polarCustomerId) {
return "polar";
}
return "stripe";
}
async function hasActiveSubscription(userId: string): Promise<boolean> {
const system = await getPaymentSystem(userId);
if (system === "polar") {
const state = await polar.customers.getState({
customerId: user.polarCustomerId,
});
return state.activeSubscriptions.length > 0;
} else {
const subscriptions = await stripe.subscriptions.list({
customer: user.stripeCustomerId,
status: "active",
});
return subscriptions.data.length > 0;
}
}
Unified Webhook Handler
app.post("/webhooks/stripe", stripeWebhookHandler);
app.post("/webhooks/polar", polarWebhookHandler);
interface SubscriptionEvent {
type: "created" | "canceled" | "renewed";
customerId: string;
productId: string;
source: "stripe" | "polar";
}
function normalizeStripeEvent(event: Stripe.Event): SubscriptionEvent | null {
}
function normalizePolarEvent(event: PolarWebhookEvent): SubscriptionEvent | null {
}
Post-Migration Cleanup
Verify Migration
async function verifyMigration() {
const migratedCustomers = await db.customer.findMany({
where: { migratedToPolar: true },
});
const issues: string[] = [];
for (const customer of migratedCustomers) {
const polarState = await polar.customers.getState({
customerId: customer.polarCustomerId,
});
if (polarState.activeSubscriptions.length === 0) {
issues.push(`${customer.email}: No active Polar subscription`);
}
const stripeSubscriptions = await stripe.subscriptions.list({
customer: customer.stripeCustomerId,
status: "active",
});
if (stripeSubscriptions.data.length > 0) {
issues.push(`${customer.email}: Stripe subscription still active`);
}
}
return issues;
}
Cancel Old System
async function cancelRemainingStripeSubscriptions() {
for await (const subscription of stripe.subscriptions.list({ status: "active" })) {
const customer = await db.customer.findFirst({
where: { stripeId: subscription.customer as string },
});
if (customer?.migratedToPolar) {
await stripe.subscriptions.cancel(subscription.id);
console.log(`Canceled Stripe subscription ${subscription.id}`);
}
}
}
Migration Checklist
Before Migration
During Migration
After Migration
Troubleshooting
Customer didn't receive migration email
- Check spam folder
- Verify email address in export
- Resend with different subject line
Customer completed checkout but Stripe not canceled
- Check webhook logs
- Verify external_id mapping
- Manually cancel if needed
Double billing
- Refund on old platform
- Apologize to customer
- Improve timing logic
Customer wants to stay on old platform
- Respect their choice (if possible)
- Offer incentive to migrate
- Set final deadline