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. サブスクリプション用エンドポイントの追加
定期課金が必要な場合は、mode を subscription に変更するだけで対応できる。
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_ で始まる文字列を .env の STRIPE_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 が整ったら、フロントエンドでは以下の画面を用意する。
- 商品・プラン選択画面: ユーザーが商品を選び、
POST /api/payment/checkoutを呼んで Checkout URL を取得。取得した URL にwindow.location.hrefでリダイレクト - 決済成功ページ (
/payment/success): URL のクエリパラメータsession_idを使い、GET /api/payment/status/:sessionIdでステータスを表示。ただし、このページの表示だけで注文確定とせず、あくまで Webhook での確定を正とすること - 決済キャンセルページ (
/payment/cancel): ユーザーへのご案内と、再度決済を試すリンクを表示 - 決済履歴ページ:
GET /api/payment/historyで一覧を取得・表示 - Customer Portal ボタン: マイページ等に配置し、
POST /api/payment/portalで取得した URL に遷移
14. テストカードで動作確認
Stripe のテストモードでは、以下のカード番号で各シナリオを確認できる。
| カード番号 | シナリオ |
|---|---|
4242 4242 4242 4242 | 正常な決済成功 |
4000 0000 0000 3220 | 3D セキュア認証が必要 |
4000 0000 0000 0002 | カード拒否 |
4000 0000 0000 9995 | 残高不足 |
有効期限は未来の任意の日付、CVC は任意の3桁、郵便番号は任意の値でよい。
15. 本番移行前のセキュリティチェックリスト
本番移行前に以下を確認しておく。
- API キーを本番用(
sk_live_)に切り替えたか。テストキーのまま本番公開しないこと - Webhook の署名検証が有効か。
constructEventを省略すると、外部から偽の Webhook を送りつけられる - 決済金額のサーバーサイド検証。フロントエンドから送られた金額をそのまま使わず、サーバー側で
priceIdから正しい金額を取得する設計にする - レート制限の導入。決済エンドポイントへの連続リクエストを
express-rate-limit等で制限する - HTTPS の強制。Stripe は Webhook の送信先として HTTP を拒否する。本番では必ず HTTPS を使う
- エラーログの整備。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 の追加が考えられる。