Use Cases (更新: 2026/6/6)

Stripeサブスクで二重課金を出した僕が学んだWebhook冪等性

成功画面で権限を開放して事故った話から、Checkout・Webhook署名検証・冪等性・解約処理まで。Claude Codeで課金事故を防ぐ実装を実コードで解説。

Stripeサブスクで二重課金を出した僕が学んだWebhook冪等性

リリース初日、Slackに「課金が二重に来てます」と通知が飛んできました。

調べたら、同じユーザーに権限が2回付与され、Stripe側のWebhookも同じイベントを3回投げていた。原因は、僕がCheckoutの成功画面に戻ってきたタイミングで権限を開放していたこと、そしてWebhookの重複配信をまったく考えていなかったことでした。

「Checkout Sessionを作ってリダイレクトすれば動く」——たしかに動きます。デモなら。でも本番の課金は、決済ボタンを置いた瞬間がスタートで、支払い失敗・プラン変更・解約予約・Webookの再送・権限剥奪まで決めて、やっと売上より問い合わせが少ない状態になる。今日はその全部を、僕が事故った順番で書きます。

この記事の要点

  • サブスク実装の本体は決済ボタンではなく、Stripeの状態をアプリの「使える機能」に正しく同期させること。
  • 権限を開放する根拠は成功画面のリダイレクトではなく、署名検証したWebhook(または再取得した最新の状態)にする。
  • Webhookは平気で重複・順序入れ替えで届く。イベントIDで冪等化し、迷ったらSubscriptionを再取得する。
  • activeは開放、past_dueは猶予、unpaid/canceledは停止——という状態表を先に固定すると、コードもサポート文面もブレない。
  • Claude Codeには「Stripe実装して」ではなく、公式ドキュメントのURL+状態表+テスト手順を渡すと事故が激減する。

サブスクは「決済ボタン」ではなく収益の運用設計

冒頭の二重課金で痛感したのは、サブスクの難所はCheckoutじゃないということです。Checkoutは一番簡単な部分。本当に決めることが多いのは、その後ろにある「お金の状態」と「機能の開放/停止」をどう一致させ続けるか、です。

支払い失敗、プラン変更、解約予約、請求書、領収書、税、顧客ポータル、Webhookの再送、権限の剥奪。ここを設計しないまま公開すると、売上が立つより先にサポートが燃えます。僕の場合は二重課金の問い合わせ対応で初日が終わりました。

なので考え方を最初に分けておきます。**Stripeが請求の正本(本当の事実)**で、アプリ側のDBは「このユーザーが今どの機能を使えるか」を判断するための同期先。この境界を曖昧にすると、どっちを信じればいいか分からなくなって事故ります。

この記事では、Claude Codeを「コードを書くAI」ではなく、実装の抜け漏れを減らすための足場(ハーネス)として使います。足場とは、要件・公式ドキュメント・DB設計・テストコマンド・レビュー観点をまとめて、Claude Codeが迷わない状態を先に作っておくこと。AIにコードを吐かせる前に、人間が決めるべき設計を渡しておく、という意味です。

対象は、月額課金のミニSaaS、会員制教材、テンプレート販売、導入コンサルの申込導線あたり。全体の流れはこうなります。

flowchart LR
  A["Pricing page"] --> B["Checkout Session<br/>mode=subscription"]
  B --> C["Stripe subscription"]
  C --> D["Webhook endpoint"]
  D --> E["billing_subscriptions"]
  D --> F["entitlements"]
  F --> G["App feature gate"]
  G --> H["Product, course, SaaS dashboard"]
  H --> I["Customer Portal"]
  I --> C

左から右に「申込→Stripe→Webhook→DB同期→機能開放」と流れ、ポータルからまたStripeに戻る輪になっているのがポイントです。

料金とプランを先に決める(コードより前に)

Claude Codeにいきなり「Stripeを実装して」と頼むと、コードは出ます。出るんですが、収益設計が空っぽのまま進むので、あとから「Studioプランってチーム席いくつ?」みたいな手戻りが必ず発生します。僕は最初これをやって、PLANS定義を3回書き直しました。

先に次の表を埋めてから実装に入ると、この手戻りが消えます。

プラン想定読者月額例年額例付与する権限
Free記事を試す読者0円0円無料記事、サンプル
Pro個人開発者、教材購入者1,980円19,800円全記事、テンプレート、限定ダウンロード
Studioチーム、法人、継続支援9,800円98,000円Pro権限、チーム席、レビュー依頼、研修資料

ユースケースは3つ以上で考えると、穴が先に見えます。

  1. SaaSのProプラン:毎月の利用料でダッシュボード、分析、エクスポートを解放する。
  2. 情報商材ファネル:無料記事→メール登録→テンプレート→月額会員→個別相談、と段階的に上げる。
  3. 研修・コンサル導線:法人向けにチーム席、請求書、キャンセル理由、支払い失敗時の連絡が要る。

この3つを並べると、「Freeにもarticle:previewは要るな」「Studioにはreview:requestが要る」みたいに権限のリストが自然に固まります。ここが固まってから、ようやくClaude Codeの出番です。

Claude Codeへの依頼は公式リンクつきにする

Stripeは機能変更が多くて、APIの形がちょいちょい変わります。なので、僕はClaude Codeに「学習した知識で書いて」とは絶対に頼みません。公式ドキュメントを読ませる前提で依頼します。実際に使っているプロンプトがこれです。

Stripe Billingで月額サブスクリプションを実装してください。
必ず次の公式ドキュメントを前提にしてください。
- Checkout subscription mode: https://docs.stripe.com/api/checkout/sessions/create
- Customer Portal: https://docs.stripe.com/customer-management
- Subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Webhook signatures and local testing: https://docs.stripe.com/webhooks
- Subscription statuses: https://docs.stripe.com/api/subscriptions/object

要件:
- Next.js App Router + TypeScript + Postgres
- Stripe Checkoutはmode="subscription"
- Stripe Customer Portalで支払い方法変更、請求書確認、解約を扱う
- Webhookは署名検証し、イベントIDで重複処理を防ぐ
- アプリ側にentitlementsテーブルを持ち、機能開放をDBで判断する
- active/trialingは開放、past_dueは3日猶予、unpaid/canceled/pausedは停止
- ローカルでstripe listenを使って検証できるREADME断片も出す

ここで専門用語を2つだけ平易に言い換えておきます。entitlementは「権限」「利用権」のこと。たとえばtemplates:downloadが有効ならテンプレートをダウンロードできる、team:seatsが有効ならチーム席を使える、という判定に使う名札です。dunningは支払い失敗後の回収対応のこと。カード失敗、3Dセキュア未完了、請求書未払いに対して、メールやポータル誘導で「払い直してね」と促す一連の流れを指します。

このプロンプトで大事なのは要件の最後の2行、状態の扱いとテスト手順を明記しているところです。ここを書かないと、Claude Codeはstatusを雑に2分岐(払ってる/払ってない)にしがちで、まさに僕が事故ったpast_dueの扱いをすっ飛ばします。

DBはStripe状態とアプリ権限を分ける

設計の肝はテーブルの分け方です。billing_subscriptionsにStripeの生の状態を、entitlementsにアプリが実際に見る権限だけを持たせます。そしてWebhookは重複して届くので、webhook_eventsでイベントIDを記録して二重処理を止める。冒頭の二重課金は、このwebhook_eventsが無かったせいで起きました。

次のSQLはPostgres向けの最小構成です。

create table if not exists app_users (
  id text primary key,
  email text not null unique,
  stripe_customer_id text unique,
  created_at timestamptz not null default now()
);

create table if not exists billing_subscriptions (
  user_id text primary key references app_users(id) on delete cascade,
  stripe_customer_id text not null,
  stripe_subscription_id text not null unique,
  plan_key text not null,
  status text not null check (
    status in ('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused')
  ),
  access_state text not null check (access_state in ('pending', 'granted', 'grace', 'revoked')),
  current_period_end timestamptz,
  cancel_at_period_end boolean not null default false,
  grace_until timestamptz,
  updated_at timestamptz not null default now()
);

create table if not exists entitlements (
  user_id text not null references app_users(id) on delete cascade,
  feature_key text not null,
  active boolean not null default true,
  expires_at timestamptz,
  updated_at timestamptz not null default now(),
  primary key (user_id, feature_key)
);

create table if not exists webhook_events (
  event_id text primary key,
  event_type text not null,
  status text not null default 'processing',
  attempts integer not null default 1,
  processed_at timestamptz,
  last_error text,
  created_at timestamptz not null default now()
);

ここでstatus(Stripe生の状態)とaccess_state(アプリの開放判断)を別カラムにしているのが効きます。Stripeのステータスは8種類もあって、画面のあちこちで分岐させるとバグの温床になる。だから「開放してよい状態か」をgranted / grace / revoked / pendingの4つに翻訳して、機能ゲートはこっちだけ見るようにします。

CheckoutとCustomer Portalの実装

依存はこれだけ。認証は実プロジェクトのAuth.js、Clerk、Supabase Authなどに置き換えてください(このサンプルはデモ用にヘッダでユーザーIDを渡しています)。

npm install stripe postgres
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_STUDIO_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_AUTOMATIC_TAX=false

まずDB接続。

// src/lib/db.ts
import postgres from "postgres";

export const sql = postgres(process.env.DATABASE_URL!, {
  max: 5,
  idle_timeout: 20,
});

次に課金まわりの本体。PLANSが先ほどの料金表のコード版です。ここに権限のリストを集約しておくと、開放処理が「このプランのfeaturesをそのまま入れる」だけで済みます。

// src/lib/billing.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export const PLANS = {
  free: {
    priceId: null,
    features: ["article:preview"],
  },
  pro: {
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    features: ["article:full", "templates:download", "course:library"],
  },
  studio: {
    priceId: process.env.STRIPE_STUDIO_PRICE_ID!,
    features: ["article:full", "templates:download", "course:library", "team:seats", "review:request"],
  },
} as const;

export type PlanKey = keyof typeof PLANS;
export type PaidPlanKey = Exclude<PlanKey, "free">;

export function isPaidPlanKey(value: unknown): value is PaidPlanKey {
  return value === "pro" || value === "studio";
}

export async function createCheckoutSession(userId: string, planKey: PaidPlanKey) {
  const customerId = await findOrCreateCustomer(userId);
  const plan = PLANS[planKey];

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    line_items: [{ price: plan.priceId, quantity: 1 }],
    client_reference_id: userId,
    allow_promotion_codes: true,
    automatic_tax: { enabled: process.env.STRIPE_AUTOMATIC_TAX === "true" },
    success_url: `${APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${APP_URL}/pricing`,
    subscription_data: {
      metadata: { userId, planKey },
    },
    metadata: { userId, planKey },
  });

  if (!session.url) throw new Error("Stripe did not return a Checkout URL");
  return session.url;
}

export async function createPortalSession(userId: string) {
  const customerId = await getCustomerId(userId);
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${APP_URL}/settings/billing`,
  });
  return session.url;
}

async function findOrCreateCustomer(userId: string) {
  const users = await sql`
    select id, email, stripe_customer_id
    from app_users
    where id = ${userId}
  `;
  const user = users[0] as { id: string; email: string; stripe_customer_id: string | null } | undefined;
  if (!user) throw new Error("User not found");
  if (user.stripe_customer_id) return user.stripe_customer_id;

  const customer = await stripe.customers.create({
    email: user.email,
    metadata: { userId },
  });

  await sql`
    update app_users
    set stripe_customer_id = ${customer.id}
    where id = ${userId}
  `;
  return customer.id;
}

async function getCustomerId(userId: string) {
  const rows = await sql`
    select stripe_customer_id
    from app_users
    where id = ${userId}
  `;
  const customerId = rows[0]?.stripe_customer_id as string | undefined;
  if (!customerId) throw new Error("Stripe customer is not linked");
  return customerId;
}

metadatauserIdを入れるのは地味ですが超重要です。これを忘れると、後でWebhookが届いたときに「このSubscriptionは誰のもの?」が分からなくなって同期できません。僕は最初subscription_data.metadataを入れ忘れて、Subscription更新イベントが全部迷子になりました。だからsession側とsubscription_data側の両方に入れています。

エンドポイントは薄く保ちます。

// src/app/api/billing/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createCheckoutSession, isPaidPlanKey } from "@/lib/billing";

export async function POST(request: NextRequest) {
  const userId = request.headers.get("x-demo-user-id");
  if (!userId) {
    return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
  }

  const { planKey } = await request.json();
  if (!isPaidPlanKey(planKey)) {
    return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
  }

  const url = await createCheckoutSession(userId, planKey);
  return NextResponse.json({ url });
}
// src/app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createPortalSession } from "@/lib/billing";

export async function POST(request: NextRequest) {
  const userId = request.headers.get("x-demo-user-id");
  if (!userId) {
    return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
  }

  const url = await createPortalSession(userId);
  return NextResponse.json({ url });
}

Webhookで権限を同期する(事故の本丸)

ここが冒頭の二重課金の現場です。成功画面のリダイレクトはUXのための演出であって、機能開放の根拠にしてはいけません。ユーザーがタブを閉じて戻ってこないこともあるし、支払いに追加認証が挟まることもある。機能を開放する根拠は、署名検証したWebhookか、Stripe APIで取得した最新状態にします。

しかもStripeはイベントの到着順を保証しません。subscription.updatedsubscription.createdより先に届くことも普通にある。だから僕はイベント本文を信じすぎず、届いたら必ずSubscriptionを再取得して最新で上書きする方針にしました。これで「古いイベントで上書きして権限が消える」事故が止まります。

// src/lib/entitlements.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { PLANS, PlanKey, stripe } from "@/lib/billing";

type AccessState = "pending" | "granted" | "grace" | "revoked";

export async function syncSubscriptionFromStripe(subscriptionId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
    expand: ["items.data.price"],
  });

  const item = subscription.items.data[0];
  const priceId = item?.price.id;
  const planKey = planKeyFromPrice(priceId);
  const userId = subscription.metadata.userId;
  if (!userId) throw new Error(`Subscription ${subscription.id} has no userId metadata`);

  const accessState = accessStateFor(subscription.status);
  const currentPeriodEnd = item?.current_period_end
    ? new Date(item.current_period_end * 1000)
    : null;
  const graceUntil = accessState === "grace"
    ? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
    : null;

  await sql.begin(async (tx) => {
    await tx`
      insert into billing_subscriptions (
        user_id, stripe_customer_id, stripe_subscription_id, plan_key,
        status, access_state, current_period_end, cancel_at_period_end, grace_until, updated_at
      )
      values (
        ${userId}, ${subscription.customer as string}, ${subscription.id}, ${planKey},
        ${subscription.status}, ${accessState}, ${currentPeriodEnd},
        ${subscription.cancel_at_period_end}, ${graceUntil}, now()
      )
      on conflict (user_id) do update set
        stripe_customer_id = excluded.stripe_customer_id,
        stripe_subscription_id = excluded.stripe_subscription_id,
        plan_key = excluded.plan_key,
        status = excluded.status,
        access_state = excluded.access_state,
        current_period_end = excluded.current_period_end,
        cancel_at_period_end = excluded.cancel_at_period_end,
        grace_until = excluded.grace_until,
        updated_at = now()
    `;

    const features = accessState === "granted" || accessState === "grace"
      ? PLANS[planKey].features
      : PLANS.free.features;

    await tx`delete from entitlements where user_id = ${userId}`;
    for (const feature of features) {
      await tx`
        insert into entitlements (user_id, feature_key, active, expires_at, updated_at)
        values (${userId}, ${feature}, true, ${graceUntil}, now())
      `;
    }
  });

  console.info("stripe.subscription.synced", {
    userId,
    subscriptionId: subscription.id,
    status: subscription.status,
    accessState,
    planKey,
  });
}

export async function hasEntitlement(userId: string, featureKey: string) {
  const rows = await sql`
    select 1
    from entitlements
    where user_id = ${userId}
      and feature_key = ${featureKey}
      and active = true
      and (expires_at is null or expires_at > now())
    limit 1
  `;
  return rows.length > 0;
}

function planKeyFromPrice(priceId: string | undefined): PlanKey {
  const entry = Object.entries(PLANS).find(([, plan]) => plan.priceId === priceId);
  if (!entry) return "free";
  return entry[0] as PlanKey;
}

function accessStateFor(status: Stripe.Subscription.Status): AccessState {
  if (status === "active" || status === "trialing") return "granted";
  if (status === "past_due") return "grace";
  if (status === "incomplete") return "pending";
  return "revoked";
}

syncSubscriptionFromStripeを1つのトランザクションでbilling_subscriptionsentitlementsの両方を更新しているのがミソです。途中で落ちても、権限だけ中途半端に書き換わる、という最悪パターンを防げます。entitlementsは毎回delete→insertで丸ごと作り直すので、プランのダウングレードで余分な権限が残ることもありません。

そしてWebhookの入口がこれです。reserveEventがイベントIDで「もう処理した?」を判定する冪等ガード。冒頭の二重課金は、ここがあれば起きませんでした。

// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { stripe } from "@/lib/billing";
import { syncSubscriptionFromStripe } from "@/lib/entitlements";

export async function POST(request: NextRequest) {
  const payload = await request.text();
  const signature = request.headers.get("stripe-signature");
  if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (error) {
    console.error("stripe.webhook.signature_failed", error);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  const reserved = await reserveEvent(event);
  if (!reserved) return NextResponse.json({ received: true, duplicate: true });

  try {
    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object as Stripe.Checkout.Session;
        const subscriptionId = expandableId(session.subscription);
        if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
        break;
      }
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "customer.subscription.deleted": {
        const subscription = event.data.object as Stripe.Subscription;
        await syncSubscriptionFromStripe(subscription.id);
        break;
      }
      case "invoice.paid":
      case "invoice.payment_failed": {
        const invoice = event.data.object as Stripe.Invoice;
        const subscriptionId = subscriptionIdFromInvoice(invoice);
        if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
        break;
      }
      default:
        console.info("stripe.webhook.ignored", { id: event.id, type: event.type });
    }

    await markEventProcessed(event.id);
    return NextResponse.json({ received: true });
  } catch (error) {
    await markEventFailed(event.id, error);
    console.error("stripe.webhook.failed", { id: event.id, type: event.type, error });
    return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 });
  }
}

function expandableId(value: string | { id: string } | null): string | null {
  if (!value) return null;
  return typeof value === "string" ? value : value.id;
}

function subscriptionIdFromInvoice(invoice: Stripe.Invoice) {
  const subscription = invoice.parent?.subscription_details?.subscription;
  return expandableId(subscription ?? null);
}

async function reserveEvent(event: Stripe.Event) {
  const rows = await sql`
    insert into webhook_events (event_id, event_type, status)
    values (${event.id}, ${event.type}, 'processing')
    on conflict (event_id) do update set
      attempts = webhook_events.attempts + 1,
      status = 'processing',
      last_error = null
    where webhook_events.status <> 'processed'
    returning event_id
  `;
  return rows.length > 0;
}

async function markEventProcessed(eventId: string) {
  await sql`
    update webhook_events
    set status = 'processed', processed_at = now(), last_error = null
    where event_id = ${eventId}
  `;
}

async function markEventFailed(eventId: string, error: unknown) {
  await sql`
    update webhook_events
    set status = 'failed', last_error = ${error instanceof Error ? error.message : String(error)}
    where event_id = ${eventId}
  `;
}

reserveEventwhere webhook_events.status <> 'processed'が冪等性の核です。同じevent_idが来ても、すでにprocessedならreturningが空になり、reservedfalseになって即「duplicate」で返す。一方、前回failedで終わっていたイベントは再処理できる。Stripeは失敗時に自動でリトライしてくるので、「重複は弾く、でも失敗の再送は受ける」というこの一行が大事なんです。

署名検証(constructEvent)も飛ばさないでください。これが無いと、Webhookエンドポイントに偽のイベントを投げられて、無料で権限を開放させられます。

ローカルでWebhookをテストする

ローカルではStripe CLIを使います。stripe listenが出力するwhsec_...STRIPE_WEBHOOK_SECRETに入れます。

stripe listen --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:3000/api/webhooks/stripe

一番実運用に近いのは、テストモードでCheckoutをブラウザで最後まで通すことです。補助的にイベントを発火するなら、こう。

stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

ただし注意。stripe triggerが作るテストイベントは、あなたの実際のPrice IDやユーザーIDと一致しないことがあります。metadata.userIdが無いダミーが飛んできて、さっきのsyncSubscriptionFromStripeno userId metadataで例外を投げる——というのは想定どおりの挙動です。最終確認では、テストモードの商品・Price・Checkout・Portal・Webhookを一連で動かしてください。

冪等性を確認したいなら、stripe listenを動かした状態で同じイベントが2回転送されるのをログで眺めるか、Dashboardの「Webhookを再送信」を押してみる。2回目が{received: true, duplicate: true}で返れば、冒頭の二重課金は二度と起きません。

僕がやらかした課金事故3つ

正直に書きます。最初のサブスクは事故の博覧会でした。

ひとつ目は、成功URLに戻っただけで権限を開放したこと。 これが冒頭の二重課金の主犯です。リダイレクトは戻ってこないこともあるし、Webhookと二重で開放処理が走る。今は成功画面では「処理中です」とだけ出して、開放はWebhook側に一本化しています。

ふたつ目は、past_dueunpaidを同じ「未払い」で雑に止めたこと。 past_dueはカードの再試行中で、まだ回収できる見込みがある状態。ここで即停止すると、復旧したはずのお客さんを怒らせます。逆にunpaidcanceledは通常止めてよい。今はpast_dueは3日猶予、それを過ぎたら停止、と分けています。猶予を3日にするか7日にするかは事業判断なので、規約とメール文面と数字をそろえるのを忘れずに。

みっつ目は、Customer Portalを作って満足してDashboardの設定を忘れたこと。 コードでPortalセッションは作れるのに、Stripe Dashboardでプラン変更や解約を許可していなくて、お客さんが「解約ボタンが無い」と問い合わせてきました。Portalに任せる範囲と自社UIで制御する範囲は、コードとDashboardの両方で合わせる必要があります。

観測可能性と収益導線

事故ったあと一番効いたのは、実はログでした。stripe.subscription.syncedのような構造化ログを残すと、問い合わせが来たときに「このユーザー、いつpast_dueになって、いつ復旧したか」が秒で追えます。最低限、Stripe customer ID・subscription ID・plan・status・access_state・event_idは追えるようにしてください。

売上を伸ばす側の計測なら、checkout_startedcheckout_completedportal_openedpayment_failed_notifiedentitlement_revokedあたりを取ると、どこで離脱しているかが見えます。計測の設計はClaude Codeでアナリティクス実装:GA4・GSCでPVと収益を測るが参考になります。

そして一番伝えたい学びはこれです。ClaudeCodeLabで実際にこの流れを組んだとき、効いたのはコード生成そのものより状態表を先に固定したことでした。activeなら開放、past_dueなら猶予、unpaidなら停止——この表を1枚決めるだけで、Claude Codeへの指示・レビュー・テスト・サポート文面が全部そろう。コードはその表の写経になります。

よくある質問

Q. Checkoutの成功画面で権限を開放してはダメですか? A. ダメです。ユーザーが戻ってこない、Webhookが遅れる、追加認証が挟まる、といったケースで破綻します。成功画面は「処理中」を見せるだけにして、開放の根拠は署名検証済みのWebhookかStripe APIの最新状態にしてください。

Q. Webhookの冪等性って具体的に何をすればいいですか? A. 受け取ったevent.idをユニークキーにしてDBに記録し、すでにprocessedなら何もせず返すだけです。本文のwebhook_eventsテーブルとreserveEventがその実装です。これでStripeの重複配信を安全に無視できます。

Q. past_dueunpaidはどう扱い分ければいいですか? A. past_dueは回収中なので短い猶予(例:3日)を置いて開放を続け、unpaidcanceledは通常停止します。猶予日数は事業判断なので、規約・督促メール・コードの3つで数字を一致させてください。

Q. テストモードと本番でハマりやすい点は? A. STRIPE_WEBHOOK_SECRETがテストと本番で別物なこと、stripe triggerのダミーイベントは実Price IDやmetadataを持たないことです。最終確認は必ずテストモードでCheckout→Portal→Webhookを一連で通してください。

Q. Claude Codeに任せきりで実装できますか? A. 設計を渡せば実装の大部分は任せられますが、料金表・状態表・公式ドキュメントのURLは人間が先に決めて渡すのが安全です。税・通貨・猶予期間といった事業判断は、生成後に必ず人間がレビューしてください。

実際に試した結果

冒頭の二重課金以来、僕がサブスク実装で最初にやることは、コードを書くことではなくなりました。まず状態表(active/past_due/unpaidをどう開放/停止するか)を1枚に固定する。次にwebhook_eventsでイベントIDを冪等化する。そして開放の根拠を成功画面からWebhookに一本化する。この3つを先に決めてから、Claude Codeに公式リンク付きで実装を頼むと、二重課金もmetadata迷子も再発しませんでした。

決済全体の入口はClaude CodeでStripe決済を実装する実践ガイド:Checkout・Webhook・返金まで、Checkout単体を深掘りするならClaude CodeでStripe Checkoutを実装:Webhookと冪等性まで、ログインや権限境界はClaude Codeで認証実装を安全に進める実践ガイドも合わせてどうぞ。

公式情報は実装前に必ず最新を確認してください。とくにStripe Checkout Sessions APICustomer PortalSubscription webhooksWebhooksSubscription objectは再確認の価値があります。

自社のSaaS・教材・会員サイトに合わせてStripe Billing、権限テーブル、Claude Codeのレビュー手順を一緒に整えたい方は、Claude Code研修・導入相談教材・テンプレート一覧からどうぞ。

#Claude Code #Stripe #サブスクリプション #Webhook #冪等性 #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。