mcpField
P
polar-integration

polar-integration

github.com/polarsource開発6257 回インストール1 日前に更新
リポジトリ

polar-integration について

Polar 決済を統合する開発者向けの、@polar-sh/sdk のガイドです。Checkout、Customer Portal、Webhooks を Web 標準で実装し、主要フレームワークでの接続方法も示します。

主な機能

  1. 01Checkout セッションの作成とリダイレクト
  2. 02Customer Portal セッションの作成
  3. 03Webhook イベントの検証と振り分け
  4. 04主要フレームワーク向けの実装例

活用シーン

  1. 01アプリへのホスト型チェックアウトの追加
  2. 02顧客によるサブスクリプションや支払い方法の管理
  3. 03決済・サブスクリプションイベントの処理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/polarsource/agent-toolkit --skill polar-integration

インストール後、Claude Code で/polar-integrationを入力して読み込まれたか確認します。

詳細

Polar SDK の統合

@polar-sh/sdk を直接使用して、Polar の主要な 3 つの HTTP エンドポイント(Checkout、Customer Portal、Webhooks)を接続するための独立したガイドです。各レシピはフレームワークに依存しない Web 標準(Request/Response)に基づいており、各セクションではフレームワークごとの簡単で慣用的な調整方法も紹介します。

セットアップ

SDK をインストールします。バージョン 1.0 以降には Node.js 22 以降が必要です。

npm install @polar-sh/sdk
# or pnpm / yarn / bun

プロジェクトで 1.0 未満の @polar-sh/sdk をすでに使用している場合(new Polar(...)、successUrl のようなキャメルケースのフィールド、@polar-sh/sdk/webhooks からのインポートが目印です)は、まず移行ガイドに従ってアップグレードしてください。旧方式と新方式を混在させないでください。

非推奨の @polar-sh/<framework> アダプター(@polar-sh/express、@polar-sh/hono、@polar-sh/fastify、@polar-sh/elysia、@polar-sh/astro、@polar-sh/sveltekit、@polar-sh/remix、@polar-sh/deno、@polar-sh/supabase)はインストールしないでください。@polar-sh/nextjs、@polar-sh/nuxt、@polar-sh/tanstack-start、@polar-sh/better-auth はメンテナンスされているため、プロジェクトですでに使用している場合はそのまま利用してください。

必要な環境変数(process.env、Deno.env、import.meta.env など、フレームワークが提供する任意のローダーを使用してください):

  • POLAR_ACCESS_TOKEN — Polar ダッシュボード(Settings → Developers)から取得する組織アクセストークンです。
  • POLAR_WEBHOOK_SECRET — Webhook のレシピでのみ必要です。
  • POLAR_ENVIRONMENT — 開発中は sandbox に設定します。本番環境では省略するか、production に設定します。Sandbox と本番環境は別々のため、それぞれに専用のトークンが必要です。

SDK は API バージョンごとにバージョン管理されており、インポートパスでバージョンが固定されます。プロジェクトですでに別のバージョンをインポートしている場合を除き、現行バージョンの 2026-10 を使用してください。サーバー専用のモジュールでクライアントを 1 つ作成し、再利用します。

import { createPolar, type Environment } from "@polar-sh/sdk/2026-10";

const accessToken = process.env.POLAR_ACCESS_TOKEN;
if (!accessToken) throw new Error("POLAR_ACCESS_TOKEN is required");

export const polar = createPolar({
  accessToken,
  environment: (process.env.POLAR_ENVIRONMENT as Environment | undefined) || "production",
});

以下のレシピでは、この polar クライアントをインポートします。各レシピは独立しているため、ユーザーから求められたものだけを実装してください。

規約
  • サービス名とメソッド名はキャメルケース(polar.checkouts.create、polar.customerSessions.create)です。すべてのリクエストとレスポンスのフィールドは、HTTP API と同じ snake_case です。
  • パスパラメーターは位置引数です(polar.checkouts.get(id))。リクエストボディは単一のオブジェクトです。
  • ページ分割されたリストは iter* メソッドから取得し、for await で処理します。

レシピ 1 — Checkout エンドポイント

目的: Polar のチェックアウトセッションを作成し、ホスト型のチェックアウトページにブラウザーを 302 リダイレクトする HTTP エンドポイントです。

基盤となる API: POST /v1/checkouts/(SDK:polar.checkouts.create(...))。完全なスキーマ:https://polar.sh/docs/api-reference/current/checkouts/create-checkout-session。

polar.checkouts.create() のフィールドリファレンス
フィールド必須備考
productsはい商品 UUID の配列です。デフォルトでは最初の商品が選択されます。
customer_idいいえ既存の Polar 顧客 ID です。
external_customer_idいいえ独自のユーザー ID です。一致する Polar 顧客がいない場合、この外部 ID で顧客が作成されます。
customer_emailいいえ
customer_nameいいえ
customer_billing_nameいいえ
customer_billing_addressいいえ{ country: "US", line1, line2, city, state, postal_code }。住所を設定する場合、country(ISO alpha-2)は必須です。
customer_tax_idいいえ
customer_ip_addressいいえ表示通貨の選択に使用されます。
customer_metadataいいえ作成された顧客にコピーされます。
is_business_customerいいえデフォルトは false です。true の場合、請求先住所の全項目と名前が必須です。
metadataいいえ作成された注文/サブスクリプションにコピーされます。
custom_field_dataいいえカスタムフィールドのスラッグ → 値のマップです。
discount_idいいえ
allow_discount_codesいいえデフォルトは true です。
require_billing_addressいいえデフォルトは false です。米国の顧客には常に必須です。
seatsいいえ事前に定義した座席数です(座席数ベースの価格設定のみ)。
min_seats / max_seatsいいえ座席数ベースの価格設定のみです。
units / min_units / max_unitsいいえユニット数ベースの価格設定のみです。
amountいいえセント単位です。custom(金額自由設定)価格でのみ使用されます。
subscription_idいいえ既存の無料サブスクリプションをアップグレードします。
success_urlいいえ下記を参照してください。
return_urlいいえチェックアウトページの戻るボタンで使用する URL です。
embed_originいいえチェックアウトを iframe に埋め込む場合に設定します。
localeいいえIETF BCP 47(en、en-US、fr-CA、...)です。デフォルトは en です。
currencyいいえISO 4217(例:usd、eur)です。
trial_interval / trial_interval_countいいえ商品のトライアル設定を上書きします。
allow_trialいいえデフォルトは true です。設定済みのトライアルを無効にするには false を設定します。
pricesいいえ商品 ID → アドホック価格のマップです(カタログ価格を上書きします)。

レスポンス: { id, url, client_secret, expires_at, status, ... }。ユーザーを url にリダイレクトします。代わりにチェックアウトを iframe に埋め込む場合は、url を @polar-sh/checkout の PolarEmbedCheckout.create に渡し、embed_origin を設定します。client_secret はクライアント側のチェックアウト呼び出しとカスタムチェックアウト UI 専用です。

特定のフィールドに関する注意事項
  • success_url と {CHECKOUT_ID}。 Polar は文字列をそのまま置換します。URL 内の {CHECKOUT_ID} トークンは、リダイレクト時に実際のセッション ID に置き換えられます。公式 API ドキュメントの例では ?checkout_id={CHECKOUT_ID} を使用していますが、任意のパラメーター名を使用できます。成功ページで polar.checkouts.get(checkoutId) を使ってセッションを取得する場合に便利です。リダイレクトを支払いの証明として扱わないでください。アクセス権は、検証済みの webhook または顧客の状態に基づいて付与してください。
  • theme(UI のヒントであり、API フィールドではありません)。 ホスト型チェックアウト UI は、URL 内の ?theme=light|dark を受け付けます。テーマを適用するには、セッション作成後に checkout.url へ追加します。
  • 顧客 ID は認証情報から取得します。 ユーザーがログインしている場合、customer_id または external_customer_id にはサーバー側セッションから取得した値を設定し、クエリ文字列から取得しないでください。この ID によって注文がその顧客に紐付けられます。
標準的な実装(Web 標準 — リクエスト/レスポンス)
import { polar } from "./polar";

const SUCCESS_URL = "https://example.com/success?checkout_id={CHECKOUT_ID}";
const THEME: "light" | "dark" | undefined = undefined;

export async function handleCheckout(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const products = url.searchParams.getAll("products");

  if (products.length === 0) {
    return Response.json({ error: "Missing products in query params" }, { status: 400 });
  }

  const ipAddress =
    request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
    request.headers.get("x-real-ip") ??
    request.headers.get("cf-connecting-ip") ??
    undefined;

  try {
    const checkout = await polar.checkouts.create({
      products,
      success_url: SUCCESS_URL,
      external_customer_id: await getUserId(request), // your auth helper, or undefined for guests
      customer_email: url.searchParams.get("customer_email") ?? undefined,
      customer_ip_address: ipAddress,
      discount_id: url.searchParams.get("discount_id") ?? undefined,
    });

    const redirectUrl = new URL(checkout.url);
    if (THEME) redirectUrl.searchParams.set("theme", THEME);

    return Response.redirect(redirectUrl.toString(), 302);
  } catch (error) {
    console.error(error);
    return new Response(null, { status: 500 });
  }
}

このハンドラーは ?products=<product_id> を含む GET リクエストを受け付けます。不要なフィールドを削除し、必要に応じて上記の表からフィールド(metadata、embed_origin、locale など)を追加してください。

フレームワークごとの違い

ハンドラーの本体は同じです。エントリーポイントだけが変わります。

  • Next.js(App Router): app/api/checkout/route.ts:export const GET = handleCheckout;

  • SvelteKit: src/routes/api/checkout/+server.ts:export const GET = ({ request }) => handleCheckout(request);

  • Astro: src/pages/api/checkout.ts:export const GET: APIRoute = ({ request }) => handleCheckout(request);

  • Remix / React Router: export const loader = ({ request }) => handleCheckout(request);

  • TanStack Start: ルートに server: { handlers: { GET: ({ request }) => handleCheckout(request) } } を設定します。

  • Hono: app.get("/checkout", (c) => handleCheckout(c.req.raw));

  • Elysia: app.get("/checkout", ({ request }) => handleCheckout(request));

  • Deno / Supabase Edge Functions: Deno.serve(handleCheckout);

  • Bun: Bun.serve({ fetch: handleCheckout });

  • Cloudflare Workers: export default { fetch: handleCheckout };(process.env の代わりに env 引数からシークレットを読み取ります)。

  • Nuxt(サーバールート): server/api/checkout.get.ts に defineEventHandler((event) => handleCheckout(toWebRequest(event))) を設定します。

  • Express:

    app.get("/checkout", async (req, res) => {
      const url = new URL(`${req.protocol}://${req.get("host")}${req.originalUrl}`);
      // ... same body using `url.searchParams`, then res.redirect(redirectUrl.toString()) ...
    });
    
  • Fastify:

    fastify.get("/checkout", async (request, reply) => {
      const url = new URL(`${request.protocol}://${request.hostname}${request.url}`);
      // ... same body, then reply.redirect(redirectUrl.toString()) ...
    });
    

レシピ 2 — 顧客ポータルのエンドポイント

目標: Polar の顧客セッションを作成し、ホストされた顧客ポータル(サブスクリプション、請求書、支払い方法)へ 302 リダイレクトする、認証済みエンドポイントです。

基盤となる API: POST /v1/customer-sessions/(SDK:polar.customerSessions.create(...))。完全なスキーマ:https://polar.sh/docs/api-reference/current/customer-sessions/create-customer-session。

polar.customerSessions.create() のフィールドリファレンス

リクエストボディはユニオン型です。customer_id または external_customer_id のどちらか一方を渡し、両方を渡さないでください。

フィールド必須備考
customer_id次のいずれか一方既存の Polar 顧客 UUID です。
external_customer_id次のいずれか一方独自のユーザー ID です。
return_urlいいえポータルページの戻るボタンの遷移先 URL です。
member_idいいえmember_model_enabled が有効な組織でのみ使用します。Polar 顧客メンバーの UUID です。個人顧客の場合は、デフォルトでオーナーメンバーが設定されます。
external_member_idいいえmember_id の代わりに指定する、独自のメンバー ID です。

レスポンス: { id, customer_portal_url, token, expires_at, customer, customer_id, return_url, ... }。ユーザーを customer_portal_url にリダイレクトします。リダイレクトせずに独自の UI 内でポータルデータを表示する場合は、token と customer が役立ちます。

標準的な実装
import { polar } from "./polar";

const RETURN_URL = "https://example.com/account";

export async function handlePortal(request: Request): Promise<Response> {
  const externalCustomerId = await getUserId(request); // your auth helper
  if (!externalCustomerId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    const session = await polar.customerSessions.create({
      external_customer_id: externalCustomerId,
      return_url: RETURN_URL,
    });
    return Response.redirect(session.customer_portal_url, 302);
  } catch (error) {
    console.error(error);
    return new Response(null, { status: 500 });
  }
}

独自のユーザー ID ではなく Polar の顧客 ID がある場合は、external_customer_id を customer_id に置き換えてください。通常は external_customer_id を使います。認証システムですでにユーザー ID が分かっているためです。呼び出し元が別の顧客の ID を選べるようにしてはいけません。

フレームワークごとの違い

レシピ 1 と同じエントリーポイントを使用します。認証状態は、各フレームワークで想定されている方法で読み取ります(Next.js:next/headers の await cookies()、Express:req.user、Hono:c.get("user") など)。


レシピ 3 — Webhook エンドポイント

目標: Polar の Webhook イベントを受け取り、署名を検証して、イベントごとのハンドラーに振り分けます。

仕様: Polar ダッシュボードで設定した固定 URL に POST エンドポイントを用意します。クライアントと同じバージョンのインポートから取得した webhooks.validateEvent で、webhook-id、webhook-timestamp、webhook-signature ヘッダーを検証します。ペイロードと SDK の型を一致させるため、そのバージョンはダッシュボードに設定した Webhook エンドポイントの API バージョンと同じにしてください。

正規の実装
import { webhooks } from "@polar-sh/sdk/2026-10";

const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) throw new Error("POLAR_WEBHOOK_SECRET is required");

export async function handleWebhook(request: Request): Promise<Response> {
  const body = await request.text(); // must be the raw body, not parsed JSON
  const headers = {
    "webhook-id": request.headers.get("webhook-id") ?? "",
    "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
    "webhook-signature": request.headers.get("webhook-signature") ?? "",
  };

  let event: webhooks.WebhookPayload;
  try {
    event = await webhooks.validateEvent(body, headers, WEBHOOK_SECRET!);
  } catch (error) {
    if (error instanceof webhooks.PolarWebhookVerificationError) {
      return Response.json({ received: false }, { status: 403 });
    }
    if (error instanceof webhooks.PolarWebhookUnknownTypeError) {
      // Signed, but an event type this SDK version doesn't know. Acknowledge it so Polar doesn't retry.
      return Response.json({ received: true }, { status: 202 });
    }
    if (error instanceof webhooks.PolarWebhookError) {
      return Response.json({ received: false }, { status: 400 });
    }
    throw error;
  }

  switch (event.type) {
    case "order.paid":
      // event.data is the Order — fulfill, send receipt, etc.
      break;

    case "subscription.active":
    case "subscription.canceled":
    case "subscription.uncanceled":
    case "subscription.revoked":
      // event.data is the Subscription — flip entitlements in your DB
      break;

    case "benefit_grant.created":
    case "benefit_grant.updated":
    case "benefit_grant.revoked":
      // grant or revoke the user's access to a benefit
      break;

    case "customer.state_changed":
      // event.data is the full customer state — reconcile access from granted_benefits
      break;
  }

  return Response.json({ received: true }, { status: 202 });
}

event は完全に型付けされているため、TypeScript は各 case 内で event.data の型を絞り込みます。その他のイベントには、checkout.created、checkout.updated、order.created、order.updated、order.refunded、subscription.created、subscription.updated、refund.created、refund.updated、product.created、product.updated、benefit.created、benefit.updated、customer.created、customer.updated、customer.deleted、organization.updated があります。エンドポイントには、処理するイベントのみを登録してください。

重要な詳細
  1. validateEvent にはリクエストボディの生データを使用してください。 フレームワークが JSON を解析している場合(Express の express.json() や Fastify のデフォルトのボディパーサーなど)、再シリアライズされた JSON では署名チェックに失敗します。
  2. 2 秒以内に応答してください。 Polar は 10 秒後にタイムアウトし、失敗した配信を最大 10 回再試行します。また、10 回連続で失敗するとエンドポイントを無効にします。イベントを保存するかキューに登録して 2xx を返し、時間のかかる処理(メール送信、プロビジョニング)はバックグラウンドジョブで行ってください。
  3. 冪等性。 Polar はイベントを再配信することがあります。webhook-id ヘッダーで重複を排除してください。この値は配信ごとに一意で、再試行時には再利用されます。event.data.id で重複を排除しないでください。これはリソース ID であり、同じリソースに関する異なるイベント(たとえば order.created と order.paid)で共有されます。
  4. ルートを公開状態にしてください。 Polar はリダイレクトをたどらないため、ログインページにリダイレクトする認証ミドルウェアの背後にあるルートでは、すべての配信が失敗します。署名が認証の役割を果たします。
ローカルでテストする

開発サーバーを起動した状態で、Polar CLI を使ってイベントを転送し、ひとつ送信します(polar-testing の「ローカル Webhook テスト」を参照してください)。

polar webhook listen 3000/api/polar/webhook   # keep it running in the background
polar webhook trigger order.paid              # exits 1 and prints the response when the handler fails

ローカルでは、POLAR_WEBHOOK_SECRET は polar webhook listen --print-secret で取得したシークレットです。

フレームワークごとの違い

Recipe 1 と同じエントリーポイントを POST で使用します(Remix では action)。さらに、次の点に注意してください。

  • Next.js Route Handler: app/api/polar/webhook/route.ts:export const POST = handleWebhook;。App Router はボディを解析しないため、request.text() が適切です。

  • Express: Webhook ルートは、app.use(express.json()) やその他のボディパーサーより前に express.raw を使って登録してください。先行するパーサーがストリームを読み取ると、署名対象のバイト列は失われます。Buffer をそのまま validateEvent に渡してください。

    app.post("/polar/webhook", express.raw({ type: "application/json" }), async (req, res, next) => {
      try {
        const event = await webhooks.validateEvent(req.body, {
          "webhook-id": req.header("webhook-id") ?? "",
          "webhook-timestamp": req.header("webhook-timestamp") ?? "",
          "webhook-signature": req.header("webhook-signature") ?? "",
        }, WEBHOOK_SECRET!);
        // ... handle event, catch the error classes as in the canonical handler ...
        res.sendStatus(202);
      } catch (error) {
        next(error);
      }
    });
    app.use(express.json());
    
  • Fastify: fastify-raw-body を登録し、ルートに config: { rawBody: true } を設定してから、request.rawBody を渡してください。

  • Hono: app.post("/polar/webhook", (c) => handleWebhook(c.req.raw));

  • Nuxt: ボディには h3 の await readRawBody(event) を、ヘッダーには getHeader(event, "webhook-id") を使用してください。

コメント