Build This Now
Build This Now
クロード・コードとは何か?Claude Code のインストールClaude Code ネイティブインストーラーClaude Code で最初のプロジェクトを作る
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email SequencesClaude Code v2.1.122 Release NotesClaude Code の Dynamic Workflows:実際のコードベースで 1,000 個の subagents を動かす方法Claude Code ベストプラクティスClaude Opus 4.7 ベストプラクティスVPS上でのClaude CodeGit 統合Claude Code レビューClaude Code WorktreesClaude CodeリモートコントロールClaude Code ChannelsChannels、Routines、Teleport、DispatchClaude Code スケジュールタスクClaude Code権限管理Claude Code オートモードClaude Code で Stripe 決済を組み込むフィードバックループTodoワークフローClaude Code タスク管理プロジェクトテンプレートClaude Code の料金とトークン使用量Claude Code の料金:実際にいくら払うことになるのかClaude Code Ultra Review 完全ガイドClaude Code で Next.js アプリを作るClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeClaude Code で SaaS の MVP を作る方法Claude Code で認証を追加する(Supabase Auth)Claude Code でトランザクションメールを追加する(Resend + React Email)Claude Code で型安全な API を作る(oRPC + Zod)How to Add File Uploads With Claude Code (Supabase Storage)How to Run Background Jobs with Claude Code and InngestHow to Build an Admin Dashboard With Claude CodeHow to Add Full-Text Search With Claude Code (Postgres)エージェント型コマース:AI エージェントが支払えるアプリの作り方Claude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Headless ModeMax Plan vs APIPrompt Caching2026年、Claude Code で SaaS を作るといくらかかるかRun a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/Subscription Billing

Stripe Subscription Billing with Claude Code

Ship recurring plans end to end with Claude Code: tiered pricing, Stripe Checkout for subscriptions, proration on plan changes, the customer portal, and webhook-driven entitlement sync into Supabase.

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →
speedy_devvWritten by speedy_devvPublished Jul 14, 202611 min readHandbook hubWorkflow index

Recurring revenue is the whole point of a SaaS, and Stripe subscriptions are how you collect it. This walks through the exact flow: tiered pricing in Stripe, a Checkout session for subscriptions, proration when someone changes plans, the customer portal for self-service, and a webhook that syncs entitlements into Supabase so your app knows who paid for what. Claude Code writes each piece, and you wire them into a Next.js 16 app that actually charges money.


設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

The Flow You Are Actually Building

Before any code, get the shape of the system clear so Claude Code builds the right thing. There are five moving parts, and they connect in one direction: Stripe holds the money and the truth, webhooks push that truth into your database, and your app reads entitlements from the database to decide what a user can do.

Here is the loop. A user picks a plan and hits Checkout. Stripe collects the card and creates a subscription. Stripe fires a webhook at your server. Your webhook writes the plan and status into a Supabase table. Your app reads that table to gate features. When the user upgrades, downgrades, or cancels through the customer portal, Stripe fires another webhook, and the table updates again. Your app never guesses. It reads one row.

Give Claude Code that mental model in plan mode so it does not try to store card data or check Stripe on every request:

claude --plan "build Stripe subscription billing: three tiers, Checkout in subscription mode, a webhook that syncs subscription status into a Supabase 'subscriptions' table, and the customer portal for plan changes. Entitlements are read from Supabase, never from the Stripe API at request time."

The Entitlements Table in Supabase

Everything hangs off one table. It maps a user to their current Stripe customer, their active subscription, the tier they are paying for, and whether that subscription is live. Row-level security makes sure a user can only ever read their own row, and a service-role client (used only in the webhook) is the only thing that writes to it.

Ask Claude Code to generate the migration. The status column mirrors Stripe's subscription statuses, and current_period_end lets you show renewal dates and handle grace periods without another API call.

-- supabase/migrations/0001_subscriptions.sql
create table public.subscriptions (
  id                      uuid primary key default gen_random_uuid(),
  user_id                 uuid not null references auth.users(id) on delete cascade,
  stripe_customer_id      text not null,
  stripe_subscription_id  text unique,
  price_id                text,
  tier                    text not null default 'free',
  status                  text not null default 'inactive',
  current_period_end      timestamptz,
  created_at              timestamptz not null default now(),
  updated_at              timestamptz not null default now(),
  unique (user_id)
);

create index subscriptions_customer_idx on public.subscriptions (stripe_customer_id);

alter table public.subscriptions enable row level security;

-- Users can read only their own subscription row.
create policy "read own subscription"
  on public.subscriptions for select
  using (auth.uid() = user_id);

-- No client-side writes. The webhook writes with the service role,
-- which bypasses RLS, so no insert/update policy is exposed to clients.

The important decision here is that there is no insert or update policy for regular users. The only writer is the webhook, running with the Supabase service role key on the server. Clients read, Stripe writes. That is what keeps entitlements honest.

Defining Tiers in Stripe

Tiers live in Stripe as Products with recurring Prices. You can create them in the dashboard, but doing it in code keeps them versioned and repeatable across environments. Each price has an ID that looks like price_1AbC..., and that ID is the link between Stripe and your app.

Have Claude Code write a one-off setup script. Run it once against your test-mode key, copy the printed price IDs into your environment, and you are done.

// scripts/setup-stripe-plans.ts
import Stripe from "stripe";

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

async function main() {
  const tiers = [
    { name: "Starter", tier: "starter", amount: 900 },
    { name: "Pro", tier: "pro", amount: 2900 },
    { name: "Business", tier: "business", amount: 9900 },
  ];

  for (const t of tiers) {
    const product = await stripe.products.create({
      name: `Acme ${t.name}`,
      metadata: { tier: t.tier },
    });

    const price = await stripe.prices.create({
      product: product.id,
      unit_amount: t.amount,
      currency: "usd",
      recurring: { interval: "month" },
      metadata: { tier: t.tier },
    });

    console.log(`${t.tier}: ${price.id}`);
  }
}

main();

Two details matter. First, unit_amount is in cents, so 2900 is 29.00 USD. Second, the tier name lives in the price metadata. That means your webhook can read the tier straight off the Stripe object later instead of hardcoding a lookup table, though we will keep an explicit map too for safety.

Store the printed IDs in your environment file:

# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
STRIPE_PRICE_STARTER=price_...
STRIPE_PRICE_PRO=price_...
STRIPE_PRICE_BUSINESS=price_...

Checkout in Subscription Mode

Checkout is a hosted Stripe page. You do not collect card numbers, you do not touch PCI scope, you hand Stripe a price and a customer and it does the rest. The key difference from a one-time payment is mode set to subscription, which tells Stripe to create a recurring subscription instead of charging once.

This route handler runs on the server, looks up (or creates) the Stripe customer for the signed-in user, and returns a Checkout URL. Note the async cookies call and the await on nothing exotic here, just standard Next.js 16 server code.

// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { createClient } from "@/lib/supabase/server";

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

export async function POST(req: Request) {
  const { priceId } = await req.json();

  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: "Not signed in" }, { status: 401 });
  }

  // Reuse the Stripe customer if this user already has one.
  const { data: existing } = await supabase
    .from("subscriptions")
    .select("stripe_customer_id")
    .eq("user_id", user.id)
    .maybeSingle();

  let customerId = existing?.stripe_customer_id;

  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      metadata: { user_id: user.id },
    });
    customerId = customer.id;
  }

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    // Pass user_id through so the webhook can find the row even
    // before a subscription row exists.
    subscription_data: { metadata: { user_id: user.id } },
  });

  return NextResponse.json({ url: session.url });
}

Passing user_id into both the customer metadata and the subscription_data metadata is the trick that makes the webhook reliable. When Stripe fires the subscription event, the payload carries that user_id, so you never have to reverse-lookup a user from an email address.

The client side is a button that posts to this route and redirects. Because the response is just a URL, the pricing page stays a Server Component and only the button needs interactivity.

// components/checkout-button.tsx
"use client";

export function CheckoutButton({ priceId, label }: { priceId: string; label: string }) {
  async function subscribe() {
    const res = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId }),
    });
    const { url } = await res.json();
    if (url) window.location.href = url;
  }

  return (
    <button
      onClick={subscribe}
      className="rounded-md bg-black px-4 py-2 text-white"
    >
      {label}
    </button>
  );
}

The Webhook That Syncs Entitlements

This is the load-bearing piece. Stripe sends events to a route on your server, and this route translates those events into rows in your subscriptions table. It is the only place that writes tier and status, and it runs with the service-role client so it can bypass RLS.

Two things make a Stripe webhook safe. You must verify the signature so nobody can forge a "you got paid" event, and you must read the raw request body, because Stripe signs the exact bytes it sent. In a Next.js 16 route handler, await req.text() gives you those bytes untouched.

// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { createServiceClient } from "@/lib/supabase/service";

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

// Explicit map from Stripe price to your app tier. The metadata on
// the price is a backup; this map is the source your code trusts.
const PRICE_TO_TIER: Record<string, string> = {
  [process.env.STRIPE_PRICE_STARTER!]: "starter",
  [process.env.STRIPE_PRICE_PRO!]: "pro",
  [process.env.STRIPE_PRICE_BUSINESS!]: "business",
};

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature");

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig!, webhookSecret);
  } catch (err) {
    return NextResponse.json({ error: "Bad signature" }, { status: 400 });
  }

  const supabase = createServiceClient();

  // A helper that upserts the row from any subscription object.
  async function syncSubscription(sub: Stripe.Subscription) {
    const userId = sub.metadata.user_id;
    const priceId = sub.items.data[0]?.price.id;
    const tier = priceId ? PRICE_TO_TIER[priceId] ?? "free" : "free";
    const active = sub.status === "active" || sub.status === "trialing";

    await supabase.from("subscriptions").upsert(
      {
        user_id: userId,
        stripe_customer_id: sub.customer as string,
        stripe_subscription_id: sub.id,
        price_id: priceId,
        tier: active ? tier : "free",
        status: sub.status,
        current_period_end: new Date(sub.items.data[0].current_period_end * 1000).toISOString(),
        updated_at: new Date().toISOString(),
      },
      { onConflict: "user_id" },
    );
  }

  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      await syncSubscription(event.data.object as Stripe.Subscription);
      break;
    }
    case "checkout.session.completed": {
      // Fetch the fresh subscription so we have full item and period data.
      const session = event.data.object as Stripe.Checkout.Session;
      if (session.subscription) {
        const sub = await stripe.subscriptions.retrieve(session.subscription as string);
        await syncSubscription(sub);
      }
      break;
    }
    default:
      break;
  }

  return NextResponse.json({ received: true });
}

A few things worth calling out. The syncSubscription helper is deliberately the single path that writes a row, so created, updated, deleted, and the initial checkout all funnel through the same logic. When a subscription is canceled, Stripe sends customer.subscription.deleted with a status of canceled, and because active is false, the row drops back to the free tier. And the upsert keys on user_id, so a user always has exactly one row no matter how many times they change plans.

Point Stripe at this route. In development, the Stripe CLI forwards live events to your local server and prints the signing secret to paste into your environment:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

In production, add the endpoint in the Stripe dashboard under Developers, Webhooks, and subscribe it to checkout.session.completed, customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted.

Reading Entitlements to Gate Features

Now the payoff. Anywhere in your app that needs to know whether a user can access a paid feature, you read the subscriptions table, not Stripe. Because RLS scopes the query to the current user, a Server Component can ask the question in one line and trust the answer.

This helper returns the user's tier and whether their subscription is active. Gate a page or a Server Action on it and you are done.

// lib/entitlements.ts
import { createClient } from "@/lib/supabase/server";

const TIER_RANK: Record<string, number> = {
  free: 0,
  starter: 1,
  pro: 2,
  business: 3,
};

export async function getEntitlement() {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) return { tier: "free", active: false };

  const { data } = await supabase
    .from("subscriptions")
    .select("tier, status")
    .eq("user_id", user.id)
    .maybeSingle();

  const active = data?.status === "active" || data?.status === "trialing";
  return { tier: active ? data!.tier : "free", active };
}

export async function requireTier(minTier: keyof typeof TIER_RANK) {
  const { tier } = await getEntitlement();
  return TIER_RANK[tier] >= TIER_RANK[minTier];
}

Using it inside a Server Component is direct. The page renders the gated content only when the user has cleared the tier bar.

// app/reports/page.tsx
import { requireTier } from "@/lib/entitlements";
import { redirect } from "next/navigation";

export default async function ReportsPage() {
  const allowed = await requireTier("pro");
  if (!allowed) redirect("/pricing");

  return <main className="p-8">Pro reports go here.</main>;
}

The TIER_RANK map turns tier gating into a numeric comparison, so a Business user automatically clears a Pro gate without you listing every tier by hand. That small design choice saves you from a wall of string checks as plans multiply.

The Customer Portal for Upgrades, Downgrades, and Cancellations

You could build upgrade and cancel buttons by hand, but Stripe already ships a hosted portal that does all of it: change plan, update card, download invoices, cancel. You send the user there, they make changes, Stripe fires the same subscription webhooks you already handle, and your table stays in sync. It is the highest-leverage piece in the whole system because it is the piece you do not have to build.

This route creates a portal session for the current user's Stripe customer and returns the URL. It looks almost identical to the Checkout route, which is the point.

// app/api/portal/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { createClient } from "@/lib/supabase/server";

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

export async function POST() {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: "Not signed in" }, { status: 401 });
  }

  const { data } = await supabase
    .from("subscriptions")
    .select("stripe_customer_id")
    .eq("user_id", user.id)
    .maybeSingle();

  if (!data?.stripe_customer_id) {
    return NextResponse.json({ error: "No customer" }, { status: 400 });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: data.stripe_customer_id,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
  });

  return NextResponse.json({ url: session.url });
}

Before the portal will let customers switch plans, enable it once in the Stripe dashboard under Settings, Billing, Customer portal. Turn on "Customers can switch plans" and add your three prices to the allowed list. Set whether downgrades apply immediately or at period end. That configuration lives in Stripe, not your code, which means you tune billing behavior without a deploy.

Proration, Handled for You

Proration is the thing people expect to be hard. It is the math of "you switched from a 9 USD plan to a 29 USD plan on day 15, so you owe the difference for the back half of the month." Stripe does this automatically. When a customer changes plans in the portal, Stripe charges the prorated difference immediately on an upgrade, and on a downgrade it issues a credit that lands on the next invoice instead of a refund.

Because the portal handles plan changes, you never write proration code. But if you ever change a plan from your own backend, for example bumping a user during a promo, the API respects the same rules. The proration_behavior option controls it, and the default of create_prorations is almost always what you want.

// Example: change a plan from your own server (the portal usually does this).
await stripe.subscriptions.update(subscriptionId, {
  items: [{ id: subscriptionItemId, price: newPriceId }],
  proration_behavior: "create_prorations", // default; use "none" to skip
});

Whichever path triggers the change, portal or API, the result is a customer.subscription.updated webhook, and your syncSubscription helper writes the new tier into Supabase. The billing math and the entitlement update stay decoupled, which is exactly how you want it.

Quality Gates Before You Ship

Money code fails loudly and expensively, so run the checks before every commit. Type errors in a webhook handler are the ones that bite hardest, because they only surface when a real event arrives.

Ask Claude Code to run both and fix what breaks:

claude "run tsc --noEmit and fix any type errors in the Stripe routes, then confirm the build passes"
npx tsc --noEmit
npm run build

Then test the real flow, not just the types. Use a Stripe test card, subscribe, and watch the row appear in Supabase. Switch plans in the portal and watch the tier change. Cancel and watch it drop to free. The stripe listen CLI plus a test card exercises the entire loop locally before a single real card touches it.

Where the Code Kit Fits

Claude Code writes every file above, and if you feed it the plan in this post it will get most of it right on the first pass. What it does not do on its own is enforce that the webhook, the entitlements helper, and the gating all stay consistent as your app grows. That is the gap the $29 Code Kit fills: a harness on top of Claude Code that plans a feature, builds it, evaluates the output, tests it against a real browser, and runs the quality gates (zero type errors, zero lint errors, a clean build) before anything ships. It is a one-time 29 USD purchase, no subscription, and it runs on the same Claude Code you already have (which needs a paid Anthropic plan). It does not replace the work in this guide, it makes that work repeatable across every feature you add after billing is live.

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

Posted by @speedy_devv

Continue in Workflow

  • エージェント型コマース:AI エージェントが支払えるアプリの作り方
    2026年のエージェント型コマースをわかりやすく解説するガイド。x402、ACP、Machine Payments Protocol が何をするのか、そして AI エージェントが購入できる有料 API を週末で出荷するための手順を紹介します。
  • Claude Code ベストプラクティス
    Claude Codeで成果を出すエンジニアを分ける5つの習慣: PRD、モジュラーなCLAUDE.mdのルール、カスタムスラッシュコマンド、/clearリセット、そしてシステム進化の思考法。
  • Claude Code オートモード
    2つ目の Sonnet モデルが、Claude Code のすべてのツール呼び出しを実行前に審査します。オートモードがブロックするもの・許可するもの、そして settings.json に追加される許可ルールについて解説します。
  • Channels、Routines、Teleport、Dispatch
    Anthropic が2026年3月と4月に出荷した4つの Claude Code 機能。これらは CLI を、スマホ・ウェブ・デスクトップをまたぐイベント駆動の調整レイヤーに変えます。
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • エージェントの基礎
    Claude Codeでスペシャリストエージェントを構築する5つの方法:タスクサブエージェント、.claude/agents YAML、カスタムスラッシュコマンド、CLAUDE.mdペルソナ、パースペクティブプロンプト。
  • エージェント・ハーネス・エンジニアリング
    ハーネスとは、AIエージェントを構成するモデル以外のすべての層のことです。5つの制御レバー、制約のパラドックス、そしてなぜハーネス設計がモデルよりもエージェントのパフォーマンスを左右するのかを学びましょう。
  • エージェントパターン
    オーケストレーター、ファンアウト、バリデーションチェーン、スペシャリストルーティング、プログレッシブリファインメント、ウォッチドッグ。Claude Code のサブエージェントを組み合わせる6つのオーケストレーション形状。
  • エージェントチームのベストプラクティス
    Claude Code エージェントチームの実証済みパターン。コンテキストが豊富なスポーンプロンプト、適切なサイズのタスク、ファイルオーナーシップ、デリゲートモード、v2.1.33〜v2.1.45 の修正内容。

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

Vibe Coding's 90-Day Reckoning: The Technical Debt Nobody Warns You About

Vibe coding gets you to a demo, not to month 3. Here is why AI-generated code accumulates technical debt, what it actually costs, and how to keep the speed without the wall.

Multi-Tenant SaaS

Add organizations, seats, and invite flows to a Next.js SaaS: workspace-scoped tables, Supabase RLS for tenant isolation, team invites via Resend, and a member and role switcher.

On this page

The Flow You Are Actually Building
The Entitlements Table in Supabase
Defining Tiers in Stripe
Checkout in Subscription Mode
The Webhook That Syncs Entitlements
Reading Entitlements to Gate Features
The Customer Portal for Upgrades, Downgrades, and Cancellations
Proration, Handled for You
Quality Gates Before You Ship
Where the Code Kit Fits

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →