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

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


目次

導入、実装手順

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

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

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


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

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

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


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

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

npm install stripe

SDK を初期化する際は、バージョンを明示的に固定しておくと、Stripe 側の変更で突然壊れるリスクを回避できる。

import Stripe from 'stripe';

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

環境変数の設定

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

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx

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


DB スキーマの設計

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

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

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

mysql> desc user;
+------------------+-----------------+------+-----+----------------------+--------------------------------------------------+
| Field            | Type            | Null | Key | Default              | Extra                                            |
+------------------+-----------------+------+-----+----------------------+--------------------------------------------------+
| id               | bigint unsigned | NO   | PRI | NULL                 | auto_increment                                   |
| name             | varchar(255)    | NO   |     | NULL                 |                                                  |
| email            | varchar(255)    | NO   | UNI | NULL                 |                                                  |
| password         | varchar(255)    | YES  |     | NULL                 |                                                  |
| createdAt        | datetime(6)     | NO   |     | CURRENT_TIMESTAMP(6) | DEFAULT_GENERATED                                |
| updatedAt        | datetime(6)     | NO   |     | CURRENT_TIMESTAMP(6) | DEFAULT_GENERATED on update CURRENT_TIMESTAMP(6) |
| deletedAt        | datetime(6)     | YES  |     | NULL                 |                                                  |
| stripeCustomerId | varchar(255)    | YES  | UNI | NULL                 |                                                  |
+------------------+-----------------+------+-----+----------------------+--------------------------------------------------+

次に、決済履歴を保存する 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)
);

mysql> desc payment;
+-----------------------+-----------------+------+-----+----------------------+--------------------------------------------------+
| Field                 | Type            | Null | Key | Default              | Extra                                            |
+-----------------------+-----------------+------+-----+----------------------+--------------------------------------------------+
| id                    | bigint unsigned | NO   | PRI | NULL                 | auto_increment                                   |
| userId                | bigint unsigned | NO   | MUL | NULL                 |                                                  |
| stripeSessionId       | varchar(255)    | NO   | UNI | NULL                 |                                                  |
| stripePaymentIntentId | varchar(255)    | YES  |     | NULL                 |                                                  |
| status                | varchar(50)     | NO   |     | NULL                 |                                                  |
| amount                | int unsigned    | NO   |     | NULL                 |                                                  |
| currency              | varchar(10)     | NO   |     | jpy                  |                                                  |
| description           | varchar(255)    | YES  |     | NULL                 |                                                  |
| createdAt             | datetime(6)     | NO   |     | CURRENT_TIMESTAMP(6) | DEFAULT_GENERATED                                |
| updatedAt             | datetime(6)     | NO   |     | CURRENT_TIMESTAMP(6) | DEFAULT_GENERATED on update CURRENT_TIMESTAMP(6) |
+-----------------------+-----------------+------+-----+----------------------+--------------------------------------------------+

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


Stripe Customer を作成してエンドポイントの作成

まずStripe が管理する「顧客オブジェクト」を作成します、顧客に紐付けて決済履歴・支払い方法・サブスクリプションなどを一元管理できる。

決済 API ルーター(payment.ts)checkout / subscription / portal / history / status

import { Router, Request, Response } from 'express'
import Stripe from 'stripe'
import { AppDataSource } from '../datasource'
import { User } from '../entities/User'
import { Payment } from '../entities/Payment'
import { authMiddleware } from '../middleware/auth'

const router = Router()

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2026-02-25.clover' as any, // バージョンを固定しないと、SDK アップデート時に挙動が変わるリスクがある
})

async function getOrCreateStripeCustomer(user: User): Promise<string> {
  // すでに Stripe Customer が作成済みであればその ID をそのまま返す(重複作成を防ぐ)
  if (user.stripeCustomerId) {
    return user.stripeCustomerId
  }

  /**
   * Stripe API に新しい Customer オブジェクトを作成するメソッド。
   * 作成に成功すると Customer オブジェクトが返され、customer.id に
   * "cus_xxxxxxxxxx" 形式の一意な顧客 ID が格納される。
   */
  const customer = await stripe.customers.create({
    email: user.email,
    name: user.name,
    metadata: { userId: String(user.id) },
  })

  const userRepo = AppDataSource.getRepository(User)
  user.stripeCustomerId = customer.id
  await userRepo.save(user)

  return customer.id
}

router.post('/checkout', authMiddleware, async (req: Request, res: Response) => {
  try {
    const { priceId, quantity = 1 } = req.body

    if (!priceId) {
      res.status(400).json({ error: 'priceId は必須です' })
      return
    }

    const userRepo = AppDataSource.getRepository(User)
    const user = await userRepo.findOne({ where: { id: req.user!.userId } })

    if (!user) {
      res.status(404).json({ error: 'ユーザーが見つかりません' })
      return
    }

    const customerId = await getOrCreateStripeCustomer(user)

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

    const paymentRepo = AppDataSource.getRepository(Payment)
    const payment = paymentRepo.create({
      userId: user.id,
      stripeSessionId: session.id,
      status: 'pending',
      amount: 0,
      currency: 'jpy',
    })
    await paymentRepo.save(payment)

    res.json({ url: session.url, sessionId: session.id })
  } catch (error) {
    console.error('Checkout error:', error)
    res.status(500).json({ error: '決済セッションの作成に失敗しました' })
  }
})

router.post('/subscription', authMiddleware, async (req: Request, res: Response) => {
  try {
    const { priceId } = req.body

    if (!priceId) {
      res.status(400).json({ error: 'priceId は必須です' })
      return
    }

    const userRepo = AppDataSource.getRepository(User)
    const user = await userRepo.findOne({ where: { id: req.user!.userId } })

    if (!user) {
      res.status(404).json({ error: 'ユーザーが見つかりません' })
      return
    }

    const customerId = await getOrCreateStripeCustomer(user)

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

    const paymentRepo = AppDataSource.getRepository(Payment)
    const payment = paymentRepo.create({
      userId: user.id,
      stripeSessionId: session.id,
      status: 'pending',
      amount: 0,
      currency: 'jpy',
      description: 'subscription',
    })
    await paymentRepo.save(payment)

    res.json({ url: session.url, sessionId: session.id })
  } catch (error) {
    console.error('Subscription error:', error)
    res.status(500).json({ error: 'サブスクリプションセッションの作成に失敗しました' })
  }
})

router.post('/portal', authMiddleware, async (req: Request, res: Response) => {
  try {
    const userRepo = AppDataSource.getRepository(User)
    const user = await userRepo.findOne({ where: { id: req.user!.userId } })

    if (!user) {
      res.status(404).json({ error: 'ユーザーが見つかりません' })
      return
    }

    if (!user.stripeCustomerId) {
      res.status(400).json({ error: 'Stripe顧客情報が見つかりません' })
      return
    }

    const session = await stripe.billingPortal.sessions.create({
      customer: user.stripeCustomerId,
      return_url: `${process.env.FRONTEND_URL}/payment`,
    })

    res.json({ url: session.url })
  } catch (error) {
    console.error('Portal error:', error)
    res.status(500).json({ error: 'ポータルセッションの作成に失敗しました' })
  }
})

router.get('/history', authMiddleware, async (req: Request, res: Response) => {
  try {
    const page = Math.max(1, parseInt(req.query.page as string) || 1)
    const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20))
    const skip = (page - 1) * limit

    const paymentRepo = AppDataSource.getRepository(Payment)
    const [payments, total] = await paymentRepo.findAndCount({
      where: { userId: req.user!.userId },
      order: { createdAt: 'DESC' },
      skip,
      take: limit,
    })

    res.json({
      payments,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    })
  } catch (error) {
    console.error('Payment history error:', error)
    res.status(500).json({ error: '決済履歴の取得に失敗しました' })
  }
})

router.get('/status/:sessionId', authMiddleware, async (req: Request, res: Response) => {
  try {
    const { sessionId } = req.params as { sessionId: string }
    const paymentRepo = AppDataSource.getRepository(Payment)
    const payment = await paymentRepo.findOne({
      where: {
        stripeSessionId: sessionId,
        userId: req.user!.userId,
      },
    })

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

    res.json({ payment })
  } catch (error) {
    console.error('Payment status error:', error)
    res.status(500).json({ error: '決済ステータスの取得に失敗しました' })
  }
})

export default router

Webhookの実装

Webhook は Stripe 決済統合で最も重要なパートだ。

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

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

  • 認証ミドルウェアを適用しない(Stripe からのリクエストであり、ユーザーのリクエストではない)
  • express.json() の前に raw body を取得する(署名検証に生のリクエストボディが必要)

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

import { Router, Request, Response } from 'express'
import Stripe from 'stripe'
import { AppDataSource } from '../datasource'
import { Payment } from '../entities/Payment'
import { User } from '../entities/User'

const router = Router()

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

router.post('/stripe', async (req: Request, res: Response) => {
  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 signature verification failed:', err)
    res.status(400).json({ error: 'Webhook署名の検証に失敗しました' })
    return
  }

  const paymentRepo = AppDataSource.getRepository(Payment)

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session

      const payment = await paymentRepo.findOne({
        where: { stripeSessionId: session.id },
      })

      if (payment) {
        payment.status = 'completed'
        payment.amount = session.amount_total ?? 0
        payment.currency = session.currency ?? 'jpy'
        payment.stripePaymentIntentId = (session.payment_intent as string) ?? undefined
        await paymentRepo.save(payment)
      }

      console.log(`Payment completed: session=${session.id}`)
      break
    }

    case 'checkout.session.expired': {
      const session = event.data.object as Stripe.Checkout.Session

      const payment = await paymentRepo.findOne({
        where: { stripeSessionId: session.id },
      })

      if (payment) {
        payment.status = 'expired'
        await paymentRepo.save(payment)
      }

      console.log(`Payment expired: session=${session.id}`)
      break
    }

    case 'payment_intent.payment_failed': {
      const paymentIntent = event.data.object as Stripe.PaymentIntent

      const payment = await paymentRepo.findOne({
        where: { stripePaymentIntentId: paymentIntent.id },
      })

      if (payment) {
        payment.status = 'failed'
        await paymentRepo.save(payment)
      }

      console.log(`Payment failed: intent=${paymentIntent.id}`)
      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      console.log(`Subscription cancelled: ${subscription.id}`)
      break
    }

    case 'invoice.payment_succeeded': {
      const invoice = event.data.object as Stripe.Invoice

      // 初回請求(checkout.session.completed で処理済み)はスキップ
      if (invoice.billing_reason === 'subscription_create') {
        console.log(`Initial invoice skipped: ${invoice.id}`)
        break
      }

      // 2回目以降の定期課金を Payment に記録
      // Stripe API 2026-02-25.clover 以降、invoice.subscription は廃止された。
      // サブスクリプション ID は invoice.parent.subscription_details.subscription に移動している。
      const subscriptionId = invoice.parent?.subscription_details?.subscription
      if (subscriptionId && invoice.customer) {
        const userRepo = AppDataSource.getRepository(User)
        const user = await userRepo.findOne({
          where: { stripeCustomerId: invoice.customer as string },
        })

        if (user) {
          // Stripe API 2026-02-25.clover 以降、invoice.payment_intent は廃止された。
          // PaymentIntent ID は confirmation_secret.client_secret の "_secret_" より前の部分から取得できる。
          // 例: "pi_abc123_secret_xyz" → "pi_abc123"
          const clientSecret = invoice.confirmation_secret?.client_secret
          const paymentIntentId = clientSecret?.split('_secret_')[0]

          const payment = paymentRepo.create({
            userId: user.id,
            stripeSessionId: `inv_${invoice.id}`,
            ...(paymentIntentId ? { stripePaymentIntentId: paymentIntentId } : {}),
            status: 'completed',
            amount: invoice.amount_paid ?? 0,
            currency: invoice.currency ?? 'jpy',
            description: 'subscription_renewal',
          })
          await paymentRepo.save(payment)
        }
      }

      console.log(`Invoice paid: ${invoice.id}`)
      break
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice
      console.log(`Invoice payment failed: ${invoice.id}`)
      break
    }

    default:
      console.log(`Unhandled event type: ${event.type}`)
  }

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

export default router

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

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

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

# ログイン
stripe login

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

Stripe ダッシュボードで本番 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_)を本番環境の環境変数に設定する。


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

バックエンド 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 に遷移

React Stripe.js

https://www.npmjs.com/package/@stripe/react-stripe-js

npm install @stripe/stripe-js @stripe/react-stripe-js

環境変数に Stripe の公開可能キーを設定

Stripe Provider コンポーネントを作成

このコンポーネントは、Stripe.js をロードして子コンポーネントで useStripe() などのフックを使えるようにするラッパー。

テストカードで動作確認

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

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

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


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

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

  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 の追加が考えられる。

目次