💡 Tips

個人開発SaaSにStripeサブスク課金をTypeScriptで実装する手順

結論:Stripe Checkoutで最速サブスク課金を実装する

個人開発SaaSにサブスク課金を組み込む最短ルートは、Stripe Checkoutのホスティングページを使う構成です。自前でカード入力フォームを作らず、StripeのCheckoutセッションURLにリダイレクトするだけで、PCI DSS準拠・3Dセキュア対応・モバイル最適化済みの決済画面が手に入ります。

この記事では TypeScript + Hono(またはNext.js APIルート)でバックエンドを実装し、Webhookのローカルテストから本番デプロイまでを一気通貫で解説します。


全体アーキテクチャ

ブラウザ
  │ ①「プランを購入」ボタン押下

バックエンド(Hono / Next.js)
  │ ②Checkoutセッション作成 → セッションURL返却

Stripe Checkout(ホスティング決済画面)
  │ ③支払い完了

Webhook(/api/stripe/webhook)
  │ ④checkout.session.completed を受信

DB(サブスク状態を更新)

DBはPlanetScale / Supabase など何でも構いません。本記事ではDB操作部分は疑似コードで表します。


1. Stripe・依存パッケージのセットアップ

# Stripeパッケージ
npm install stripe @stripe/stripe-js

# ローカルWebhookテスト用CLI
# https://stripe.com/docs/stripe-cli
brew install stripe/stripe-cli/stripe

.env.local に環境変数を追加します。

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxx
NEXT_PUBLIC_BASE_URL=http://localhost:3000

注意: sk_test_ はテストキーです。本番は sk_live_ を使い、環境変数を分けて管理してください。


2. Stripeクライアントのシングルトン初期化

// lib/stripe.ts
import Stripe from "stripe";

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error("STRIPE_SECRET_KEY is not set");
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2024-04-10", // 最新APIバージョンを指定
  typescript: true,
});

3. Checkoutセッション作成エンドポイント

Honoの場合

// routes/checkout.ts
import { Hono } from "hono";
import { stripe } from "../lib/stripe";

const app = new Hono();

app.post("/api/stripe/checkout", async (c) => {
  const { priceId, userId } = await c.req.json<{
    priceId: string;
    userId: string;
  }>();

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
    // Webhookでユーザーを特定するためのメタデータ
    metadata: { userId },
    // 既存顧客IDがあればここで渡す(サブスク管理が容易になる)
    // customer: existingStripeCustomerId,
  });

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

export default app;

Next.js App Routerの場合

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

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

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
    metadata: { userId },
  });

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

フロントエンド(共通)

// components/CheckoutButton.tsx
"use client";

const handleCheckout = async (priceId: string) => {
  const res = await fetch("/api/stripe/checkout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ priceId, userId: "user_123" }),
  });
  const { url } = await res.json();
  window.location.href = url; // Stripeのホスティングページへリダイレクト
};

4. Webhookの実装(最重要)

Checkoutが完了したあと、Stripeはあなたのサーバーに checkout.session.completed を送信します。この受信処理がサブスク管理の核です。

// app/api/stripe/webhook/route.ts(Next.js例)
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { stripe } from "@/lib/stripe";

export async function POST(req: NextRequest) {
  const body = await req.text(); // rawBodyが必要
  const sig = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const userId = session.metadata?.userId;
      const subscriptionId = session.subscription as string;
      // DBにサブスク情報を保存する
      // await db.user.update({ where: { id: userId }, data: { subscriptionId, plan: "pro" } });
      console.log(`User ${userId} subscribed: ${subscriptionId}`);
      break;
    }
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      // サブスクキャンセル時の処理
      // await db.user.update({ where: { stripeSubscriptionId: subscription.id }, data: { plan: "free" } });
      break;
    }
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

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

重要: Next.js App RouterではWebhookルートに bodyParser が自動で適用されます。req.text() でrawボディを取得することで署名検証が正しく動作します。


5. Webhookのローカルテスト(Stripe CLI)

Stripeダッシュボードを確認しながら、ローカル環境でWebhookをリアルタイムにテストできます。

# StripeのWebhookをローカルにフォワード
stripe listen --forward-to localhost:3000/api/stripe/webhook

# 別ターミナルでイベントを手動送信
stripe trigger checkout.session.completed

stripe listen を起動すると whsec_xxxx 形式のWebhookシークレットが表示されます。これを .env.localSTRIPE_WEBHOOK_SECRET に設定します。


6. Stripe Dashboardで商品・価格を作成する

設定項目値の例
商品名Pro Plan
価格¥980 / 月
課金タイプ繰り返し(recurring)
価格IDprice_xxxxxxxxxx

ダッシュボード → 製品カタログ → 「商品を追加」で作成し、Price ID(price_ から始まる文字列)をフロントのボタンに渡します。


7. 本番デプロイ時のチェックリスト

  • 環境変数を sk_live_ / pk_live_ に切り替える
  • Stripeダッシュボードで本番用Webhookエンドポイントを登録(https://yourdomain.com/api/stripe/webhook
  • Webhookシークレット(whsec_)を本番の環境変数に設定
  • 成功URL・キャンセルURLを本番ドメインに更新
  • Stripeのテストカード(4242 4242 4242 4242)で動作確認後、本番モード有効化申請
📚 おすすめ書籍

実践TypeScript BFF×Expressのベストプラクティス

TypeScriptでバックエンド構築を深めたい方に

Amazonで見る →

よくある落とし穴と対処法

❌ Webhookの署名検証エラー

bodyParserexpress.json() が先にボディをパースしてしまうと署名検証が失敗します。StripeのWebhookルートには必ず rawボディ を渡すか、該当ルートだけ bodyParser を無効にしてください。

❌ Webhookの二重処理

Stripeは同じイベントを複数回送信することがあります(リトライ)。event.id をDBに記録してべき等性を担保しましょう。

// 処理済みイベントIDをチェック
const processed = await db.stripeEvent.findUnique({ where: { id: event.id } });
if (processed) return NextResponse.json({ received: true }); // スキップ
await db.stripeEvent.create({ data: { id: event.id } });

❌ サブスク更新・失敗の未対応

invoice.payment_failedcustomer.subscription.updated も必ずハンドリングしてください。特に支払い失敗時にプランをダウングレードしないと、無料利用が続いてしまいます。


まとめ

ステップやること
1Stripe CLIとパッケージをインストール
2stripe.checkout.sessions.create でセッション作成・リダイレクト
3/api/stripe/webhook でイベント受信・DB更新
4stripe listen でローカルWebhookテスト
5本番キーに切り替えてデプロイ

Stripe CheckoutとWebhookの組み合わせは、個人開発SaaSのマネタイズとして最もコスパが高い構成です。カード情報を自前で扱わないため、セキュリティ面のリスクも最小化できます。まずはテストモードで動かしてみてください。

SaaSを公開するサーバーには、高速でコスパの良いVPSを選ぶと開発体験が向上します。{{A8:xserverVps}}