mcpField
P
paddle-subscription-sync

paddle-subscription-sync

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

paddle-subscription-sync について

paddle-subscription-syncは、Paddleの顧客・サブスクリプション状態をアプリのデータベースに同期する開発者向けスキルです。イベント処理、安全な更新、状態の意味、予約変更、既存データの初期投入を扱います。

主な機能

  1. 01顧客・サブスクリプション表の設計
  2. 02イベント時刻に基づく状態更新
  3. 03アプリユーザーとPaddle顧客の紐付け
  4. 04状態とアクセス判定の説明
  5. 05予約済み解約・一時停止への対応
  6. 06初期データ投入の案内

活用シーン

  1. 01サブスクリプション状態に応じた機能制限
  2. 02アカウントページでの状態表示
  3. 03順不同・重複Webhookへの対応
  4. 04既存Paddleデータのミラー初期化

MCP 対応

初期データ投入ではPaddle MCPサーバーによるページネーションを案内します。状態同期自体は、検証済みWebhookとデータベースを基盤とします。

インストール

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

$npx skills add https://github.com/paddlehq/paddle-agent-skills --skill paddle-subscription-sync

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

詳細

Paddle のサブスクリプション状態をデータベースに同期する

このスキルを使う場面

Paddle のサブスクリプションと顧客の状態をローカルに保持する必要がある場合に、このスキルを使います。通常は、サブスクリプションの状態に応じた機能制限、Paddle API を呼び出さずにアカウントページを表示すること、売上のレポート作成などが該当します。このスキルでは、テーブルスキーマ、購読するイベント、アップサートのパターン、アプリのユーザーと Paddle の顧客のマッピング方法、ステータスの意味、予定された変更(期間終了時に有効になる一時停止やキャンセル)の処理方法を定義します。

このスキルでは、検証済みのウェブフックをすでに受信できることを前提としています。まだ受信できない場合は、先に webhooks を実行してください。このスキルでは、ルートハンドラーと署名検証について説明しています。ここに記載するコードは、そのスキルで作成するイベントハンドラー関数内に置きます。

前提条件

  • 署名検証を行うウェブフックエンドポイントが動作していること(webhooks)。
  • データベース(例では Supabase を使用しますが、UPSERT が使える SQL ストアならどれでも構いません)。
  • 通知先で次のイベントを購読していること:
    • customer.created、customer.updated
    • subscription.created、subscription.updated、subscription.canceled
    • 任意:単発購入の場合は transaction.completed、トライアルから移行する場合は subscription.activated
  • バックフィルと照合ジョブ用の API キーに、customer.read と subscription.read の権限があること。ウェブフックハンドラー自体は API を呼び出しません。

状態をミラーリングする理由

「このユーザーは Pro プランを利用中か?」を確認するたびに Paddle API を呼び出すこともできますが、自分の DB を確認するより遅くなります。

ミラーリングでは、ウェブフックを DB 更新の信頼できる情報源とし、アプリは自分の DB を読み取り、API は即時の応答が必要な書き込み操作(キャンセル、更新、返金)に限って使います。

スキーマ

必要最小限のスキーマを SQL で示します。

-- Maps a Paddle customer to your app user (email is the fallback bridge).
CREATE TABLE customers (
  customer_id TEXT PRIMARY KEY,        -- "ctm_01h..."
  email TEXT NOT NULL,
  last_event_at TIMESTAMPTZ,           -- occurredAt of the newest event applied
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX customers_email_idx ON customers(email);

CREATE TABLE subscriptions (
  subscription_id TEXT PRIMARY KEY,    -- "sub_01h..."
  customer_id TEXT NOT NULL REFERENCES customers(customer_id),
  user_id TEXT,                        -- your app's user ID, from checkout customData
  subscription_status TEXT NOT NULL,   -- see "Status semantics" below
  price_id TEXT NOT NULL,
  product_id TEXT NOT NULL,
  scheduled_change_action TEXT,        -- 'cancel' | 'pause' | 'resume'; null if nothing is scheduled
  scheduled_change_effective_at TIMESTAMPTZ,
  paddle_created_at TIMESTAMPTZ NOT NULL, -- Paddle's created_at, for ordering
  last_event_at TIMESTAMPTZ,           -- occurredAt of the newest event applied
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX subscriptions_customer_id_idx ON subscriptions(customer_id);
CREATE INDEX subscriptions_user_id_idx ON subscriptions(user_id);
CREATE INDEX subscriptions_status_idx ON subscriptions(subscription_status);

このスキーマは意図的に簡素にしています。必要に応じてカラムを追加してください(例:複数アイテムのサブスクリプションの場合は currency_code、next_billed_at、discount_id、JSONB 型の items)。Paddle のデータははるかに豊富ですが、UI で実際にクエリするものだけをミラーリングしてください。

Paddle はイベントの配信順序を保証しません。リトライや再生されたイベントが、新しいイベントの後に届くことがあります。そのため、書き込みには 2 つの関数を使い、1 つのアトミックなステートメント内で行を挿入するか、受信したイベントが last_event_at より新しい場合に限り行を更新します。

CREATE OR REPLACE FUNCTION upsert_subscription(
  p_subscription_id TEXT, p_customer_id TEXT, p_user_id TEXT, p_status TEXT,
  p_price_id TEXT, p_product_id TEXT, p_scheduled_change_action TEXT,
  p_scheduled_change_effective_at TIMESTAMPTZ, p_paddle_created_at TIMESTAMPTZ,
  p_last_event_at TIMESTAMPTZ
) RETURNS void LANGUAGE sql AS $$
  INSERT INTO subscriptions AS s (
    subscription_id, customer_id, user_id, subscription_status, price_id, product_id,
    scheduled_change_action, scheduled_change_effective_at, paddle_created_at, last_event_at
  ) VALUES (
    p_subscription_id, p_customer_id, p_user_id, p_status, p_price_id, p_product_id,
    p_scheduled_change_action, p_scheduled_change_effective_at, p_paddle_created_at, p_last_event_at
  )
  ON CONFLICT (subscription_id) DO UPDATE SET
    customer_id = EXCLUDED.customer_id,
    user_id = COALESCE(EXCLUDED.user_id, s.user_id),
    subscription_status = EXCLUDED.subscription_status,
    price_id = EXCLUDED.price_id,
    product_id = EXCLUDED.product_id,
    scheduled_change_action = EXCLUDED.scheduled_change_action,
    scheduled_change_effective_at = EXCLUDED.scheduled_change_effective_at,
    paddle_created_at = EXCLUDED.paddle_created_at,
    last_event_at = EXCLUDED.last_event_at,
    updated_at = NOW()
  WHERE s.last_event_at IS NULL OR s.last_event_at < EXCLUDED.last_event_at;
$$;

CREATE OR REPLACE FUNCTION upsert_customer(
  p_customer_id TEXT, p_email TEXT, p_last_event_at TIMESTAMPTZ
) RETURNS void LANGUAGE sql AS $$
  INSERT INTO customers AS c (customer_id, email, last_event_at)
  VALUES (p_customer_id, p_email, p_last_event_at)
  ON CONFLICT (customer_id) DO UPDATE SET
    email = EXCLUDED.email,
    last_event_at = EXCLUDED.last_event_at,
    updated_at = NOW()
  WHERE c.last_event_at IS NULL OR c.last_event_at < EXCLUDED.last_event_at;
$$;

これらの関数は呼び出し元の権限で実行されるため、行レベルセキュリティは引き続き適用されます。サービスロールクライアントで呼び出してください。IS NULL のチェックにより、last_event_at の追加前から存在していた行にも最初の書き込みを反映できます。Supabase 以外では、同じ INSERT … ON CONFLICT … DO UPDATE … WHERE ステートメントを直接実行してください。

Supabase クライアント

例では、Paddle Next.js スターターキットに合わせて、サーバー側の Supabase ヘルパーを 2 つ使います。アプリに同等のものがすでにある場合は、それらを使ってください。@/utils/supabase/server の createClient は、サインイン中のユーザーとして読み取りを行います(anon キー、ユーザーセッションの Cookie)。

// utils/supabase/server.ts
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: () => cookieStore.getAll(),
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            );
          } catch {
            // Called from a Server Component; middleware refreshes the session instead.
          }
        },
      },
    },
  );
}
// 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: () => {} } },
  );
}

import { createClient as createAdminClient } from "@/utils/supabase/server-internal" としてインポートし、サーバー上でのみ使用してください。使用するのは、ウェブフックハンドラー内、またはユーザーを確認した後の Server Action 内です。この関数は意図的に Cookie をスキップするため、サインイン中のユーザーのセッションを取得することはありません。独自のラッパーを作成する場合は、@supabase/ssr のインポートと名前が衝突するため、createServerClient という名前は使わないでください。

アプリのユーザーとPaddleの顧客を紐付ける

最も確実な紐付け方法は、チェックアウト時に独自のユーザーIDを渡すことです。

  • customData: { userId: user.id } を指定してチェックアウトを開始します(checkout-web を参照)。
  • Paddleはチェックアウトのカスタムデータをトランザクションにコピーし、そこから作成されたサブスクリプションにもコピーします。subscription.created には data.customData.userId が含まれます。
  • 以下のハンドラーは、その値を subscriptions.user_id に保存します。

このカスタムデータを指定せずに作成されたサブスクリプションに備えて、メールアドレスもフォールバックとして使用します。

  • アプリのユーザーテーブルにはメールアドレスがあります。
  • Paddleの顧客レコードにもメールアドレスがあります。
  • ユーザーが請求ページを開いたときに、メールアドレスで照合します。

customers.email のミラーにより、API呼び出しを行わずに、認証済みユーザーからPaddleの customer_id を検索できます。

// utils/paddle/get-customer-id.ts
import { createClient } from "@/utils/supabase/server";

export async function getCustomerId(): Promise<string> {
  const supabase = await createClient();
  const { data: userData } = await supabase.auth.getUser();
  const email = userData?.user?.email;
  if (!email) return "";

  const { data } = await supabase
    .from("customers")
    .select("customer_id")
    .eq("email", email)
    .single();

  return data?.customer_id ?? "";
}

ユーザーがアプリに登録した後でチェックアウトする場合(まだPaddleの顧客がいない場合)、getCustomerId は '' を返します。初回のチェックアウト後に customer.created ウェブフックが発火し、ハンドラーが行をアップサートするため、それ以降の呼び出しではユーザーが見つかります。

サブスクリプションのイベントを処理する

webhooks のルートハンドラー内で、型付きハンドラーに振り分けます。

// utils/paddle/process-webhook.ts
import {
  EventName,
  type Customer,
  type CustomerCreatedEvent,
  type CustomerUpdatedEvent,
  type EventEntity,
  type Subscription,
  type SubscriptionCanceledEvent,
  type SubscriptionCreatedEvent,
  type SubscriptionUpdatedEvent,
} from "@paddle/paddle-node-sdk";
import { createClient as createAdminClient } from "@/utils/supabase/server-internal";

type SubscriptionEvent =
  | SubscriptionCreatedEvent
  | SubscriptionUpdatedEvent
  | SubscriptionCanceledEvent;
type CustomerEvent = CustomerCreatedEvent | CustomerUpdatedEvent;

export async function processEvent(event: EventEntity) {
  switch (event.eventType) {
    case EventName.SubscriptionCreated:
    case EventName.SubscriptionUpdated:
    case EventName.SubscriptionCanceled:
      return saveSubscription(event.data, event.occurredAt);
    case EventName.CustomerCreated:
    case EventName.CustomerUpdated:
      return saveCustomer(event.data, event.occurredAt);
  }
}

// eventAt is the event's occurredAt (or the entity's updatedAt when reconciling).
// The SQL function ignores the write if the row already has a newer one.
export async function saveSubscription(
  sub: SubscriptionEvent["data"] | Subscription,
  eventAt: string,
) {
  const admin = await createAdminClient();
  const userId = sub.customData?.userId;

  const { error } = await admin.rpc("upsert_subscription", {
    p_subscription_id: sub.id,
    p_customer_id: sub.customerId,
    p_user_id: typeof userId === "string" ? userId : null,
    p_status: sub.status,
    p_price_id: sub.items[0]?.price?.id ?? "",
    p_product_id: sub.items[0]?.price?.productId ?? "",
    p_scheduled_change_action: sub.scheduledChange?.action ?? null,
    p_scheduled_change_effective_at: sub.scheduledChange?.effectiveAt ?? null,
    p_paddle_created_at: sub.createdAt,
    p_last_event_at: eventAt,
  });
  if (error) throw error;
}

export async function saveCustomer(customer: CustomerEvent["data"] | Customer, eventAt: string) {
  const admin = await createAdminClient();
  const { error } = await admin.rpc("upsert_customer", {
    p_customer_id: customer.id,
    p_email: customer.email,
    p_last_event_at: eventAt,
  });
  if (error) throw error;
}

注意点:

  • ウェブフックハンドラーでは、匿名キーではなくサービスロールキーを使うSupabaseクライアント(createAdminClient)を使用する必要があります。ウェブフックはログインユーザーとして実行されず、RLSポリシーによって匿名キーからの書き込みがブロックされるためです。サービスロールクライアントは専用のファイル(server-internal.ts)に置き、クライアントコードからインポートされないようにしてください。
  • イベントの順序:再試行されたイベントや、再送された古いイベントが、新しいイベントの後に届くことがあります。関数は occurredAt が last_event_at より新しい場合にのみ書き込むため、遅延した配信や重複した配信によって変更が生じることはありません。メール送信など、状態の書き込み以外の副作用については、引き続き event.eventId で重複を排除してください(webhooks を参照)。
  • ハイブリッドサブスクリプションでは、items 配列に複数の明細項目が含まれることがあります。この例では最初の項目のみを保存しています。複数項目のサブスクリプションでは、配列全体をJSONBとして保存してください。

ステータスの意味

アクセス制御のロジックでは、subscription.status の値を参照してください。

ステータス意味ユーザーにアクセス権を付与すべきか?
active支払い済みで、有効です。はい
trialingトライアル期間中です(無料または有料)。はい
past_due支払い期限を過ぎています。自動回収の場合:Paddleが再試行します(督促)。手動回収(請求書)の場合:再試行されません。はい。バナーを表示
pausedサブスクリプションは一時停止中です。請求もサービスもありません。いいえ(通常)
canceledキャンセルが適用されています。いいえ

canceled は終了状態です。サブスクリプションが実際に終了したことを意味します。ユーザーがキャンセルしても、請求期間がまだ終了していない場合、ステータスは変わらず(active、trialing、または past_due のまま)、scheduled_change_action は cancel になります。ステータスが canceled に変わるのは、scheduled_change_effective_at に達したときだけです。

妥当なアクセス制御の例:

function hasActiveSubscription(sub: Subscription | null): boolean {
  if (!sub) return false;
  return ["active", "trialing", "past_due"].includes(sub.subscription_status);
}

past_due の期間中はアクセスを維持しつつ、支払い方法を更新するようユーザーに促すバナーを表示してください(手動回収の場合は、請求書の支払いを促します)。

予約済みの変更の処理

ユーザーが期間の途中でキャンセルする場合、または一時停止を予約する場合、Paddle は次の内容を含む subscription.updated イベントを送信します。

{
  "data": {
    "id": "sub_01h...",
    "status": "active",
    "scheduled_change": {
      "action": "cancel",
      "effective_at": "2026-05-12T00:00:00Z"
    }
  }
}

ステータスは変わりません(ここでは active です。trialing または past_due の場合もあります)。effective_at まではサービスを利用できます。その後、Paddle はステータスを canceled(または paused)に変更し、scheduled_change をクリアする別のイベントを送信します。再開日が設定された一時停止中のサブスクリプションには、resume の予約済み変更があります。

scheduled_change.action と scheduled_change.effective_at は、それぞれ scheduled_change_action と scheduled_change_effective_at として保存します。UI では、アクションに応じてメッセージを決めます。たとえば、「登録する」ではなく、「サブスクリプションは5月12日にキャンセルされます」と表示します。一般的なパターンは次のとおりです。

function getSubscriptionUiState(sub: Subscription | null) {
  if (!sub) return "no-subscription";
  switch (sub.scheduled_change_action) {
    case "cancel":
      return "cancel-scheduled";
    case "pause":
      return "pause-scheduled";
    case "resume":
      return "resume-scheduled";
    default:
      return sub.subscription_status; // 'active' | 'trialing' | 'past_due' | 'paused' | 'canceled'
  }
}

ミラーリングした状態のクエリ

ミラーリング後は、API を呼び出さずに DB からすべてを読み取れます。

// utils/db/get-user-subscription.ts
"use server";

import { createClient } from "@/utils/supabase/server";
import { getCustomerId } from "@/utils/paddle/get-customer-id";

export async function getUserSubscription() {
  const supabase = await createClient();
  const { data: userData } = await supabase.auth.getUser();
  const userId = userData?.user?.id;
  if (!userId) return null;

  // Match the user ID from checkout customData, or fall back to the email-matched customer.
  const customerId = await getCustomerId();
  const match = customerId
    ? `user_id.eq.${userId},customer_id.eq.${customerId}`
    : `user_id.eq.${userId}`;

  const { data } = await supabase
    .from("subscriptions")
    .select("*")
    .or(match)
    .order("paddle_created_at", { ascending: false })
    .limit(1)
    .maybeSingle();

  return data;
}

このクエリは、Paddle の created_at が最も新しいサブスクリプションを返します。独自の created_at で並べ替えないでください。これは行が書き込まれた時刻であり、バックフィルや順不同のイベントによって順序が乱れるためです。ユーザーが複数のサブスクリプションを保持できる場合は、subscription_status で絞り込む(例:.in("subscription_status", ["active", "trialing", "past_due"]))か、すべて返します。

Paddle API は、キャンセル、一時停止、再開、アップグレード、返金などの変更操作にのみ使用します。読み取りには DB を使用してください。

初回バックフィル

既存の Paddle データがあるアプリにサブスクリプション同期を追加する場合、ミラーを信頼する前にバックフィルが必要です。

Paddle MCP はサーバーごとに 3 つのツール(search、execute、report_missing_tool)を公開します。ワークフロー:search を呼び出して正確なメソッド名とパラメーターの形式を確認し、次に execute を呼び出して、client.<resource>.<operation>(...) を呼ぶ非同期関数を実行します。メソッドパスは camelCase です(client.clientTokens.create、client.pricingPreview.preview)。本文パラメーターとレスポンスフィールドは snake_case です(tax_category、product_id、unit_price、currency_code)。リストメソッドは data ではなく、{ pagination: { hasMore, estimatedTotal }, <resource>: [...] } を返します。配列のキーはリソースの API パスセグメントであり、メソッドパスが camelCase の場合は kebab-case になります:(await client.products.list()).products、(await client.clientTokens.list())["client-tokens"]。pagination.hasMore が true の間は、.next() ではなく { after: "<last_id>" } を使ってページングします。client.currencies.list() のようにページングされないリストは、プレーンな配列を返します。単一エンティティのメソッドはエンティティを返します。複数ステップのワークフローは 1 回の execute 内で連結してください。呼び出し間で変数は保持されません。上限は、1 回の execute あたり API 呼び出し 50 回、タイムアウト 30 秒、コード 32,000 文字です。

Paddle MCP サーバーを利用できる場合は、1 回の execute 呼び出し内で明示的にページングします:

async (client) => {
  const customers = [];
  let after;
  do {
    const page = await client.customers.list({
      after,
      per_page: 200,
      status: ["active", "archived"],
    });
    customers.push(...page.customers);
    after = page.pagination.hasMore ? page.customers.at(-1).id : undefined;
  } while (after);
  return customers;
};

顧客の一覧取得ではデフォルトで active の顧客のみが返されます。古いサブスクリプションでも顧客の行を取得できるよう、archived も含めてください。client.subscriptions.list() も同様にページングします(page.subscriptions)。デフォルトですべてのステータスが返されます。

ここでは 50 回の呼び出し上限が重要です。 per_page: 200 の場合、1 回の execute 呼び出しあたり約 10,000 件の顧客が上限です。ユーザーのアカウントが 1 回の execute に収まる場合は問題ありません。収まらない場合は、複数回の execute 呼び出しに分割し、毎回、最後に確認した ID を開始位置の after カーソルとして渡すか、この上限のない以下の SDK ジョブを使用してください。

それ以外の場合(または大規模なバックフィルの場合)は、SDK を使用します。同じ saveCustomer/saveSubscription ヘルパーを通じて書き込むことで、順序ガードが維持されます。各エンティティの updatedAt がイベント時刻として使われるため、このジョブが新しい Webhook の書き込みを上書きすることはありません。たとえば cron で起動する Route Handler として、ヘルパーを再利用できるよう Next.js アプリ内で実行します:

// app/api/cron/reconcile-paddle/route.ts
import { getPaddleInstance } from "@/utils/paddle/get-paddle-instance";
import { saveCustomer, saveSubscription } from "@/utils/paddle/process-webhook";

export async function GET(request: Request) {
  const secret = process.env.CRON_SECRET;
  if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  const paddle = getPaddleInstance();
  for await (const customer of paddle.customers.list({ status: ["active", "archived"] })) {
    await saveCustomer(customer, customer.updatedAt);
  }
  for await (const sub of paddle.subscriptions.list()) {
    await saveSubscription(sub, sub.updatedAt);
  }
  return Response.json({ ok: true });
}

アクセス制御の読み取り元を DB に切り替える前に、一度実行してください。その後も、見逃した Webhook によって古いままになったデータを修復する照合作業として、定期的に(たとえばプラットフォームの cron から毎晩)実行し続けます。大規模なアカウントでは、プラットフォームの関数実行時間制限内に収まるよう、処理を分割してください。

よくある落とし穴

  • Webhook ハンドラーで Supabase の anon キーを使用する。 RLS ポリシーによって書き込みがブロックされます。サーバー内部のクライアント(createAdminClient)でサービスロールキーを使用してください。
  • チェックアウト前にユーザーを customer_id で紐付ける。 新しいアプリユーザーには、チェックアウトを完了するまで Paddle の顧客がいません。ユーザー ID をチェックアウトの customData に渡し、メールアドレスでの照合にフォールバックしてください。
  • canceled を「キャンセルボタンを押しただけ」と扱う。 これは終端状態です。ユーザーがキャンセルを押したときのイベントは、ユーザーによる即時キャンセルを許可している場合を除き、scheduled_change.action: 'cancel' が null 以外の値である subscription.updated です。ステータスが canceled に変わるまではアクセスを取り消さないでください。
  • 古い行からステータスを読み取る。 ハンドラーをキューに入れている場合(遅い処理ではそうするべきです)、DB 行が更新されるのはキューワーカーの実行後です。Webhook の受信から行の更新までの遅延は通常 1 秒未満ですが、ルートハンドラー内で読み取り後の書き込みを反映する必要がある場合は、upsert を同期的に実行してください。
  • subscription.items の 1 項目だけを保存する。 ハイブリッドプラン(基本サブスクリプション+アドオン)を販売する場合は、配列全体を保存してください。そうしないと、UI には最初の項目しか表示されません。
  • 読み取り先を DB に切り替える前にバックフィルしない。 既存の顧客は次の Webhook まで「サブスクリプションなし」と表示されます。その Webhook は、顧客がキャンセルしたときに届くかもしれません。必ず先にバックフィルしてから、定期的に照合を続けてください。
  • customer.updated を忘れる。 顧客が Paddle でメールアドレスを変更すると、新しいメールアドレスを同期するまで getCustomerId で顧客を検索できなくなります。最初から customer.updated を購読してください。
  • イベントの順序が前後する。 Paddle は配信順序を保証しません。subscription.updated が subscription.created より先に届くことがあり、再試行または再生された古いイベントが新しいイベントの後に届くこともあります。単純な UPSERT では、古いイベントによって新しい状態が上書きされます。上記の last_event_at ガードを通して書き込み、event.eventId で副作用を重複排除してください。

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

  1. チェックアウトフローでサンドボックスのサブスクリプションを作成し、ユーザー ID を customData に渡します。
  2. customers テーブルと subscriptions テーブルを確認します。どちらにも checkout.completed から 1 秒以内に新しい行が作成され、サブスクリプションの user_id が設定されているはずです。
  3. カスタマーポータルからサブスクリプションをキャンセルします。
  4. scheduled_change_action: 'cancel' が設定され、scheduled_change_effective_at に値が設定された状態で、subscriptions 行のステータス(active、またはトライアル中は trialing)が維持されていることを確認します。
  5. サンドボックスにはテストクロックがないため、期間が終了するのを待たないでください。effective_from: "immediately" を指定してサンドボックスのサブスクリプションをキャンセルする(subscription-cancel を参照)か、シミュレーターの subscription_cancellation シナリオを実行してください(sandbox-testing を参照)。デフォルトでは即時に有効になります。シミュレーターが送信するのは Webhook のみなので、ペイロードに実際の ID を含めるには、サンドボックスのサブスクリプション ID を入力し、Usage type が Simulation または Both の宛先を使用してください。行のステータスが canceled に変わり、スケジュールされた変更の列がクリアされることを確認します。
  6. 同じ方法で subscription_pause シナリオを実行し、UI が paused を適切に処理することを確認します。

関連ドキュメント

コメント