mcpField
P
paddle-customer-portal

paddle-customer-portal

github.com/paddlehq開発9103 回インストール4 日前に更新
リポジトリ

paddle-customer-portal について

Next.js 15 アプリに Paddle がホストする顧客向けセルフサービスポータルを追加するためのスキルです。認証済み Server Action による一時 URL の発行、安全確認、サブスクリプション別リンクの作成方法を開発者向けに説明します。

主な機能

  1. 01Next.js 15 Server Action の設定
  2. 02認証済みの顧客検索
  3. 03一時的なポータルセッション URL
  4. 04サブスクリプション別のディープリンク
  5. 05セキュリティとエラー処理の解説

活用シーン

  1. 01「サブスクリプション管理」ボタンの追加
  2. 02請求書や支払い方法の確認
  3. 03解約または支払い情報更新画面への直接リンク

インストール

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

$npx skills add https://github.com/paddlehq/paddle-agent-skills --skill paddle-customer-portal

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

詳細

Next.js から Paddle カスタマーポータルのセッション URL を発行する

このスキルを使う場面

請求書の確認、支払い方法の更新、サブスクリプションのキャンセルなど、カスタマーセルフサービスの UI を Paddle にホストしてもらいたい場合にこのスキルを使います。アプリの「サブスクリプションを管理」ボタンから、ユーザーをそのホストされた UI に遷移させるだけで済みます。このスキルでは、ユーザーを認証し、Paddle の顧客 ID を取得して、paddle.customerPortalSessions.create を呼び出し、リダイレクト先の URL を返す Next.js 15(App Router)の Server Action を扱います。

これは ポータル を利用する方法です。もう一つの方法は、Paddle API を直接使って独自の請求画面を構築することです。両者の違いについては、以下の「そもそもポータルを使うべきですか?」を参照してください。

そもそもポータルを使うべきですか?

Paddle カスタマーポータルは、ユーザーがサブスクリプションを管理できるようにするための選択肢の一つです。もう一つは、Paddle Node SDK と直接通信する独自の請求 UI を構築する方法です。それぞれに適した場面があります。

選択肢適した場面
Paddle カスタマーポータル(このスキル)「ユーザーがサブスクリプションをセルフサービスで管理できる」状態を最短で実現したい場合に適しています。Paddle が UI を提供し、ローカライズやレイアウト、機能追加に伴う更新にも対応します。Paddle Retain のキャンセルフローを設定している場合、顧客が自動徴収のサブスクリプションをキャンセルすると、ポータルにそのフローが表示されます。初期段階のプロダクト、MVP、または請求機能が差別化要因ではないプロダクトに適しています。
カスタム請求画面(subscription-cancel、subscription-update、および取引の一覧表示や請求書のダウンロードなどの直接 API 呼び出しを参照)ブランドを完全にコントロールしたい場合、アプリのデザインシステムと深く統合したい場合、カスタムフロー(アプリ内のアップグレード案内、キャンセルフローで独自のリテンション施策を実施する、請求書を独自に表示するなど)が必要な場合、または請求以外の UI と密接に組み合わせたい場合に適しています。Paddle Next.js starter kit はこの方法を採用しています。ダッシュボードではサブスクリプションと取引をネイティブに表示し、キャンセルや更新には Server Actions を使用しています。

組み合わせて使うこともできます。たとえば、長期的な管理操作(請求書のダウンロード、支払い方法の変更)にはポータルを使い、価値の高いフロー(アップグレードや、日割り計算された請求額を示す確認画面)にはカスタム UI を構築できます。セッション URL には特定のサブスクリプション操作へのディープリンクがあるため、ユーザーを概要画面に遷移させるのではなく、特定の操作を直接表示することもできます。

この選択は恒久的なものではありません。よくある進め方は、まずポータルを導入してカスタマーセルフサービスを実現し、その後、プロダクトにとって重要になったフローから段階的にカスタム UI へ置き換えていく方法です。

前提条件

  • 顧客レコードが少なくとも1件あるPaddleアカウント(サンドボックスでも構いません)。
  • サーバー側で利用可能なPADDLE_API_KEY — このアクションはServer Actionで実行し、ブラウザーでは実行しません。このキーにはcustomer_portal_session.write権限が必要です。
  • Webhookから同期されたcustomersテーブル(subscription-syncを参照)。セッションの発行には、ユーザーのPaddleのcustomer_idが必要です。メールアドレスを使って紐付け、検索します。
  • 任意でsubscriptionsテーブル — サブスクリプションごとの詳細リンクが必要な場合は、顧客が所有するサブスクリプションIDをSDKに渡します。
  • 認証システム。例ではSupabaseを使用していますが、セッションベースの認証であればどれでも機能します。
NEXT_PUBLIC_PADDLE_ENV=sandbox             # or "production"
PADDLE_API_KEY=pdl_sdbx_apikey_...

NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...              # or SUPABASE_SECRET_KEY (new opaque sb_secret_*)
Supabaseヘルパー

このアクションでは、サーバー側のSupabaseクライアントを2つ使用します。これらはPaddle Next.js starter kitに準拠しています。プロジェクトに相当するヘルパーがすでにある場合は、そちらを使用してください。

// src/utils/supabase/server.ts — the signed-in user's session (anon key + cookies)
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            );
          } catch {
            // Called from a Server Component. Safe to ignore if middleware refreshes sessions.
          }
        },
      },
    },
  );
}
// src/utils/supabase/server-internal.ts — service role, no user session. Server-only.
import { createServerClient } from "@supabase/ssr";

export async function createClient() {
  // No cookies: with the signed-in user's session attached, requests would run
  // as that user and row-level security (RLS) would still apply.
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { cookies: { getAll: () => [], setAll: () => {} } },
  );
}

サービスロールクライアントはRLSをバイパスするため、ユーザーの認証状態を確認した後、サーバー上でのみ使用してください。意図的にCookieを使用しないため、サインイン済みユーザーのセッションが設定されることはありません。独自のヘルパーを作成する場合、ラッパーにcreateServerClientという名前を付けないでください。これは呼び出し先の@supabase/ssrの関数名です。

ポータルセッションの仕組み

const session = await paddle.customerPortalSessions.create(
  customerId, // "ctm_01h..."
  subscriptionIds, // string[] — e.g. ["sub_01h...", "sub_02h..."], or [] for overview only
);

返されるsessionには、以下の情報が含まれます。

  • session.urls.general.overview — 「自分のポータルを開く」ためのメインURLです。 標準的なセルフサービスフローでユーザーをリダイレクトするときに使用します。ユーザーはポータルのホーム画面に移動し、そこから操作できます。
  • session.urls.subscriptions[] — 渡したsubscriptionIdごとに1件ずつ返される、サブスクリプション別の詳細リンクの配列です。
    • id — 入力したサブスクリプションIDと一致します。
    • cancelSubscription — そのサブスクリプションの解約画面への直接リンクです。
    • updateSubscriptionPaymentMethod — そのサブスクリプションの支払い方法画面への直接リンクです。
    • view_subscription(APIフィールド)— フォームを開かずにポータル内の該当サブスクリプションページを表示するリンクです。Node SDK(3.10.0)ではまだ公開されていないため、必要な場合はPOST /customers/{customer_id}/portal-sessionsを直接呼び出してください。

subscriptionIdsには最大25件の一意なIDを設定でき、各IDはその顧客に属している必要があります。そうでない場合、APIは400を返します。概要ページのみのリンクを使う場合は、空の配列を渡してください。ポータルではアカウントや請求書の閲覧は引き続き可能ですが、サブスクリプション別の詳細リンクは取得できません。

これらのURLは一時的で、期限が切れます。機密情報として扱ってください。リンクを持つ人は、期限が切れるまで顧客のポータルを開くことができます。キャッシュや保存はせず、ユーザーが「管理」をクリックするたびに新しいセッションを発行してください。

Server Actionの全体像

// src/actions/portal.ts
"use server";

import { ApiError } from "@paddle/paddle-node-sdk";
import { getPaddleInstance } from "@/utils/paddle/get-paddle-instance";
import { createClient } from "@/utils/supabase/server";
import { createClient as createAdminClient } from "@/utils/supabase/server-internal";

export async function createPortalSession() {
  // 1. Authenticate. Reject anonymous requests before any DB query or SDK call.
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user || !user.email) {
    return { error: "Not authenticated" };
  }

  // 2. Look up the authenticated user's Paddle customer_id via the email
  //    bridge. The customers table is the mirror your webhook handler
  //    populates from customer.created / customer.updated events.
  const admin = await createAdminClient();
  const { data: customerRow } = await admin
    .from("customers")
    .select("customer_id")
    .eq("email", user.email)
    .single();

  if (!customerRow?.customer_id) {
    // The user signed up but hasn't completed a checkout yet, so no Paddle
    // customer record exists. Don't pass an empty string to the SDK — it
    // will 400. Either redirect them to a "subscribe first" page or return
    // a clear error the UI can render.
    return { error: "No Paddle customer" };
  }

  // 3. Mint the session. Notice the customerId comes from step 2 (the
  //    authenticated user's record), NOT from any input parameter — a user
  //    must never be able to request a portal session for someone else's ID.
  //    This action only needs the overview URL, so it passes no subscription
  //    IDs. For deep links, pass up to 25 IDs this customer owns.
  try {
    const paddle = getPaddleInstance();
    const session = await paddle.customerPortalSessions.create(customerRow.customer_id, []);

    // 4. Return ONLY the URL. Don't leak the raw session object — it includes
    //    the customer_id, session id, and full deep-link table that the client
    //    doesn't need for a "redirect to portal" flow.
    return { url: session.urls.general.overview };
  } catch (error) {
    // 5. Turn Paddle API errors into a message the UI can show; rethrow anything else.
    if (error instanceof ApiError) {
      console.error("Portal session failed", error.code, error.detail);
      return { error: "Couldn't open the billing portal. Please try again." };
    }
    throw error;
  }
}

これを呼び出すクライアントコンポーネントは簡単です。

// src/components/manage-subscription-button.tsx
"use client";
import { createPortalSession } from "@/actions/portal";

export function ManageSubscriptionButton() {
  return (
    <button
      onClick={async () => {
        const result = await createPortalSession();
        if ("error" in result) {
          // surface the error to the user
          return;
        }
        window.location.href = result.url;
      }}
    >
      Manage subscription
    </button>
  );
}

セキュリティモデル

重要なのは次の3点です。

  1. 何よりも先に認証する。 DBクエリやSDK呼び出しの前に、アクションでセッションを確認する必要があります。ポータルURLは顧客の請求データにアクセスするための鍵です。未認証のリクエストに対して発行するのは重大なバグです。
  2. customerIdは入力値ではなく、認証済みユーザーから取得する。 アクションはcustomerIdパラメーターを受け取りません(また、どの顧客のポータルを作成するかに影響する、クライアントから渡された識別子も受け取りません)。customerIdは認証セッションを使って、サーバー側でcustomersテーブルから解決します。これにより、認証済みユーザーが「他人のctm_01hのポータルURL」を要求するのを防ぎます。
  3. URLだけを返す。 sessionオブジェクト全体には、customerId、セッションID、詳細リンクのテーブルが含まれます。クライアントが必要とするのはリダイレクトURLだけなので、それだけを返してください。

よくある落とし穴

  • 認証チェックを行わない。 Server Actions は匿名ユーザーやスクリプトからの直接 POST など、あらゆる場所から呼び出せます。認証がなければ、誰でもポータル URL を発行できます。
  • アクションの入力値 customerId を信頼する。 クライアントから customerId を渡し、アクションがその値をそのまま使うパターンは、どれもセキュリティ上の欠陥です。認証済みユーザーが、ID を知っている任意の顧客のポータルをリクエストできてしまいます。
  • URL をキャッシュする。 ポータルリンクは有効期限が切れるため、キャッシュされたリンクはいずれ機能しなくなります。それまでの間、保存されたリンクを持つ人なら誰でも顧客の請求情報を開けてしまいます。クリックごとに必ず新しいセッションを発行してください。
  • セッションオブジェクト全体を返す。 customer_id、セッションメタデータ、クライアントが必要としないサブスクリプションごとのディープリンクが漏えいします。urls.general.overview だけを返してください(埋め込み用にディープリンクが必要な場合も、特定の URL だけを返し、セッション全体は返さないでください)。
  • 顧客が存在しないケースに対応しない。 まだチェックアウトしていない新規ユーザーには、Paddle の customer_id がありません。空文字列や null を customerPortalSessions.create に渡すと、分かりにくいエラーを伴う 400 が返されます。顧客情報がないことを明示的に検出し、「先にサブスクリプションに登録してください」というメッセージを表示してください。
  • 確認していないサブスクリプション ID を渡す。 ID が 25 個を超える場合、重複がある場合、または別の顧客に属する ID が含まれる場合、リクエスト全体が 400 で失敗します。認証済み顧客の行にある ID だけを渡すか、概要リンクの場合は ID を渡さないでください。
  • 「ポータルを開く」と「ポータルから解約する」を混同する。 urls.general.overview はポータルの ホーム であり、ユーザーはポータルがサポートするあらゆる操作を行えます。ユーザーを 解約画面に直接 遷移させたい場合は(特定のサブスクリプションについて)、代わりに urls.subscriptions[i].cancelSubscription を使ってください。そのサブスクリプションのページをフォームを開かずに表示する場合は、view_subscription を使います。ほとんどのアプリでは概要リンクだけを使います。
  • カスタム画面を使うべき場面でポータルを使う、またはその逆。 ポータルを使うかカスタム画面を使うかの判断は、単なるコーディングの近道ではなく、実際のプロダクト上の判断です(上記の「そもそもポータルを使うべきですか?」を参照)。解約時にリテンション施策を行いたい場合は、Paddle Retain の解約フローを設定すれば、ポータルに表示できます(自動収集されたサブスクリプションのみ)。それを超えるリテンションロジックは、カスタムで構築してください。ブランドやフローに関する要件がなければ、必要になるまではポータルを使うのが適切です。

インテグレーションを検証する

  1. Paddle の顧客レコードがあるログイン済みユーザー(つまり、サンドボックスでのチェックアウトを完了したユーザー)として、「サブスクリプションを管理」をクリックします。アクションから { url: '<some https url>' } が返されるはずです。その URL にアクセスすると、ポータルのホームが開きます。
  2. クリックするたびに URL が異なることを確認します。urls.general.overview の値が毎回変わるはずです。変わらない場合は、セッションをキャッシュしています。
  3. ログアウトした状態でアクションを試します。{ error: 'Not authenticated' } が返され、SDK が呼び出されていないことを確認します。
  4. まったく新しいユーザー(チェックアウト未完了で、customers に行がないユーザー)としてアクションを試します。{ error: 'No Paddle customer' } が返され、SDK が呼び出されていないことを確認します。
  5. (アクションに入力パラメーターを追加した場合は(追加しないでください。ただし、何らかの理由で追加した場合は)、)認証済みユーザーが所有していない顧客のポータル URL をアクションが発行できないことを確認します。
  6. ディープリンク用にサブスクリプション ID を渡す場合は、customerPortalSessions.create のレスポンスを直接確認します。session.urls.subscriptions に渡した各 ID の項目が含まれ、別の場所でディープリンクとして使用できる cancelSubscription URL と updateSubscriptionPaymentMethod URL があることを確認します。

関連ドキュメント

コメント