ブログ リソース 概要 検索 トピック
Tutorials

Node.jsでのPolar.sh決済: バックエンド完全ガイド

更新日 2026年1月3日

カテゴリー: Tutorials
共有

Node.jsバックエンドでのPolar.sh決済統合

この投稿は、Polar.sh決済に関する私のXスレッドを、完全なコード例、エラー処理、本番環境での使用から得られた教訓を加えて拡張したものです。

SaaSを構築したりデジタル商品を販売する場合、決済プロバイダーが必要です。Polar.shは私の個人プロジェクトで使用する主要な選択肢となっています。ここでは、Node.jsバックエンドとの統合方法を紹介します。

Polar.shを選ぶ理由 #why-polar-sh

コードに深く入る前に、代替案の中でなぜPolar.shを選んだのかを説明します。

Stripeとの比較: Polarはより多くの機能を標準で提供しています。ホストされたチェックアウトページ、顧客ポータル、サブスクリプションライフサイクル管理を、自前で構築することなく利用できます。Stripeはより多くの制御を可能にしますが、より多くのコードを必要とします。

Paddle / Lemon Squeezyとの比較: メリチャント・オブ・レコード(税務処理を担う事業者)としての同様の利点(VAT/消費税の処理を担う)がありますが、Polarは開発者とオープンソースプロジェクトのために特別に構築されています。開発体験(DX)が明らかに優れています。

Polarが代行してくれるもの:

  • ホストされたチェックアウトページ
  • 決済処理
  • 顧客ポータル
  • サブスクリプションライフサイクル
  • 税務コンプライアンス(メリチャント・オブ・レコードとして)

あなたが担うもの:

  • Webhookイベント
  • ユーザーの権利付与
  • 使用状況の追跡

プロジェクトの設定 #project-setup

SDKとWebhook検証ライブラリをインストールします:

Terminal
npm install @polar-sh/sdk standardwebhooks

Polarクライアントを初期化します:

src/lib/polar.ts
import { Polar } from '@polar-sh/sdk';

export const polar = new Polar({
    accessToken: process.env.POLAR_ACCESS_TOKEN!,
    server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});

必要な環境変数:

.env
POLAR_ACCESS_TOKEN=your_access_token
POLAR_WEBHOOK_SECRET=your_webhook_secret

これらはPolarダッシュボードから取得します。開発中はサンドボックスモードを使用してください。テスト用のカードが提供され、実際の金額は課金されません。

チェックアウトセッションの作成 #creating-checkout-sessions

ユーザーが「Subscribe」または「Buy」をクリックした場合、チェックアウトセッションを作成し、リダイレクトさせます:

src/routes/checkout.ts
import { polar } from '../lib/polar';

export async function createCheckout(user: { id: string; email: string }, productId: string) {
    const checkout = await polar.checkouts.create({
        products: [productId],
        successUrl: 'https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}',
        customerEmail: user.email,
        metadata: {
            userId: String(user.id),
        },
    });

    return checkout.url;
}

ポイント:

  • metadata.userIdはPolarの顧客をあなたのユーザーと紐付けます。Webhookでこれが必要になります。
  • successUrlには{CHECKOUT_SESSION_ID}を含めることができ、Polarは実際のIDに置き換えます。
  • productsは配列を受け取ります。1つのチェックアウトで複数の商品をバンドルできます。

Expressルーターの例:

src/routes/api.ts
router.post('/create-checkout', async (req, res) => {
    const user = req.user; // 認証ミドルウェアから
    const { productId } = req.body;

    if (!productId) {
        return res.status(400).json({ error: 'Product ID required' });
    }

    try {
        const checkoutUrl = await createCheckout(user, productId);
        res.json({ url: checkoutUrl });
    } catch (error) {
        console.error('Checkout creation failed:', error);
        res.status(500).json({ error: 'Failed to create checkout' });
    }
});

Webhookの処理 #webhook-handling

Webhookは、Polarがアプリに支払いイベントを通知する方法です。これは正しく実装することが最も重要な部分です。

シグネチャ検証

PolarはStandard Webhooks仕様を使用しています。スプーフィングされたリクエストを防ぐために、シグネチャを検証する必要があります:

src/webhooks/polar.ts
import { Webhook } from 'standardwebhooks';

const secret = 'whsec_' + Buffer.from(
    process.env.POLAR_WEBHOOK_SECRET!
).toString('base64');

const wh = new Webhook(secret);

export function verifyWebhook(rawBody: string, headers: Record<string, string>) {
    try {
        return wh.verify(rawBody, headers);
    } catch (error) {
        console.error('Webhook verification failed:', error);
        throw new Error('Invalid webhook signature');
    }
}

ルーター設定(重要!)

Webhookルーターは、シグネチャ検証のために生のリクエストボディを必要とします。JSONミドルウェアの前に登録してください:

src/app.ts
import express from 'express';
import { webhookRouter } from './routes/webhooks';

const app = express();

// Webhookルーターを最初に登録 - 生のボディが必要
app.use('/api/webhooks', webhookRouter);

// Webhookの後にJSONパース
app.use(express.json());

// その他のルーター...
app.use('/api', apiRouter);
src/routes/webhooks.ts
import { Router } from 'express';
import express from 'express';
import { verifyWebhook } from '../webhooks/polar';
import { handlePolarEvent } from '../services/polar-events';

const router = Router();

router.post('/polar',
    express.raw({ type: 'application/json' }),
    async (req, res) => {
        try {
            const event = verifyWebhook(
                req.body.toString(),
                req.headers as Record<string, string>
            );

            await handlePolarEvent(event);
            res.status(200).json({ received: true });
        } catch (error) {
            console.error('Webhook error:', error);
            res.status(400).json({ error: 'Webhook processing failed' });
        }
    }
);

export { router as webhookRouter };

イベントハンドラー

主要なサブスクリプションイベントを処理する方法は以下の通りです:

src/services/polar-events.ts
import { db } from '../lib/db';

interface PolarEvent {
    type: string;
    data: {
        id: string;
        status: string;
        customer_id: string;
        metadata?: { userId?: string };
        current_period_end?: string;
    };
}

export async function handlePolarEvent(event: PolarEvent) {
    const { type, data } = event;

    switch (type) {
        case 'subscription.created':
            // Polar顧客をユーザーと紐付け
            await db.user.update({
                where: { id: data.metadata?.userId },
                data: {
                    polarCustomerId: data.customer_id,
                    subscriptionId: data.id,
                    subscriptionStatus: 'pending',
                },
            });
            break;

        case 'subscription.active':
            // 支払い成功 - サブスクリプションを有効化
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: 'active',
                    currentPeriodEnd: new Date(data.current_period_end!),
                    // プラン固有の制限を設定
                    apiCallsLimit: 10000,
                    storageLimit: 5 * 1024 * 1024 * 1024, // 5GB
                },
            });
            break;

        case 'subscription.updated':
            // プラン変更、更新など
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: data.status,
                    currentPeriodEnd: new Date(data.current_period_end!),
                },
            });
            break;

        case 'subscription.canceled':
            // ユーザーがキャンセル - 期間終了までアクティブ
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: 'canceled',
                    canceledAt: new Date(),
                },
            });
            break;

        case 'subscription.revoked':
            // 即時失効(支払い失敗、返金など)
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: 'revoked',
                    apiCallsLimit: 0,
                    storageLimit: 0,
                },
            });
            break;

        case 'order.paid':
            // 1回限りの購入完了
            await handleOneTimePurchase(data);
            break;

        default:
            console.log(`Unhandled event type: ${type}`);
    }
}

async function handleOneTimePurchase(data: PolarEvent['data']) {
    // ライフタイムアクセス、クレジットなどを付与
    await db.user.update({
        where: { id: data.metadata?.userId },
        data: {
            lifetimeAccess: true,
        },
    });
}

サブスクリプション管理 #subscription-management

サブスクリプションのキャンセル

ユーザーがアプリからキャンセルできるようにします(期間終了までアクセスは維持されます):

src/services/subscriptions.ts
import { polar } from '../lib/polar';

export async function cancelSubscription(subscriptionId: string) {
    await polar.subscriptions.update({
        id: subscriptionId,
        subscriptionUpdate: {
            cancelAtPeriodEnd: true,
        },
    });
}

顧客ポータル

Polarは、顧客が請求管理、支払い方法の更新、請求書の表示を行えるホストされたポータルを提供します:

src/services/subscriptions.ts
export async function getCustomerPortalUrl(polarCustomerId: string) {
    const session = await polar.customerSessions.create({
        customerId: polarCustomerId,
    });

    return session.customerPortalUrl;
}

Expressルーター:

src/routes/api.ts
router.post('/billing-portal', async (req, res) => {
    const user = req.user;

    if (!user.polarCustomerId) {
        return res.status(400).json({ error: 'No subscription found' });
    }

    try {
        const portalUrl = await getCustomerPortalUrl(user.polarCustomerId);
        res.json({ url: portalUrl });
    } catch (error) {
        console.error('Portal creation failed:', error);
        res.status(500).json({ error: 'Failed to create portal session' });
    }
});

よくある落とし穴 #common-pitfalls

本番環境でPolarを統合した後、私が目撃(そして犯した)間違いを紹介します。

1. Webhookより前にJSONミドルウェアを登録

シグネチャ検証エラーが発生する場合、原因のほとんどはこれです。生のボディがWebhookハンドラーに届く前にexpress.json()によってパースされてしまいます。

修正: express.json()の前にWebhookルーターを登録します。

2. すべてのサブスクリプション状態を処理しない

サブスクリプションはactivecanceledpast_dueincompletetrialingrevokedなどの状態になります。activeだけを確認するのではなく、ライフサイクル全体を処理してください。

3. メタデータの欠落

チェックアウトのメタデータにuserIdを渡すのを忘れた場合、Polarの顧客をユーザーと紐付けることができません。常に識別情報を含めてください。

4. イデムポテントでないWebhookハンドラー

PolarはWebhookを再送することがあります。ハンドラーはイデムポテント(冪等)である必要があります。同じイベントを2回処理しても、何も壊さないようにしてください。

// ダメ - 重複レコードを作成する
await db.payment.create({ data: { orderId: event.data.id, ... } });

// 良い - 一意のIDに基づいてUpsertする
await db.payment.upsert({
    where: { polarOrderId: event.data.id },
    create: { polarOrderId: event.data.id, ... },
    update: { ... },
});

5. Webhookレスポンスのブロッキング

Webhookエンドポイントは迅速に応答する必要があります。重い処理が必要な場合は、即座に確認応答し、非同期で処理します:

router.post('/polar', express.raw({ type: 'application/json' }), async (req, res) => {
    const event = verifyWebhook(req.body.toString(), req.headers);

    // 即座にレスポンスを返す
    res.status(200).json({ received: true });

    // 非同期で処理(本番では適切なジョブキューを使用)
    setImmediate(() => handlePolarEvent(event));
});

統合のテスト #testing-your-integration

サンドボックスモードの使用

Polarのサンドボックス環境では、実際の課金なしでフルフローをテストできます:

const polar = new Polar({
    accessToken: process.env.POLAR_ACCESS_TOKEN,
    server: 'sandbox', // テストモード
});

テストカード

サンドボックスでは、以下のテストカード番号を使用します:

  • 4242 4242 4242 4242 - 成功した支払い
  • 4000 0000 0000 0002 - カードが拒否された場合

ローカルWebhookのテスト

ngrokや類似のツールを使用してローカルサーバーを公開します:

ngrok http 3000

次に、PolarダッシュボードでWebhook URLを以下に設定します: https://your-ngrok-url.ngrok.io/api/webhooks/polar

セットアップの確認

本番公開前のチェックリスト:

  • チェックアウトが正常に作成され、Polarにリダイレクトする
  • 成功URLがアプリにリダイレクトする
  • Webhookシグネチャ検証がパスする
  • subscription.activeイベントがユーザーのアクセスを有効化する
  • 顧客ポータルが正常に読み込まれる
  • キャンセルが機能し、cancelAtPeriodEndが設定される

以上が、Polar.sh決済の完全な統合です。重要な洞察は、Polarが得意としていること(チェックアウトUI、決済処理、顧客ポータル)はPolarに任せて、コードはWebhook処理とユーザーの権利付与に集中することです。

詳細は、公式Polarドキュメントをご確認ください。

カテゴリー Tutorials
共有

最新のAIインサイトをあなたのインボックスにお届けします

最新のトレンド、チュートリアル、業界のインサイトを常に把握してください。当社のニュースレターを信頼する開発者のコミュニティに参加してください。

新規アカウントのみ。メールアドレスを送信することで、当社の プライバシーポリシー