Express.js に Stripe 決済を導入する方法【2026年最新・完全ガイド】

Stripe Checkout を Express.js に組み込めば、単発決済からサブスクリプションまで半日で実装できる。本記事では、パッケージ導入から Webhook によるステータス同期、本番デプロイまでを番号付きステップで解説する。


目次

この記事で実装できること

Express.js バックエンドに以下の決済機能を追加する。

  • Stripe Checkout による単発決済とサブスクリプション
  • Webhook を使った非同期の支払いステータス管理
  • Customer Portal によるサブスク管理画面の提供
  • 決済履歴 API(ページネーション対応)

対象読者は、Express.js でのAPI開発経験があり、これから決済機能を追加したい開発者だ。


1. Stripe アカウントの作成と API キーの取得

Stripe の公式サイト(dashboard.stripe.com)でアカウントを作成する。ダッシュボードの「Developers」→「API keys」から、テスト用の Publishable Key と Secret Key を取得しておく。

テスト環境ではキーが pk_test_ / sk_test_ で始まる。本番環境に移行するまでは、必ずテストキーを使うこと。


2. パッケージのインストール

プロジェクトのルートで以下を実行する。

npm install stripe

2026年3月時点の最新は v20 系で、API Version 2026-02-25.clover に対応している。SDK を初期化する際は、API バージョンを明示的に固定しておくと、Stripe 側の変更で突然壊れるリスクを回避できる。

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2026-02-25.clover',
});

3. 環境変数の設定

.env に以下の3つを追加する。

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx
APP_URL=http://localhost:3000

STRIPE_WEBHOOK_SECRET はこの時点では空で構わない。後述の Webhook 設定ステップで値が決まる。

シークレットキーはサーバーサイド専用だ。フロントエンドのコードやリポジトリに含めてはいけない。


4. DB スキーマの設計

決済情報を管理するために、2つのテーブル変更が必要になる。

まず、既存の User テーブルに Stripe 顧客 ID を保持するカラムを追加する。

ALTER TABLE users ADD COLUMN stripe_customer_id VARCHAR(255) NULL UNIQUE;

次に、決済履歴を保存する Payment テーブルを新規作成する。

CREATE TABLE payments (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  stripe_session_id VARCHAR(255) UNIQUE NOT NULL,
  stripe_payment_intent_id VARCHAR(255) NULL,
  status VARCHAR(50) NOT NULL DEFAULT 'pending',
  amount INT UNSIGNED NOT NULL,
  currency VARCHAR(10) DEFAULT 'jpy',
  description VARCHAR(255) NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

status カラムには pending(決済開始)→ completed(成功)/ failed(失敗)/ expired(期限切れ)の4状態を格納する。この状態遷移は Webhook で自動更新される。


5. Stripe Customer の取得・作成ロジック

Checkout Session を作るには Stripe Customer が必要だ。初回決済時に Customer を自動作成し、User テーブルに紐づけておくと、2回目以降の決済やサブスク管理がスムーズになる。

async function getOrCreateCustomer(user: User): Promise<string> {
  if (user.stripeCustomerId) {
    return user.stripeCustomerId;
  }

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

  user.stripeCustomerId = customer.id;
  await userRepository.save(user);

  return customer.id;
}

metadata にアプリ側のユーザー ID を入れておくと、Stripe ダッシュボードや Webhook で照合しやすい。


6. Checkout Session API の実装

単発決済用のエンドポイントを作成する。フロントエンドから priceId(Stripe ダッシュボードで商品作成時に発行される ID)と数量を受け取り、Checkout Session の URL を返す。

router.post('/checkout', authMiddleware, async (req, res) => {
  const { priceId, quantity = 1 } = req.body;
  const user = req.user;

  const customerId = await getOrCreateCustomer(user);

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    customer: customerId,
    line_items: [{ price: priceId, quantity }],
    success_url: `${process.env.APP_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/payment/cancel`,
    metadata: { userId: String(user.id) },
  });

  // DB に pending レコードを作成
  const payment = new Payment();
  payment.userId = user.id;
  payment.stripeSessionId = session.id;
  payment.status = 'pending';
  payment.amount = 0; // Webhook で実際の金額に更新
  await paymentRepository.save(payment);

  res.json({ url: session.url, sessionId: session.id });
});

フロントエンドは、返ってきた url にユーザーをリダイレクトするだけでいい。Stripe のホスト型決済ページが表示され、カード入力や3Dセキュア認証は Stripe 側で処理される。PCI DSS の対応負荷が大幅に減るのが Checkout 方式の最大の利点だ。


7. サブスクリプション用エンドポイントの追加

定期課金が必要な場合は、modesubscription に変更するだけで対応できる。

router.post('/subscription', authMiddleware, async (req, res) => {
  const { priceId } = req.body;
  const user = req.user;

  const customerId = await getOrCreateCustomer(user);

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.APP_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/payment/cancel`,
  });

  res.json({ url: session.url, sessionId: session.id });
});

サブスクリプションの解約やプラン変更は、次のステップで実装する Customer Portal に任せるのが効率的だ。


8. Customer Portal の提供

Stripe の Customer Portal を使えば、サブスクリプションの解約・プラン変更・支払い方法の更新画面を自前で作る必要がなくなる。

router.post('/portal', authMiddleware, async (req, res) => {
  const user = req.user;

  if (!user.stripeCustomerId) {
    return res.status(400).json({ error: '決済履歴がありません' });
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.APP_URL}/mypage`,
  });

  res.json({ url: portalSession.url });
});

Customer Portal の表示内容は Stripe ダッシュボードの「Settings」→「Billing」→「Customer portal」でカスタマイズできる。


9. Webhook ハンドラーの実装(最重要)

Webhook は Stripe 決済統合で最も重要なパートだ。リダイレクトの成功/失敗だけでは決済の完了を保証できない。ユーザーがブラウザを閉じたり、通信が途切れたりしても、Webhook なら Stripe からサーバーへ直接通知が届く。

Webhook エンドポイントには2つの注意点がある。

  • 認証ミドルウェアを適用しない(Stripe からのリクエストであり、ユーザーのリクエストではない)
  • express.json() の前に raw body を取得する(署名検証に生のリクエストボディが必要)
// index.ts でルート登録する際、webhook だけ先に登録する
app.use('/api/webhook', express.raw({ type: 'application/json' }), webhookRouter);
app.use(express.json()); // 他のルートは通常の JSON パーサー
// routes/webhook.ts
router.post('/stripe', async (req, res) => {
  const sig = req.headers['stripe-signature'] as string;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook 署名検証に失敗:', err);
    return res.status(400).send('Webhook Error');
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      await paymentRepository.update(
        { stripeSessionId: session.id },
        {
          status: 'completed',
          amount: session.amount_total ?? 0,
          currency: session.currency ?? 'jpy',
          stripePaymentIntentId: session.payment_intent as string,
        }
      );
      break;
    }
    case 'checkout.session.expired': {
      const session = event.data.object as Stripe.Checkout.Session;
      await paymentRepository.update(
        { stripeSessionId: session.id },
        { status: 'expired' }
      );
      break;
    }
    case 'payment_intent.payment_failed': {
      const intent = event.data.object as Stripe.PaymentIntent;
      await paymentRepository.update(
        { stripePaymentIntentId: intent.id },
        { status: 'failed' }
      );
      break;
    }
    default:
      console.log(`未処理のイベント: ${event.type}`);
  }

  res.json({ received: true });
});

Webhook ハンドラーが 200 以外を返すと、Stripe は最大72時間、指数バックオフでリトライを繰り返す。ハンドラー内で例外が起きた場合でも、必ず res.json({ received: true }) を返すか、適切にエラーハンドリングすること。


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

ローカル開発環境では、Stripe CLI を使って Webhook を転送する。

# インストール(macOS)
brew install stripe/stripe-cli/stripe

# ログイン
stripe login

# ローカルサーバーに転送
stripe listen --forward-to localhost:3000/api/webhook/stripe

CLI が出力する whsec_ で始まる文字列を .envSTRIPE_WEBHOOK_SECRET に設定する。

テスト用のイベントを手動で発火させることもできる。

stripe trigger checkout.session.completed

11. 決済履歴・ステータス確認 API

フロントエンドから決済状況を確認するための API を追加する。

// 決済履歴(ページネーション付き)
router.get('/history', authMiddleware, async (req, res) => {
  const page = parseInt(req.query.page as string) || 1;
  const limit = parseInt(req.query.limit as string) || 20;

  const [payments, total] = await paymentRepository.findAndCount({
    where: { userId: req.user.id },
    order: { createdAt: 'DESC' },
    skip: (page - 1) * limit,
    take: limit,
  });

  res.json({
    payments,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  });
});

// 個別ステータス確認
router.get('/status/:sessionId', authMiddleware, async (req, res) => {
  const payment = await paymentRepository.findOne({
    where: {
      stripeSessionId: req.params.sessionId,
      userId: req.user.id,
    },
  });

  if (!payment) {
    return res.status(404).json({ error: '決済情報が見つかりません' });
  }

  res.json(payment);
});

12. 本番環境での Webhook 登録

ローカルテストが完了したら、本番用の Webhook エンドポイントを Stripe ダッシュボードに登録する。

Stripe ダッシュボードで「Developers」→「Webhooks」→「Add endpoint」を選択し、以下を設定する。

  • エンドポイント URL: https://your-domain.com/api/webhook/stripe
  • 監視するイベント: checkout.session.completed, checkout.session.expired, payment_intent.payment_failed, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed

登録後に表示される署名シークレット(whsec_)を本番環境の環境変数に設定する。


13. フロントエンド側の実装ポイント

バックエンド API が整ったら、フロントエンドでは以下の画面を用意する。

  1. 商品・プラン選択画面: ユーザーが商品を選び、POST /api/payment/checkout を呼んで Checkout URL を取得。取得した URL に window.location.href でリダイレクト
  2. 決済成功ページ (/payment/success): URL のクエリパラメータ session_id を使い、GET /api/payment/status/:sessionId でステータスを表示。ただし、このページの表示だけで注文確定とせず、あくまで Webhook での確定を正とすること
  3. 決済キャンセルページ (/payment/cancel): ユーザーへのご案内と、再度決済を試すリンクを表示
  4. 決済履歴ページ: GET /api/payment/history で一覧を取得・表示
  5. Customer Portal ボタン: マイページ等に配置し、POST /api/payment/portal で取得した URL に遷移

14. テストカードで動作確認

Stripe のテストモードでは、以下のカード番号で各シナリオを確認できる。

カード番号シナリオ
4242 4242 4242 4242正常な決済成功
4000 0000 0000 32203D セキュア認証が必要
4000 0000 0000 0002カード拒否
4000 0000 0000 9995残高不足

有効期限は未来の任意の日付、CVC は任意の3桁、郵便番号は任意の値でよい。


15. 本番移行前のセキュリティチェックリスト

本番移行前に以下を確認しておく。

  1. API キーを本番用(sk_live_)に切り替えたか。テストキーのまま本番公開しないこと
  2. Webhook の署名検証が有効かconstructEvent を省略すると、外部から偽の Webhook を送りつけられる
  3. 決済金額のサーバーサイド検証。フロントエンドから送られた金額をそのまま使わず、サーバー側で priceId から正しい金額を取得する設計にする
  4. レート制限の導入。決済エンドポイントへの連続リクエストを express-rate-limit 等で制限する
  5. HTTPS の強制。Stripe は Webhook の送信先として HTTP を拒否する。本番では必ず HTTPS を使う
  6. エラーログの整備。Webhook ハンドラーの失敗は DB の不整合に直結するため、エラー時の通知を仕組み化しておく

決済フロー全体像

最後に、決済処理の全体の流れを整理する。

フロントエンド          バックエンド (Express)          Stripe
    |                        |                          |
    |-- POST /checkout ----->|                          |
    |                        |-- Customer 取得/作成 --->|
    |                        |<-------------------------|
    |                        |-- Session 作成 --------->|
    |                        |<-------------------------|
    |                        |-- Payment を pending で保存
    |<-- { url } ------------|                          |
    |                        |                          |
    |-- url にリダイレクト --------------------------->|
    |                        |                   決済処理|
    |<-- success_url にリダイレクト ------------------|
    |                        |<-- Webhook 通知 ---------|
    |                        |-- Payment を completed に更新
    |                        |-- { received: true } --->|

リダイレクトによる成功通知と Webhook による確定通知は非同期だ。成功ページの表示は「おそらく成功」であり、注文の確定処理は必ず Webhook 側で行うこと。これが Stripe 統合における最も重要な設計原則になる。


まとめ

Express.js への Stripe 導入は、Checkout Session 方式を選べば PCI DSS 対応の大部分を Stripe に委任でき、実装コストを大幅に抑えられる。本記事で解説した15ステップを順に進めれば、単発決済・サブスクリプション・Customer Portal・Webhook の一通りの決済基盤が完成する。

次のステップとしては、返金処理(Refund API)の組み込み、サブスクリプションのステータス管理の拡充、管理者向けの決済ダッシュボード API の追加が考えられる。

目次