Update to Polar
Snapshot Nov 3, 2026 · 19:24 UTC · version 0.2.2
Collection source: downloaded plugin package.
Payment or plan references changed
Full description — Now says: Squeezy). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Moving customers, subscriptions and saved cards with...
Observed in published text. Live prices and checkout terms have not been verified by this change.
Up to 10 plugins free · no card required.
All fields and original values.
Full description
Nov 3, 2026 · 00:14 UTC
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 betw...
Nov 3, 2026 · 19:24 UTC
Squeezy). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Moving customers, subscriptions and saved cards with Polar's Stripe migration; (3) Running both billing systems during the transition; (4...
Skill instructions
Nov 3, 2026 · 00:14 UTC
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 betw...
Nov 3, 2026 · 19:24 UTC
Squeezy). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Moving customers, subscriptions and saved cards with Polar's Stripe migration; (3) Running both billing systems during the transition; (4...
Compare saved observations
Download comparison JSONSKILL.md text changes
− removed · + added
--- before +++ after @@ -1,575 +1,127 @@ --- 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. + Guide for migrating to Polar from other payment platforms (Stripe Billing, Paddle, Lemon Squeezy). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Moving customers, subscriptions and saved cards with Polar's Stripe migration; (3) Running both billing systems during the transition; (4) Mapping products and pricing between platforms; (5) Updating webhook and access handling for migrated subscriptions; (6) Communicating the migration to customers; (7) Testing the migration in sandbox first. --- # Polar Migration Guide -Migrate to Polar from other payment platforms while minimizing disruption to your customers and revenue. +[Polar's migration guide](https://polar.sh/docs/migrate) is the source of truth; read it before recommending anything. This skill summarizes what the application has to do. -## Migration Overview +## Pick the path -### What Can Be Migrated +- **From Stripe:** use Polar's assisted migration under **Settings → Migrations**. Polar imports products, prices and customers, Stripe copies saved cards straight to Polar's payment processor, and Polar switches each subscription over with the same paid period. Most customers don't need to do anything. Migrations are rolling out gradually; if the tab is missing, contact [Polar support](https://polar.sh/docs/support). +- **From Paddle, Lemon Squeezy or anything else:** there's no automated migration. Contact Polar support first, then follow "Other providers" below. Customers move by checking out again on Polar. -| 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 | +## Rules for every migration -*Payment method tokens cannot be transferred between processors due to PCI compliance. Customers will need to re-enter payment details. +- Never stop, cancel or pause the old provider's billing, or activate a migrated Polar subscription, outside Polar's switch. Don't write your own cutover scripts: they double-bill. +- Never handle raw card data, and never ask for Stripe or Polar secrets in chat. +- Use the application's immutable customer ID as the Polar customer `external_id` (passed as `external_customer_id` at checkout). Not email, and not the old provider's ID unless that's already the app's canonical ID. An `external_id` can't be changed once set. +- Each subscription must have exactly one system responsible for its next renewal. Record which one, rather than inferring it from which IDs are set. +- Past payments stay in the old provider. Don't create Polar orders for transactions Polar didn't process, and keep the old account for refunds, disputes and accounting. -### Migration Strategies +## From Stripe -**1. Hard Cutover** -- Cancel all subscriptions in old system -- Create new subscriptions in Polar -- Best for: Small customer base, simple products +The dashboard handles connecting Stripe, importing the catalog, copying cards and switching subscriptions. The application handles the first and last stages and the events in between. -**2. Gradual Migration** -- Run both systems in parallel -- Migrate customers as they renew -- Best for: Large customer base, minimizing risk +### 1. Move new sales to Polar -**3. New Customers Only** -- Keep existing customers on old system -- New customers use Polar -- Best for: Testing Polar before full commitment +Integrate Polar checkout and webhooks first (`polar-integration`), passing `external_customer_id` on every checkout. New customers then subscribe on Polar while existing subscriptions keep renewing on Stripe. Keep the Stripe IDs in your database until nothing renews there. -## Pre-Migration Checklist +During coexistence, access checks must accept a valid entitlement from either provider, and cancellations or plan changes go to whichever system bills that subscription. -### 1. Set Up Polar +### 2. Map imported customers -```bash -# Test in sandbox first -# https://sandbox.polar.sh +The import creates or reuses Polar customers, but Polar can't know your application's customer IDs. Match each imported customer once on an exact, unique email, then set your ID on customers whose `external_id` is still empty. Send missing, duplicate or changed emails for review instead of guessing. -# Install SDK -npm install @polar-sh/sdk -``` - -### 2. Map Your Products - -Create equivalent products in Polar: - -```typescript -import { Polar } from "@polar-sh/sdk"; - -const polar = new Polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN, - server: "sandbox", // Test first! -}); - -// Create products matching your old catalog -const basicPlan = await polar.products.create({ - name: "Basic Plan", - organizationId: "org_xxx", - description: "Basic features", - prices: [ - { - type: "recurring", - recurringInterval: "month", - amountType: "fixed", - priceAmount: 900, // $9.00 - priceCurrency: "usd", - }, - { - type: "recurring", - recurringInterval: "year", - amountType: "fixed", - priceAmount: 9000, // $90.00 - priceCurrency: "usd", - }, - ], -}); -``` - -### 3. Map Benefits/Entitlements - -```typescript -// Create benefits in Polar -const licenseBenefit = await polar.benefits.create({ - type: "license_keys", - description: "Software License", - organizationId: "org_xxx", - properties: { - prefix: "PRO", - activations: { limit: 3 }, - }, -}); +```ts +import { polar } from "./polar"; // client from polar-integration -// Attach the benefit to the product in the Polar dashboard -// (Organization → Products → [product] → Benefits). -``` - -### 4. Set Up Webhooks - -```typescript -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": - // Grant access in your system - await grantAccess(event.data.customer.externalId, event.data.productId); - break; - - case "subscription.canceled": - // Schedule access removal - await scheduleAccessRemoval(event.data.customer.externalId, event.data.endsAt); - break; - } - - return Response.json({ received: true }); +const customer = await polar.customers.get(polarCustomerId); +if (customer.external_id === null) { + await polar.customers.update(polarCustomerId, { external_id: appCustomerId }); } ``` -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 - -```typescript -import Stripe from "stripe"; +Imported products are new Polar products. Map each internal plan to its new Polar product and price IDs explicitly, never by name. Benefits aren't imported: attach them to the imported products before the switch if access depends on them. -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); +### 3. Prepare for the switch -// Export customers with active subscriptions -async function exportStripeCustomers() { - const customers: ExportedCustomer[] = []; +Polar stops each switched subscription on Stripe by canceling it immediately, without proration or a final invoice. Before switching: - for await (const subscription of stripe.subscriptions.list({ status: "active" })) { - const customer = await stripe.customers.retrieve(subscription.customer as string); +- Turn off Stripe's canceled-subscription email (Settings → Billing → Subscriptions and emails) and pause any Stripe automation triggered by a cancellation. +- Make the Stripe `customer.subscription.deleted` handler ignore switched subscriptions, so it doesn't revoke access or send a cancellation email: - 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, - }); + ```ts + if (subscription.cancellation_details?.comment?.startsWith("Migrated to Polar")) { + return; // billing moved to Polar; reconcile through subscription.migrated instead } + ``` - return customers; -} -``` - -### Create Customers in Polar +- Handle Polar's `subscription.migrated` event as the cutover signal. It carries `provider` and `provider_subscription_id`. Treat it as an idempotent upsert: store the Polar subscription ID against the Stripe subscription and record Polar as the system that bills next. Don't grant or revoke access from it, and don't send a welcome email: -```typescript -// Map Stripe products to Polar products -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) { - // Create customer in Polar with external ID for linking - const polarCustomer = await polar.customers.create({ - organizationId: "org_xxx", - email: customer.email, - name: customer.name, - externalId: customer.stripeCustomerId, // Link to Stripe ID + ```ts + case "subscription.migrated": + await recordMigratedSubscription({ + stripeSubscriptionId: event.provider_subscription_id, + polarSubscriptionId: event.data.id, }); + break; + ``` - console.log(`Created Polar customer: ${polarCustomer.id} for ${customer.email}`); - } -} -``` +Expect `subscription.updated`, `subscription.migrated` and `customer.state_changed` per switched subscription, in any order. Migration sends no `subscription.created` or `subscription.active`, and the import sends no `product.created` or `customer.created`. -### Migrate Subscriptions (Gradual) +### 4. Switch, reconcile and clean up -```typescript -async function migrateSubscription(customer: ExportedCustomer) { - const polarProductId = productMap[customer.productId]; - - // Create checkout for customer to enter new payment method - const checkout = await polar.checkouts.create({ - products: [polarProductId], - customerEmail: customer.email, - externalCustomerId: customer.stripeCustomerId, - successUrl: `https://yoursite.com/migration-complete?customer=${customer.stripeCustomerId}`, - // Allow discount for migration - discountId: "migration_discount_xxx", - }); - - // Send migration email to customer - await sendMigrationEmail(customer.email, { - checkoutUrl: checkout.url, - currentPeriodEnd: customer.currentPeriodEnd, - }); +Run the switch from the dashboard. It can't be undone, and subscriptions renewing within 24 hours stay on Stripe until the next attempt. Afterwards, check that every moved subscription bills only in Polar and maps to one customer and entitlement. Retire Stripe checkout and subscription code only once no subscriptions renew there. - return checkout; -} -``` +Limits to know up front: Stripe Connect platform accounts and Stripe accounts in India can't use the automated flow. Subscriptions sold through Stripe Managed Payments, and those with multiple items, a quantity above 1, or a past-due, unpaid or paused status, need attention or stay on Stripe. Only cards are copied, real card copies need live Stripe accounts, and the Stripe key mode must match the Polar environment. -### Handle Migration Completion +For a full assessment, Polar's guide includes a [coding assistant prompt](https://polar.sh/docs/migrate#coding-assistant-prompt) that produces a migration plan and reconciliation contract before any code changes. -```typescript -// Inside the validateEvent switch in your webhook handler: -case "order.paid": { - const order = event.data; - const stripeCustomerId = order.customer.externalId; - - if (stripeCustomerId?.startsWith("cus_")) { - // Cancel Stripe subscription at period end - const stripeSubscriptions = await stripe.subscriptions.list({ - customer: stripeCustomerId, - status: "active", - }); +## Other providers - for (const sub of stripeSubscriptions.data) { - await stripe.subscriptions.update(sub.id, { - cancel_at_period_end: true, - }); - } - - // Mark customer as migrated in your database - await db.customer.update({ - where: { stripeId: stripeCustomerId }, - data: { - migratedToPolar: true, - polarCustomerId: order.customer.id, - }, - }); - } - break; -} -``` +### Recreate the catalog -## Migration from Paddle +The billing interval belongs to the product, and a product has one fixed price per currency. Monthly and yearly billing are two products: -### Export Paddle Data - -```typescript -// Paddle API to export subscribers -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 - -```typescript -const paddleToPolar = { - products: { - "paddle_12345": "polar_basic", - "paddle_12346": "polar_pro", - }, -}; - -async function migratePaddleCustomer(paddleUser: PaddleSubscriber) { - // Create checkout with Polar - 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 - -```typescript -// Lemon Squeezy API export -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 - -```typescript -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) { - // Use your email service (SendGrid, Postmark, etc.) - 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 - -```typescript -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 - -```typescript -// Determine which system handles a customer -async function getPaymentSystem(userId: string): Promise<"stripe" | "polar"> { - const user = await db.user.findUnique({ where: { id: userId } }); - - if (user?.polarCustomerId) { - return "polar"; - } - return "stripe"; -} - -// Route subscription checks -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; - } -} +```ts +await polar.products.create({ + name: "Pro (Monthly)", + recurring_interval: "month", + prices: [{ amount_type: "fixed", price_amount: 2900, price_currency: "usd" }], +}); +await polar.products.create({ + name: "Pro (Yearly)", + recurring_interval: "year", + prices: [{ amount_type: "fixed", price_amount: 29000, price_currency: "usd" }], +}); ``` -### Unified Webhook Handler +Recreate benefits (license keys, file downloads, Discord roles and others) and attach them with `polar.products.updateBenefits(productId, { benefits: [benefitId] })`. -```typescript -// Handle webhooks from both systems during migration -app.post("/webhooks/stripe", stripeWebhookHandler); -app.post("/webhooks/polar", polarWebhookHandler); - -// Normalize events to your internal format -interface SubscriptionEvent { - type: "created" | "canceled" | "renewed"; - customerId: string; - productId: string; - source: "stripe" | "polar"; -} +### Move customers -function normalizeStripeEvent(event: Stripe.Event): SubscriptionEvent | null { - // Convert Stripe event to internal format -} +Create customers ahead of time if you want them in Polar before they check out: -function normalizePolarEvent(event: PolarWebhookEvent): SubscriptionEvent | null { - // Convert Polar event to internal format -} +```ts +await polar.customers.create({ external_id: appCustomerId, email, name }); ``` -## Post-Migration Cleanup +Paid subscriptions can't be created through the API, and saved cards can't move between processors outside Stripe's copy. Each customer checks out on Polar, where they enter their card. Send them a checkout link created with `external_customer_id`. -### Verify Migration +To avoid charging twice for the same period, schedule the old subscription to cancel at the end of its period, and give the Polar checkout a trial that covers the days left, using `trial_interval: "day"` and `trial_interval_count`. Check this approach with Polar support for your case. -```typescript -async function verifyMigration() { - // Get all customers marked as migrated - const migratedCustomers = await db.customer.findMany({ - where: { migratedToPolar: true }, - }); - - const issues: string[] = []; - - for (const customer of migratedCustomers) { - // Verify Polar subscription exists - const polarState = await polar.customers.getState({ - customerId: customer.polarCustomerId, - }); +### License keys - if (polarState.activeSubscriptions.length === 0) { - issues.push(`${customer.email}: No active Polar subscription`); - } - - // Verify Stripe subscription is canceled - const stripeSubscriptions = await stripe.subscriptions.list({ - customer: customer.stripeCustomerId, - status: "active", - }); +License keys can't be imported. Keep validating existing keys against the old provider during the transition; new purchases get Polar keys. - if (stripeSubscriptions.data.length > 0) { - issues.push(`${customer.email}: Stripe subscription still active`); - } - } - - return issues; -} -``` - -### Cancel Old System - -```typescript -// After all customers migrated, cancel remaining Stripe subscriptions -async function cancelRemainingStripeSubscriptions() { - for await (const subscription of stripe.subscriptions.list({ status: "active" })) { - // Check if customer was migrated - 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}`); - } - } -} -``` +## Communicating with customers -## Migration Checklist +- **Stripe migration:** no action is needed from customers. Polar adds a notice about the move to its first billing emails, such as the first renewal receipt. If you want customers to hear about it from you first, send a short heads-up before switching. +- **Other providers:** customers need to check out again. Tell them plainly that they'll enter their card on Polar, when they'll be charged, and that the old subscription ends at the end of its paid period. -### Before Migration +## Testing -- [ ] Products created in Polar (sandbox) -- [ ] Benefits/entitlements mapped -- [ ] Webhooks configured -- [ ] Email templates ready -- [ ] Customer support informed -- [ ] Rollback plan documented - -### During Migration - -- [ ] Test with small batch first -- [ ] Monitor error rates -- [ ] Track conversion rate -- [ ] Respond to customer issues quickly -- [ ] Send reminder emails on schedule - -### After Migration - -- [ ] Verify all subscriptions migrated -- [ ] Cancel old platform subscriptions -- [ ] Update documentation -- [ ] Remove old platform code -- [ ] Archive old platform data -- [ ] Celebrate! 🎉 - -## 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 +Run the migration in Polar's sandbox with a Stripe test-mode account first. Sandbox verifies the application side (customer mapping, `subscription.migrated` handling, access checks), but can't copy real cards. See `polar-testing` for sandbox setup and local webhook testing.
Full technical diff · 2 changed fields
changed /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.\n"
"Guide for migrating to Polar from other payment platforms (Stripe Billing, Paddle, Lemon Squeezy). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Moving customers, subscriptions and saved cards with Polar's Stripe migration; (3) Running both billing systems during the transition; (4) Mapping products and pricing between platforms; (5) Updating webhook and access handling for migrated subscriptions; (6) Communicating the migration to customers; (7) Testing the migration in sandbox first.\n"
changed /skill_md_contents
"---\nname: polar-migration\ndescription: |\n 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.\n---\n\n# Polar Migration Guide\n\nMigrate to Polar from other payment platforms while minimizing disruption to your customers and revenue.\n\n## Migration Overview\n\n### What Can Be Migrated\n\n| Data Type | Migration Method |\n|-----------|-----------------|\n| Customer emails/names | API import |\n| Product catalog | Manual recreation |\n| Active subscriptions | New subscription creation |\n| Payment methods | Customer re-entry required* |\n| Historical orders | Not migrated (keep in old system) |\n| License keys | New keys generated |\n\n*Payment method tokens cannot be transferred between processors due to PCI compliance. Customers will need to re-enter payment details.\n\n### Migration Strategies\n\n**1. Hard Cutover**\n- Cancel all subscriptions in old system\n- Create new subscriptions in Polar\n- Best for: Small customer base, simple products\n\n**2. Gradual Migration**\n- Run both systems in parallel\n- Migrate customers as they renew\n- Best for: Large customer base, minimizing risk\n\n**3. New Customers Only**\n- Keep existing customers on old system\n- New customers use Polar\n- Best for: Testing Polar before full commitment\n\n## Pre-Migration Checklist\n\n### 1. Set Up Polar\n\n```bash\n# Test in sandbox first\n# https://sandbox.polar.sh\n\n# Install SDK\nnpm install @polar-sh/sdk\n```\n\n### 2. Map Your Products\n\nCreate equivalent products in Polar:\n\n```typescript\nimport { Polar } from \"@polar-sh/sdk\";\n\nconst polar = new Polar({\n accessToken: process.env.POLAR_ACCESS_TOKEN,\n server: \"sandbox\", // Test first!\n});\n\n// Create products matching your old catalog\nconst basicPlan = await polar.products.create({\n name: \"Basic Plan\",\n organizationId: \"org_xxx\",\n description: \"Basic features\",\n prices: [\n {\n type: \"recurring\",\n recurringInterval: \"month\",\n amountType: \"fixed\",\n priceAmount: 900, // $9.00\n priceCurrency: \"usd\",\n },\n {\n type: \"recurring\",\n recurringInterval: \"year\",\n amountType: \"fixed\",\n priceAmount: 9000, // $90.00\n priceCurrency: \"usd\",\n },\n ],\n});\n```\n\n### 3. Map Benefits/Entitlements\n\n```typescript\n// Create benefits in Polar\nconst licenseBenefit = await polar.benefits.create({\n type: \"license_keys\",\n description: \"Software License\",\n organizationId: \"org_xxx\",\n properties: {\n prefix: \"PRO\",\n activations: { limit: 3 },\n },\n});\n\n// Attach the benefit to the product in the Polar dashboard\n// (Organization → Products → [product] → Benefits).\n```\n\n### 4. Set Up Webhooks\n\n```typescript\nimport { validateEvent, WebhookVerificationError } from \"@polar-sh/sdk/webhooks\";\n\nexport async function POST(request: Request): Promise<Response> {\n const body = await request.text();\n\n let event: ReturnType<typeof validateEvent>;\n try {\n event = validateEvent(\n body,\n {\n \"webhook-id\": request.headers.get(\"webhook-id\") ?? \"\",\n \"webhook-timestamp\": request.headers.get(\"webhook-timestamp\") ?? \"\",\n \"webhook-signature\": request.headers.get(\"webhook-signature\") ?? \"\",\n },\n process.env.POLAR_WEBHOOK_SECRET!,\n );\n } catch (error) {\n if (error instanceof WebhookVerificationError) {\n return Response.json({ received: false }, { status: 403 });\n }\n throw error;\n }\n\n switch (event.type) {\n case \"order.paid\":\n // Grant access in your system\n await grantAccess(event.data.customer.externalId, event.data.productId);\n break;\n\n case \"subscription.canceled\":\n // Schedule access removal\n await scheduleAccessRemoval(event.data.customer.externalId, event.data.endsAt);\n break;\n }\n\n return Response.json({ received: true });\n}\n```\n\nSee the `polar-integration` skill for the full webhook recipe (event types, framework variations, raw-body requirement, idempotency).\n\n## Migration from Stripe Billing\n\n### Export Customer Data\n\n```typescript\nimport Stripe from \"stripe\";\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY);\n\n// Export customers with active subscriptions\nasync function exportStripeCustomers() {\n const customers: ExportedCustomer[] = [];\n\n for await (const subscription of stripe.subscriptions.list({ status: \"active\" })) {\n const customer = await stripe.customers.retrieve(subscription.customer as string);\n\n if (customer.deleted) continue;\n\n customers.push({\n stripeCustomerId: customer.id,\n email: customer.email!,\n name: customer.name || undefined,\n subscriptionId: subscription.id,\n productId: subscription.items.data[0].price.product as string,\n priceId: subscription.items.data[0].price.id,\n currentPeriodEnd: new Date(subscription.current_period_end * 1000),\n cancelAtPeriodEnd: subscription.cancel_at_period_end,\n });\n }\n\n return customers;\n}\n```\n\n### Create Customers in Polar\n\n```typescript\n// Map Stripe products to Polar products\nconst productMap: Record<string, string> = {\n \"prod_stripe_basic\": \"prod_polar_basic\",\n \"prod_stripe_pro\": \"prod_polar_pro\",\n};\n\nasync function createPolarCustomers(customers: ExportedCustomer[]) {\n for (const customer of customers) {\n // Create customer in Polar with external ID for linking\n const polarCustomer = await polar.customers.create({\n organizationId: \"org_xxx\",\n email: customer.email,\n name: customer.name,\n externalId: customer.stripeCustomerId, // Link to Stripe ID\n });\n\n console.log(`Created Polar customer: ${polarCustomer.id} for ${customer.email}`);\n }\n}\n```\n\n### Migrate Subscriptions (Gradual)\n\n```typescript\nasync function migrateSubscription(customer: ExportedCustomer) {\n const polarProductId = productMap[customer.productId];\n\n // Create checkout for customer to enter new payment method\n const checkout = await polar.checkouts.create({\n products: [polarProductId],\n customerEmail: customer.email,\n externalCustomerId: customer.stripeCustomerId,\n successUrl: `https://yoursite.com/migration-complete?customer=${customer.stripeCustomerId}`,\n // Allow discount for migration\n discountId: \"migration_discount_xxx\",\n });\n\n // Send migration email to customer\n await sendMigrationEmail(customer.email, {\n checkoutUrl: checkout.url,\n currentPeriodEnd: customer.currentPeriodEnd,\n });\n\n return checkout;\n}\n```\n\n### Handle Migration Completion\n\n```typescript\n// Inside the validateEvent switch in your webhook handler:\ncase \"order.paid\": {\n const order = event.data;\n const stripeCustomerId = order.customer.externalId;\n\n if (stripeCustomerId?.startsWith(\"cus_\")) {\n // Cancel Stripe subscription at period end\n const stripeSubscriptions = await stripe.subscriptions.list({\n customer: stripeCustomerId,\n status: \"active\",\n });\n\n for (const sub of stripeSubscriptions.data) {\n await stripe.subscriptions.update(sub.id, {\n cancel_at_period_end: true,\n });\n }\n\n // Mark customer as migrated in your database\n await db.customer.update({\n where: { stripeId: stripeCustomerId },\n data: {\n migratedToPolar: true,\n polarCustomerId: order.customer.id,\n },\n });\n }\n break;\n}\n```\n\n## Migration from Paddle\n\n### Export Paddle Data\n\n```typescript\n// Paddle API to export subscribers\nasync function exportPaddleSubscribers() {\n const response = await fetch(\"https://vendors.paddle.com/api/2.0/subscription/users\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n vendor_id: process.env.PADDLE_VENDOR_ID,\n vendor_auth_code: process.env.PADDLE_AUTH_CODE,\n state: \"active\",\n }),\n });\n\n const data = await response.json();\n return data.response;\n}\n```\n\n### Map Paddle to Polar\n\n```typescript\nconst paddleToPolar = {\n products: {\n \"paddle_12345\": \"polar_basic\",\n \"paddle_12346\": \"polar_pro\",\n },\n};\n\nasync function migratePaddleCustomer(paddleUser: PaddleSubscriber) {\n // Create checkout with Polar\n const checkout = await polar.checkouts.create({\n products: [paddleToPolar.products[paddleUser.plan_id]],\n customerEmail: paddleUser.user_email,\n externalCustomerId: `paddle_${paddleUser.user_id}`,\n successUrl: \"https://yoursite.com/migrated\",\n });\n\n return checkout;\n}\n```\n\n## Migration from Lemon Squeezy\n\n```typescript\n// Lemon Squeezy API export\nasync function exportLemonSqueezyCustomers() {\n const response = await fetch(\"https://api.lemonsqueezy.com/v1/subscriptions\", {\n headers: {\n Authorization: `Bearer ${process.env.LEMON_SQUEEZY_API_KEY}`,\n },\n });\n\n const { data } = await response.json();\n return data.filter((sub: any) => sub.attributes.status === \"active\");\n}\n```\n\n## Customer Communication\n\n### Migration Email Template\n\n```typescript\nconst migrationEmailTemplate = {\n subject: \"Action Required: Update Your Payment Method\",\n body: `\nHi {{customer_name}},\n\nWe're upgrading our payment system to provide you with a better experience.\n\n**What you need to do:**\nClick the link below to update your payment method. This will only take a minute.\n\n[Update Payment Method]({{checkout_url}})\n\n**What's changing:**\n- More payment options\n- Better invoice management\n- Improved customer portal\n\n**What's NOT changing:**\n- Your subscription price\n- Your features and access\n- Your billing date\n\n**Timeline:**\nPlease complete this by {{deadline}} to ensure uninterrupted service.\n\nYour current subscription will continue until {{current_period_end}}, then\nseamlessly transition to our new system.\n\nQuestions? Reply to this email.\n\nThanks,\n{{company_name}}\n`,\n};\n\nasync function sendMigrationEmail(email: string, data: MigrationData) {\n // Use your email service (SendGrid, Postmark, etc.)\n await emailService.send({\n to: email,\n subject: migrationEmailTemplate.subject,\n body: renderTemplate(migrationEmailTemplate.body, {\n customer_name: data.name || \"there\",\n checkout_url: data.checkoutUrl,\n deadline: formatDate(data.deadline),\n current_period_end: formatDate(data.currentPeriodEnd),\n company_name: \"Your Company\",\n }),\n });\n}\n```\n\n### Reminder Sequence\n\n```typescript\nconst reminderSchedule = [\n { daysBeforeDeadline: 14, template: \"initial\" },\n { daysBeforeDeadline: 7, template: \"reminder\" },\n { daysBeforeDeadline: 3, template: \"urgent\" },\n { daysBeforeDeadline: 1, template: \"final\" },\n];\n\nasync function scheduleReminders(customer: Customer, deadline: Date) {\n for (const reminder of reminderSchedule) {\n const sendAt = subDays(deadline, reminder.daysBeforeDeadline);\n\n await scheduleEmail({\n to: customer.email,\n template: reminder.template,\n sendAt,\n data: { checkoutUrl: customer.migrationCheckoutUrl },\n });\n }\n}\n```\n\n## Parallel Running\n\n### Router Pattern\n\n```typescript\n// Determine which system handles a customer\nasync function getPaymentSystem(userId: string): Promise<\"stripe\" | \"polar\"> {\n const user = await db.user.findUnique({ where: { id: userId } });\n\n if (user?.polarCustomerId) {\n return \"polar\";\n }\n return \"stripe\";\n}\n\n// Route subscription checks\nasync function hasActiveSubscription(userId: string): Promise<boolean> {\n const system = await getPaymentSystem(userId);\n\n if (system === \"polar\") {\n const state = await polar.customers.getState({\n customerId: user.polarCustomerId,\n });\n return state.activeSubscriptions.length > 0;\n } else {\n const subscriptions = await stripe.subscriptions.list({\n customer: user.stripeCustomerId,\n status: \"active\",\n });\n return subscriptions.data.length > 0;\n }\n}\n```\n\n### Unified Webhook Handler\n\n```typescript\n// Handle webhooks from both systems during migration\napp.post(\"/webhooks/stripe\", stripeWebhookHandler);\napp.post(\"/webhooks/polar\", polarWebhookHandler);\n\n// Normalize events to your internal format\ninterface SubscriptionEvent {\n type: \"created\" | \"canceled\" | \"renewed\";\n customerId: string;\n productId: string;\n source: \"stripe\" | \"polar\";\n}\n\nfunction normalizeStripeEvent(event: Stripe.Event): SubscriptionEvent | null {\n // Convert Stripe event to internal format\n}\n\nfunction normalizePolarEvent(event: PolarWebhookEvent): SubscriptionEvent | null {\n // Convert Polar event to internal format\n}\n```\n\n## Post-Migration Cleanup\n\n### Verify Migration\n\n```typescript\nasync function verifyMigration() {\n // Get all customers marked as migrated\n const migratedCustomers = await db.customer.findMany({\n where: { migratedToPolar: true },\n });\n\n const issues: string[] = [];\n\n for (const customer of migratedCustomers) {\n // Verify Polar subscription exists\n const polarState = await polar.customers.getState({\n customerId: customer.polarCustomerId,\n });\n\n if (polarState.activeSubscriptions.length === 0) {\n issues.push(`${customer.email}: No active Polar subscription`);\n }\n\n // Verify Stripe subscription is canceled\n const stripeSubscriptions = await stripe.subscriptions.list({\n customer: customer.stripeCustomerId,\n status: \"active\",\n });\n\n if (stripeSubscriptions.data.length > 0) {\n issues.push(`${customer.email}: Stripe subscription still active`);\n }\n }\n\n return issues;\n}\n```\n\n### Cancel Old System\n\n```typescript\n// After all customers migrated, cancel remaining Stripe subscriptions\nasync function cancelRemainingStripeSubscriptions() {\n for await (const subscription of stripe.subscriptions.list({ status: \"active\" })) {\n // Check if customer was migrated\n const customer = await db.customer.findFirst({\n where: { stripeId: subscription.customer as string },\n });\n\n if (customer?.migratedToPolar) {\n await stripe.subscriptions.cancel(subscription.id);\n console.log(`Canceled Stripe subscription ${subscription.id}`);\n }\n }\n}\n```\n\n## Migration Checklist\n\n### Before Migration\n\n- [ ] Products created in Polar (sandbox)\n- [ ] Benefits/entitlements mapped\n- [ ] Webhooks configured\n- [ ] Email templates ready\n- [ ] Customer support informed\n- [ ] Rollback plan documented\n\n### During Migration\n\n- [ ] Test with small batch first\n- [ ] Monitor error rates\n- [ ] Track conversion rate\n- [ ] Respond to customer issues quickly\n- [ ] Send reminder emails on schedule\n\n### After Migration\n\n- [ ] Verify all subscriptions migrated\n- [ ] Cancel old platform subscriptions\n- [ ] Update documentation\n- [ ] Remove old platform code\n- [ ] Archive old platform data\n- [ ] Celebrate! 🎉\n\n## Troubleshooting\n\n**Customer didn't receive migration email**\n- Check spam folder\n- Verify email address in export\n- Resend with different subject line\n\n**Customer completed checkout but Stripe not canceled**\n- Check webhook logs\n- Verify external_id mapping\n- Manually cancel if needed\n\n**Double billing**\n- Refund on old platform\n- Apologize to customer\n- Improve timing logic\n\n**Customer wants to stay on old platform**\n- Respect their choice (if possible)\n- Offer incentive to migrate\n- Set final deadline\n""---\nname: polar-migration\ndescription: |\n Guide for migrating to Polar from other payment platforms (Stripe Billing, Paddle, Lemon Squeezy). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Moving customers, subscriptions and saved cards with Polar's Stripe migration; (3) Running both billing systems during the transition; (4) Mapping products and pricing between platforms; (5) Updating webhook and access handling for migrated subscriptions; (6) Communicating the migration to customers; (7) Testing the migration in sandbox first.\n---\n\n# Polar Migration Guide\n\n[Polar's migration guide](https://polar.sh/docs/migrate) is the source of truth; read it before recommending anything. This skill summarizes what the application has to do.\n\n## Pick the path\n\n- **From Stripe:** use Polar's assisted migration under **Settings → Migrations**. Polar imports products, prices and customers, Stripe copies saved cards straight to Polar's payment processor, and Polar switches each subscription over with the same paid period. Most customers don't need to do anything. Migrations are rolling out gradually; if the tab is missing, contact [Polar support](https://polar.sh/docs/support).\n- **From Paddle, Lemon Squeezy or anything else:** there's no automated migration. Contact Polar support first, then follow \"Other providers\" below. Customers move by checking out again on Polar.\n\n## Rules for every migration\n\n- Never stop, cancel or pause the old provider's billing, or activate a migrated Polar subscription, outside Polar's switch. Don't write your own cutover scripts: they double-bill.\n- Never handle raw card data, and never ask for Stripe or Polar secrets in chat.\n- Use the application's immutable customer ID as the Polar customer `external_id` (passed as `external_customer_id` at checkout). Not email, and not the old provider's ID unless that's already the app's canonical ID. An `external_id` can't be changed once set.\n- Each subscription must have exactly one system responsible for its next renewal. Record which one, rather than inferring it from which IDs are set.\n- Past payments stay in the old provider. Don't create Polar orders for transactions Polar didn't process, and keep the old account for refunds, disputes and accounting.\n\n## From Stripe\n\nThe dashboard handles connecting Stripe, importing the catalog, copying cards and switching subscriptions. The application handles the first and last stages and the events in between.\n\n### 1. Move new sales to Polar\n\nIntegrate Polar checkout and webhooks first (`polar-integration`), passing `external_customer_id` on every checkout. New customers then subscribe on Polar while existing subscriptions keep renewing on Stripe. Keep the Stripe IDs in your database until nothing renews there.\n\nDuring coexistence, access checks must accept a valid entitlement from either provider, and cancellations or plan changes go to whichever system bills that subscription.\n\n### 2. Map imported customers\n\nThe import creates or reuses Polar customers, but Polar can't know your application's customer IDs. Match each imported customer once on an exact, unique email, then set your ID on customers whose `external_id` is still empty. Send missing, duplicate or changed emails for review instead of guessing.\n\n```ts\nimport { polar } from \"./polar\"; // client from polar-integration\n\nconst customer = await polar.customers.get(polarCustomerId);\nif (customer.external_id === null) {\n await polar.customers.update(polarCustomerId, { external_id: appCustomerId });\n}\n```\n\nImported products are new Polar products. Map each internal plan to its new Polar product and price IDs explicitly, never by name. Benefits aren't imported: attach them to the imported products before the switch if access depends on them.\n\n### 3. Prepare for the switch\n\nPolar stops each switched subscription on Stripe by canceling it immediately, without proration or a final invoice. Before switching:\n\n- Turn off Stripe's canceled-subscription email (Settings → Billing → Subscriptions and emails) and pause any Stripe automation triggered by a cancellation.\n- Make the Stripe `customer.subscription.deleted` handler ignore switched subscriptions, so it doesn't revoke access or send a cancellation email:\n\n ```ts\n if (subscription.cancellation_details?.comment?.startsWith(\"Migrated to Polar\")) {\n return; // billing moved to Polar; reconcile through subscription.migrated instead\n }\n ```\n\n- Handle Polar's `subscription.migrated` event as the cutover signal. It carries `provider` and `provider_subscription_id`. Treat it as an idempotent upsert: store the Polar subscription ID against the Stripe subscription and record Polar as the system that bills next. Don't grant or revoke access from it, and don't send a welcome email:\n\n ```ts\n case \"subscription.migrated\":\n await recordMigratedSubscription({\n stripeSubscriptionId: event.provider_subscription_id,\n polarSubscriptionId: event.data.id,\n });\n break;\n ```\n\nExpect `subscription.updated`, `subscription.migrated` and `customer.state_changed` per switched subscription, in any order. Migration sends no `subscription.created` or `subscription.active`, and the import sends no `product.created` or `customer.created`.\n\n### 4. Switch, reconcile and clean up\n\nRun the switch from the dashboard. It can't be undone, and subscriptions renewing within 24 hours stay on Stripe until the next attempt. Afterwards, check that every moved subscription bills only in Polar and maps to one customer and entitlement. Retire Stripe checkout and subscription code only once no subscriptions renew there.\n\nLimits to know up front: Stripe Connect platform accounts and Stripe accounts in India can't use the automated flow. Subscriptions sold through Stripe Managed Payments, and those with multiple items, a quantity above 1, or a past-due, unpaid or paused status, need attention or stay on Stripe. Only cards are copied, real card copies need live Stripe accounts, and the Stripe key mode must match the Polar environment.\n\nFor a full assessment, Polar's guide includes a [coding assistant prompt](https://polar.sh/docs/migrate#coding-assistant-prompt) that produces a migration plan and reconciliation contract before any code changes.\n\n## Other providers\n\n### Recreate the catalog\n\nThe billing interval belongs to the product, and a product has one fixed price per currency. Monthly and yearly billing are two products:\n\n```ts\nawait polar.products.create({\n name: \"Pro (Monthly)\",\n recurring_interval: \"month\",\n prices: [{ amount_type: \"fixed\", price_amount: 2900, price_currency: \"usd\" }],\n});\nawait polar.products.create({\n name: \"Pro (Yearly)\",\n recurring_interval: \"year\",\n prices: [{ amount_type: \"fixed\", price_amount: 29000, price_currency: \"usd\" }],\n});\n```\n\nRecreate benefits (license keys, file downloads, Discord roles and others) and attach them with `polar.products.updateBenefits(productId, { benefits: [benefitId] })`.\n\n### Move customers\n\nCreate customers ahead of time if you want them in Polar before they check out:\n\n```ts\nawait polar.customers.create({ external_id: appCustomerId, email, name });\n```\n\nPaid subscriptions can't be created through the API, and saved cards can't move between processors outside Stripe's copy. Each customer checks out on Polar, where they enter their card. Send them a checkout link created with `external_customer_id`.\n\nTo avoid charging twice for the same period, schedule the old subscription to cancel at the end of its period, and give the Polar checkout a trial that covers the days left, using `trial_interval: \"day\"` and `trial_interval_count`. Check this approach with Polar support for your case.\n\n### License keys\n\nLicense keys can't be imported. Keep validating existing keys against the old provider during the transition; new purchases get Polar keys.\n\n## Communicating with customers\n\n- **Stripe migration:** no action is needed from customers. Polar adds a notice about the move to its first billing emails, such as the first renewal receipt. If you want customers to hear about it from you first, send a short heads-up before switching.\n- **Other providers:** customers need to check out again. Tell them plainly that they'll enter their card on Polar, when they'll be charged, and that the old subscription ends at the end of its paid period.\n\n## Testing\n\nRun the migration in Polar's sandbox with a Stripe test-mode account first. Sandbox verifies the application side (customer mapping, `subscription.migrated` handling, access checks), but can't copy real cards. See `polar-testing` for sandbox setup and local webhook testing.\n"Full snapshot data
{
"description": "Guide for migrating to Polar from other payment platforms (Stripe Billing, Paddle, Lemon Squeezy). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Moving customers, subscriptions and saved cards with Polar's Stripe migration; (3) Running both billing systems during the transition; (4) Mapping products and pricing between platforms; (5) Updating webhook and access handling for migrated subscriptions; (6) Communicating the migration to customers; (7) Testing the migration in sandbox first.\n",
"included_files": [],
"name": "polar-migration",
"skill_md_contents": "---\nname: polar-migration\ndescription: |\n Guide for migrating to Polar from other payment platforms (Stripe Billing, Paddle, Lemon Squeezy). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Moving customers, subscriptions and saved cards with Polar's Stripe migration; (3) Running both billing systems during the transition; (4) Mapping products and pricing between platforms; (5) Updating webhook and access handling for migrated subscriptions; (6) Communicating the migration to customers; (7) Testing the migration in sandbox first.\n---\n\n# Polar Migration Guide\n\n[Polar's migration guide](https://polar.sh/docs/migrate) is the source of truth; read it before recommending anything. This skill summarizes what the application has to do.\n\n## Pick the path\n\n- **From Stripe:** use Polar's assisted migration under **Settings → Migrations**. Polar imports products, prices and customers, Stripe copies saved cards straight to Polar's payment processor, and Polar switches each subscription over with the same paid period. Most customers don't need to do anything. Migrations are rolling out gradually; if the tab is missing, contact [Polar support](https://polar.sh/docs/support).\n- **From Paddle, Lemon Squeezy or anything else:** there's no automated migration. Contact Polar support first, then follow \"Other providers\" below. Customers move by checking out again on Polar.\n\n## Rules for every migration\n\n- Never stop, cancel or pause the old provider's billing, or activate a migrated Polar subscription, outside Polar's switch. Don't write your own cutover scripts: they double-bill.\n- Never handle raw card data, and never ask for Stripe or Polar secrets in chat.\n- Use the application's immutable customer ID as the Polar customer `external_id` (passed as `external_customer_id` at checkout). Not email, and not the old provider's ID unless that's already the app's canonical ID. An `external_id` can't be changed once set.\n- Each subscription must have exactly one system responsible for its next renewal. Record which one, rather than inferring it from which IDs are set.\n- Past payments stay in the old provider. Don't create Polar orders for transactions Polar didn't process, and keep the old account for refunds, disputes and accounting.\n\n## From Stripe\n\nThe dashboard handles connecting Stripe, importing the catalog, copying cards and switching subscriptions. The application handles the first and last stages and the events in between.\n\n### 1. Move new sales to Polar\n\nIntegrate Polar checkout and webhooks first (`polar-integration`), passing `external_customer_id` on every checkout. New customers then subscribe on Polar while existing subscriptions keep renewing on Stripe. Keep the Stripe IDs in your database until nothing renews there.\n\nDuring coexistence, access checks must accept a valid entitlement from either provider, and cancellations or plan changes go to whichever system bills that subscription.\n\n### 2. Map imported customers\n\nThe import creates or reuses Polar customers, but Polar can't know your application's customer IDs. Match each imported customer once on an exact, unique email, then set your ID on customers whose `external_id` is still empty. Send missing, duplicate or changed emails for review instead of guessing.\n\n```ts\nimport { polar } from \"./polar\"; // client from polar-integration\n\nconst customer = await polar.customers.get(polarCustomerId);\nif (customer.external_id === null) {\n await polar.customers.update(polarCustomerId, { external_id: appCustomerId });\n}\n```\n\nImported products are new Polar products. Map each internal plan to its new Polar product and price IDs explicitly, never by name. Benefits aren't imported: attach them to the imported products before the switch if access depends on them.\n\n### 3. Prepare for the switch\n\nPolar stops each switched subscription on Stripe by canceling it immediately, without proration or a final invoice. Before switching:\n\n- Turn off Stripe's canceled-subscription email (Settings → Billing → Subscriptions and emails) and pause any Stripe automation triggered by a cancellation.\n- Make the Stripe `customer.subscription.deleted` handler ignore switched subscriptions, so it doesn't revoke access or send a cancellation email:\n\n ```ts\n if (subscription.cancellation_details?.comment?.startsWith(\"Migrated to Polar\")) {\n return; // billing moved to Polar; reconcile through subscription.migrated instead\n }\n ```\n\n- Handle Polar's `subscription.migrated` event as the cutover signal. It carries `provider` and `provider_subscription_id`. Treat it as an idempotent upsert: store the Polar subscription ID against the Stripe subscription and record Polar as the system that bills next. Don't grant or revoke access from it, and don't send a welcome email:\n\n ```ts\n case \"subscription.migrated\":\n await recordMigratedSubscription({\n stripeSubscriptionId: event.provider_subscription_id,\n polarSubscriptionId: event.data.id,\n });\n break;\n ```\n\nExpect `subscription.updated`, `subscription.migrated` and `customer.state_changed` per switched subscription, in any order. Migration sends no `subscription.created` or `subscription.active`, and the import sends no `product.created` or `customer.created`.\n\n### 4. Switch, reconcile and clean up\n\nRun the switch from the dashboard. It can't be undone, and subscriptions renewing within 24 hours stay on Stripe until the next attempt. Afterwards, check that every moved subscription bills only in Polar and maps to one customer and entitlement. Retire Stripe checkout and subscription code only once no subscriptions renew there.\n\nLimits to know up front: Stripe Connect platform accounts and Stripe accounts in India can't use the automated flow. Subscriptions sold through Stripe Managed Payments, and those with multiple items, a quantity above 1, or a past-due, unpaid or paused status, need attention or stay on Stripe. Only cards are copied, real card copies need live Stripe accounts, and the Stripe key mode must match the Polar environment.\n\nFor a full assessment, Polar's guide includes a [coding assistant prompt](https://polar.sh/docs/migrate#coding-assistant-prompt) that produces a migration plan and reconciliation contract before any code changes.\n\n## Other providers\n\n### Recreate the catalog\n\nThe billing interval belongs to the product, and a product has one fixed price per currency. Monthly and yearly billing are two products:\n\n```ts\nawait polar.products.create({\n name: \"Pro (Monthly)\",\n recurring_interval: \"month\",\n prices: [{ amount_type: \"fixed\", price_amount: 2900, price_currency: \"usd\" }],\n});\nawait polar.products.create({\n name: \"Pro (Yearly)\",\n recurring_interval: \"year\",\n prices: [{ amount_type: \"fixed\", price_amount: 29000, price_currency: \"usd\" }],\n});\n```\n\nRecreate benefits (license keys, file downloads, Discord roles and others) and attach them with `polar.products.updateBenefits(productId, { benefits: [benefitId] })`.\n\n### Move customers\n\nCreate customers ahead of time if you want them in Polar before they check out:\n\n```ts\nawait polar.customers.create({ external_id: appCustomerId, email, name });\n```\n\nPaid subscriptions can't be created through the API, and saved cards can't move between processors outside Stripe's copy. Each customer checks out on Polar, where they enter their card. Send them a checkout link created with `external_customer_id`.\n\nTo avoid charging twice for the same period, schedule the old subscription to cancel at the end of its period, and give the Polar checkout a trial that covers the days left, using `trial_interval: \"day\"` and `trial_interval_count`. Check this approach with Polar support for your case.\n\n### License keys\n\nLicense keys can't be imported. Keep validating existing keys against the old provider during the transition; new purchases get Polar keys.\n\n## Communicating with customers\n\n- **Stripe migration:** no action is needed from customers. Polar adds a notice about the move to its first billing emails, such as the first renewal receipt. If you want customers to hear about it from you first, send a short heads-up before switching.\n- **Other providers:** customers need to check out again. Tell them plainly that they'll enter their card on Polar, when they'll be charged, and that the old subscription ends at the end of its paid period.\n\n## Testing\n\nRun the migration in Polar's sandbox with a Stripe test-mode account first. Sandbox verifies the application side (customer mapping, `subscription.migrated` handling, access checks), but can't copy real cards. See `polar-testing` for sandbox setup and local webhook testing.\n"
}SHA-256 of public snapshot: b65b3044b1a5a15366c876b8c8cfc12af136510d1b0bf1d4b12dc368c85e3743