polar-migration について
polar-migrationは、サブスクリプション事業をPolarへ移行する開発者向けガイドです。顧客・商品・契約の移行方法に加え、段階的な切り替え、並行運用、決済方法の制約、移行後の確認を扱います。
主な機能
- 01顧客データのインポート方法を説明
- 02商品と特典のマッピングを計画
- 033種類の移行戦略を比較
- 04Stripe、Paddleなどからの移行例
- 05並行運用とWebhookを解説
- 06移行前後のチェックリストを提供
活用シーン
- 01Stripe Billingの顧客をPolarへ移行
- 02有効なサブスクリプションを段階的に移行
- 03移行中に2つの決済システムを並行運用
- 04移行結果を確認して旧プラットフォームを終了
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ polarsource/ skills --skill polar-migrationインストール後、Claude Code で/polar-migrationを入力して読み込まれたか確認します。
詳細
Polar 移行ガイド
顧客や収益への影響を最小限に抑えながら、ほかの決済プラットフォームから Polar に移行します。
移行の概要
移行できるデータ
| データの種類 | 移行方法 |
|---|---|
| 顧客のメールアドレス/名前 | API インポート |
| 商品カタログ | 手動で再作成 |
| 有効なサブスクリプション | 新しいサブスクリプションを作成 |
| 決済方法 | 顧客による再入力が必要* |
| 過去の注文 | 移行されません(旧システムに保持) |
| ライセンスキー | 新しいキーを生成 |
*PCI 準拠のため、決済方法のトークンを異なる決済処理事業者間で移行することはできません。顧客は決済情報を再入力する必要があります。
移行戦略
1. 一斉切り替え
- 旧システムのすべてのサブスクリプションをキャンセルする
- Polar で新しいサブスクリプションを作成する
- 最適なケース:顧客基盤が小さく、商品がシンプルな場合
2. 段階的な移行
- 両方のシステムを並行して運用する
- 顧客の更新時に移行する
- 最適なケース:顧客基盤が大きく、リスクを最小限に抑えたい場合
3. 新規顧客のみ
- 既存の顧客は旧システムを利用し続ける
- 新規顧客には Polar を利用してもらう
- 最適なケース:本格的に移行する前に Polar を試したい場合
移行前のチェックリスト
1. Polar をセットアップする
# Test in sandbox first
# https://sandbox.polar.sh
# Install SDK
npm install @polar-sh/sdk
2. 商品を対応付ける
Polar で同等の商品を作成します。
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. 特典/エンタイトルメントを対応付ける
// 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 },
},
});
// Attach the benefit to the product in the Polar dashboard
// (Organization → Products → [product] → Benefits).
4. Webhook をセットアップする
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 });
}
Webhook の設定手順全体(イベントの種類、フレームワークごとの違い、raw body の要件、冪等性)については、polar-integration スキルを参照してください。
Stripe Billing からの移行
顧客データをエクスポートする
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// Export customers with active subscriptions
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;
}
Polar で顧客を作成する
// 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
});
console.log(`Created Polar customer: ${polarCustomer.id} for ${customer.email}`);
}
}
サブスクリプションを移行する(段階的な移行)
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,
});
return checkout;
}
移行完了に対応する
// 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",
});
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;
}
Paddle からの移行
Paddle のデータをエクスポートする
// 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;
}
Paddle から Polar への対応付け
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;
}
Lemon Squeezy からの移行
// 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");
}
顧客への連絡
移行メールのテンプレート
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",
}),
});
}
リマインダーの送信順序
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 },
});
}
}
並行運用
ルーターパターン
// 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;
}
}
統合 Webhook ハンドラー
// 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";
}
function normalizeStripeEvent(event: Stripe.Event): SubscriptionEvent | null {
// Convert Stripe event to internal format
}
function normalizePolarEvent(event: PolarWebhookEvent): SubscriptionEvent | null {
// Convert Polar event to internal format
}
移行後のクリーンアップ
移行を検証する
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,
});
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",
});
if (stripeSubscriptions.data.length > 0) {
issues.push(`${customer.email}: Stripe subscription still active`);
}
}
return issues;
}
旧システムを解約する
// 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}`);
}
}
}
移行チェックリスト
移行前
- Polar で商品を作成済み(サンドボックス)
- 特典/エンタイトルメントを対応付け済み
- Webhook を設定済み
- メールテンプレートを準備済み
- カスタマーサポートに周知済み
- ロールバック計画を文書化済み
移行中
- まず少数の顧客でテストする
- エラー率を監視する
- コンバージョン率を追跡する
- 顧客の問題に迅速に対応する
- 予定どおりにリマインダーメールを送信する
移行後
- すべてのサブスクリプションが移行されたことを確認する
- 旧プラットフォームのサブスクリプションをキャンセルする
- ドキュメントを更新する
- 旧プラットフォーム用のコードを削除する
- 旧プラットフォームのデータをアーカイブする
- お祝いしましょう!🎉
トラブルシューティング
顧客に移行メールが届かなかった
- 迷惑メールフォルダーを確認する
- エクスポートデータ内のメールアドレスを確認する
- 件名を変えて再送する
顧客がチェックアウトを完了したが、Stripe がキャンセルされていない
- Webhook のログを確認する
- external_id の対応付けを確認する
- 必要に応じて手動でキャンセルする
二重請求
- 旧プラットフォームで返金する
- 顧客に謝罪する
- タイミングのロジックを改善する
顧客が旧プラットフォームを使い続けたい
- 可能な限り、顧客の選択を尊重する
- 移行を促す特典を提案する
- 最終期限を設定する
コメント
Markdown 対応