Stripeのテストモード
Stripeのテストモードは、「本番の決済を動かす前に、完全に安全な環境で課金フローを検証する」ための仕組みです。実際のお金は一切動きません。
ここでは、最短で動かす手順+実務でハマるポイントまでまとめます。
① テストモードの基本切り替え
まずはここが入口です。
-
Stripe Dashboard にログイン
-
右上の 「テストモード」トグルをON
👉 これで「完全に別の世界」に切り替わります
(本番データとは一切混ざらない)
② APIキーをテスト用に変更(重要)
Stripeはキーが全てです。
-
テスト用キー(使う)
-
pk_test_xxx(公開キー) -
sk_test_xxx(秘密キー)
-
-
本番用キー(絶対混ぜない)
-
pk_live_xxx -
sk_live_xxx
-
👉 よくあるミス
「フロントはtest、サーバーはlive」=事故確定
③ 商品・価格をテスト環境で作る
テストモード中に作る必要があります。
-
商品(Product)作成
-
価格(Price)設定(月額・年額など)
👉 注意
本番モードとは別なので
本番リリース時は“作り直し”になる
④ テストカードで決済する
Stripeは専用のテストカード番号を用意しています。
代表例:
-
成功するカード
4242 4242 4242 4242 -
失敗するカード
4000 0000 0000 9995 -
3Dセキュア対応
4000 0027 6000 3184
👉 入力情報(例)
-
有効期限:未来の日付
-
CVC:適当(例:123)
⑤ Webhookをテストする(ここが本番差分)
決済後の処理はWebhookで動きます。
例:
-
checkout.session.completed -
invoice.paid
ローカルでやる場合:
stripe listen --forward-to localhost:8787/webhook
👉 Stripe CLI を使うと便利
⑥ サブスクのテスト方法(重要)
サブスクは時間を待つ必要があるので工夫します。
方法①:テストクロック(おすすめ)
-
Stripe Test Clocks を使用
-
時間を一気に進めて請求イベントを発火できる
👉 これがないと「1ヶ月待つ」ことになる
⑦ よくあるミス(実務的に重要)
❌ ミス1:本番キー混在
→ テストなのに課金される
❌ ミス2:Webhook未設定
→ 決済成功してもDB更新されない
❌ ミス3:フロントだけ動いてる
→ サーバー処理未検証
❌ ミス4:3Dセキュア未テスト
→ 本番で決済失敗
⑧ 実務での正しい流れ(おすすめ)
① テストモードON
② 商品作成
③ Checkout実装
④ Webhook実装
⑤ テストカードで決済
⑥ DB更新確認
⑦ サブスク更新(Test Clock)確認
⑧ 本番キーに切り替え
まとめ(本質)
Stripeテストモードの本質はこれです:
👉 「決済ではなく“イベント処理”を検証すること」
-
決済成功 → DB更新
-
失敗 → リトライ処理
-
サブスク更新 → 権限維持
ここができていれば、本番でほぼコケません。
いいですね、その構成(Workers + D1 + Stripe)なら、**かなりシンプルに“そのまま動く最小構成”**を作れます。
まずは「テストモードで1回決済 → DB反映」まで一気に通します。
全体構成(超シンプル版)
-
/create-checkout→ Stripe Checkout作成 -
/webhook→ 決済成功を受け取る -
D1 → ユーザー状態を保存
① 事前準備
Stripe側
-
Stripe Dashboard でテストモードON
-
商品+Priceを作成(
price_xxxを控える)
② wrangler.toml
name = "stripe-saas"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "saas-db"
database_id = "YOUR_DB_ID"
③ D1テーブル
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT,
is_active INTEGER
);
④ Workerコード(コピペOK)
export default {
async fetch(req: Request, env: any) {
const url = new URL(req.url);
// ① Checkout作成
if (url.pathname === "/create-checkout") {
const stripe = await loadStripe(env.STRIPE_SECRET_KEY);
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
mode: "subscription",
line_items: [
{
price: env.STRIPE_PRICE_ID,
quantity: 1,
},
],
success_url: "http://localhost:8787/success",
cancel_url: "http://localhost:8787/cancel",
});
return Response.redirect(session.url, 303);
}
// ② Webhook
if (url.pathname === "/webhook") {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
const stripe = await loadStripe(env.STRIPE_SECRET_KEY);
let event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return new Response("Webhook Error", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object;
await env.DB.prepare(
"INSERT INTO users (id, email, is_active) VALUES (?, ?, 1)"
)
.bind(session.id, session.customer_email)
.run();
}
return new Response("ok");
}
return new Response("Hello Stripe SaaS");
},
};
// Stripe loader(Workers対応)
async function loadStripe(secret: string) {
const Stripe = (await import("stripe")).default;
return new Stripe(secret, {
apiVersion: "2023-10-16",
});
}
⑤ 環境変数
wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
wrangler secret put STRIPE_PRICE_ID
⑥ Webhook設定(重要)
ローカル開発:
stripe listen --forward-to localhost:8787/webhook
👉 Stripe CLI を使用
出てきた whsec_xxx を環境変数へ
⑦ テスト実行
ブラウザで:
http://localhost:8787/create-checkout
テストカード:
4242 4242 4242 4242
⑧ 成功するとこうなる
-
Stripe → 決済成功
-
Webhook発火
-
D1に保存
SELECT * FROM users;
👉 is_active = 1 ならOK
ここが“本質”
このテンプレで確認すべきは:
-
決済できたか ❌(重要じゃない)
-
Webhook → DB更新できたか ✅(最重要)
次にやるべき拡張
ここから一気に実用になります:
① ログイン連携(Kinde/Auth)
→ user_idと紐づけ
② サブスク解約対応
customer.subscription.deleted
③ アクセス制御
if (!user.is_active) {
return new Response("Payment required", { status: 402 });
}
いいテーマです。ここを曖昧にすると課金は成功してるのにユーザーが使えないという事故が起きます。
結論から言うと、設計はシンプルでOKです。
結論(推奨設計)
👉 「自分のユーザーID ↔ Stripe Customer ID を1対1で紐付ける」
users.id ←→ stripe_customer_id
これを“唯一の正”にします。
① なぜCustomerを軸にするのか
Stripeはイベントが全部こう来ます:
-
customer.subscription.created -
invoice.paid -
checkout.session.completed
👉 全部 “customer” を基準に動く
つまり:
👉 Customer IDを持ってないと何も追えない
② DB設計(D1)
最小でこうです:
ALTER TABLE users ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE users ADD COLUMN subscription_status TEXT;
おすすめ完成形:
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT,
stripe_customer_id TEXT UNIQUE,
subscription_status TEXT, -- active / canceled / past_due
current_period_end INTEGER
);
③ Checkout時に“ユーザーを埋め込む”(超重要)
ここが設計の核です。
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: PRICE_ID, quantity: 1 }],
customer_email: user.email,
metadata: {
user_id: user.id, // ← これが命
},
success_url: "...",
cancel_url: "...",
});
👉 ポイント
Stripeに「これは誰の決済か」を持たせる
④ Webhookで紐付ける
checkout.session.completedで確定します。
if (event.type === "checkout.session.completed") {
const session = event.data.object;
const userId = session.metadata.user_id;
const customerId = session.customer;
await DB.prepare(`
UPDATE users
SET stripe_customer_id = ?, subscription_status = 'active'
WHERE id = ?
`)
.bind(customerId, userId)
.run();
}
👉 これで完成:
User ←→ Customer が確定
⑤ 以降は全部Customer基準で処理
例えば:
サブスク更新
if (event.type === "invoice.paid") {
const customerId = event.data.object.customer;
await DB.prepare(`
UPDATE users
SET subscription_status = 'active'
WHERE stripe_customer_id = ?
`)
.bind(customerId)
.run();
}
解約
if (event.type === "customer.subscription.deleted") {
const customerId = event.data.object.customer;
await DB.prepare(`
UPDATE users
SET subscription_status = 'canceled'
WHERE stripe_customer_id = ?
`)
.bind(customerId)
.run();
}
⑥ よくある失敗(ここは強めに言います)
❌ metadata入れてない
→ 誰の決済かわからない → 詰み
❌ emailで紐付け
→ 将来必ずズレる(変更・重複)
❌ Customer作ってない
→ 毎回別人扱いになる
⑦ 強い設計(ワンランク上)
さらに安定させるなら:
① 事前にCustomer作る
const customer = await stripe.customers.create({
email: user.email,
metadata: { user_id: user.id },
});
👉 DBに保存しておく
② Checkout時に使う
customer: user.stripe_customer_id,
👉 これで完全に一貫性が保たれる
まとめ(重要な一行)
👉 「ユーザーIDをStripeに渡し、Customer IDをDBに保存する」
この往復ができていれば:
-
決済
-
更新
-
解約
-
再課金
全部コントロールできます。
いいところに来ています。
ただし最初に現実を一つだけ:フロントでのガードは意味がありません。必ずサーバー(Worker)側で遮断してください。
結論(設計の芯)
👉 「リクエストごとにDBで課金状態を検証して、ダメなら即ブロック」
Request → 認証 → DB確認 → OKなら通す / NGなら402
① 前提(必要な状態)
ユーザーはこうなっている想定:
subscription_status = 'active'
current_period_end > now
👉 これを満たさなければ 即ブロック
② middlewareの考え方(Workers)
Cloudflare Workers では「専用middleware機構」はないので、
👉 関数でラップする形が一番実務的です
③ コピペOK:未課金ブロックmiddleware
type Env = {
DB: D1Database;
};
// 認証済みユーザー取得(仮:ヘッダーから)
async function getUserId(req: Request): Promise<string | null> {
return req.headers.get("x-user-id"); // ← 本番はKinde等に置き換え
}
// 課金チェック
async function requirePaidUser(
req: Request,
env: Env
): Promise<{ ok: true; userId: string } | { ok: false; res: Response }> {
const userId = await getUserId(req);
if (!userId) {
return {
ok: false,
res: new Response("Unauthorized", { status: 401 }),
};
}
const user = await env.DB.prepare(
`SELECT subscription_status, current_period_end
FROM users
WHERE id = ?`
)
.bind(userId)
.first<any>();
if (!user) {
return {
ok: false,
res: new Response("User not found", { status: 404 }),
};
}
const now = Math.floor(Date.now() / 1000);
const isActive =
user.subscription_status === "active" &&
user.current_period_end &&
user.current_period_end > now;
if (!isActive) {
return {
ok: false,
res: new Response("Payment Required", { status: 402 }),
};
}
return { ok: true, userId };
}
④ 実際の使い方
export default {
async fetch(req: Request, env: Env) {
const url = new URL(req.url);
// 保護されたAPI
if (url.pathname === "/api/protected") {
const check = await requirePaidUser(req, env);
if (!check.ok) return check.res;
// 通過したら処理
return new Response(`Hello paid user: ${check.userId}`);
}
return new Response("public route");
},
};
⑤ Webhookと連動(超重要)
middlewareはDBを見ているだけなので、
👉 Webhookで正しく更新されていることが前提
例:
// invoice.paid
subscription_status = 'active'
current_period_end = 次回請求日
// subscription.deleted
subscription_status = 'canceled'
👉 ここがズレると「課金してるのに使えない」地獄になります
⑥ よくある事故(ここは強め)
❌ statusだけ見てる
→ past_dueでも通る → 不正利用
❌ period見てない
→ 期限切れユーザーが使える
❌ フロントだけ制御
→ API直叩きで突破される
⑦ 実務で強い設計
さらに一段上:
キャッシュ(高速化)
// KV or Cache APIで数秒キャッシュ
👉 毎回DB叩かなくて済む
権限レベル
plan = 'free' | 'pro' | 'enterprise'
👉 APIごとに制御可能
まとめ(本質)
👉 「課金状態は“毎リクエスト検証”が正解」
-
Stripeは非同期(Webhook)
-
DBが唯一の真実
-
middlewareで強制
これで「未課金ユーザー完全遮断」が成立します。
無料トライアルは「なんとなく付ける」と必ず破綻します。
先に芯を決めます。
👉 “課金なしでも使える期間”ではなく、
👉 “期限付きで active と同等扱いにする状態”として設計する
これがブレない設計です。
結論(状態設計)
free → trialing → active → past_due → canceled
👉 トライアル中はこう扱う:
trialing = active と同じ権限(ただし期限あり)
① DB設計(これで十分強い)
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT,
stripe_customer_id TEXT UNIQUE,
subscription_status TEXT, -- free / trialing / active / canceled
trial_end INTEGER, -- UNIX秒
current_period_end INTEGER
);
② トライアル開始パターン(2種類)
パターンA(おすすめ):Stripeに任せる
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: PRICE_ID, quantity: 1 }],
subscription_data: {
trial_period_days: 7,
},
metadata: {
user_id: user.id,
},
});
👉 メリット
-
自動で課金開始
-
Webhookも整合性あり
👉 デメリット
- カード登録が必要
パターンB:自前トライアル(カード不要)
await DB.prepare(`
UPDATE users
SET subscription_status = 'trialing',
trial_end = ?
WHERE id = ?
`)
.bind(now + 7 * 86400, user.id)
.run();
👉 メリット
- UXが良い(登録即使える)
👉 デメリット
- 不正利用対策が必要
③ middleware(トライアル対応版)
ここが一番重要です👇
async function requirePaidUser(req: Request, env: Env) {
const userId = await getUserId(req);
if (!userId) {
return { ok: false, res: new Response("Unauthorized", { status: 401 }) };
}
const user = await env.DB.prepare(`
SELECT subscription_status, trial_end, current_period_end
FROM users WHERE id = ?
`)
.bind(userId)
.first<any>();
if (!user) {
return { ok: false, res: new Response("User not found", { status: 404 }) };
}
const now = Math.floor(Date.now() / 1000);
const isTrial =
user.subscription_status === "trialing" &&
user.trial_end &&
user.trial_end > now;
const isActive =
user.subscription_status === "active" &&
user.current_period_end &&
user.current_period_end > now;
if (!isTrial && !isActive) {
return {
ok: false,
res: new Response("Payment Required", { status: 402 }),
};
}
return { ok: true, userId };
}
👉 これで
trial中も使える/期限切れたら即ブロック
④ Webhook設計(超重要)
Stripe利用時:
トライアル開始
if (event.type === "customer.subscription.created") {
const sub = event.data.object;
await DB.prepare(`
UPDATE users
SET subscription_status = 'trialing',
trial_end = ?
WHERE stripe_customer_id = ?
`)
.bind(sub.trial_end, sub.customer)
.run();
}
課金開始
if (event.type === "invoice.paid") {
const invoice = event.data.object;
await DB.prepare(`
UPDATE users
SET subscription_status = 'active',
current_period_end = ?
WHERE stripe_customer_id = ?
`)
.bind(invoice.lines.data[0].period.end, invoice.customer)
.run();
}
解約
if (event.type === "customer.subscription.deleted") {
await DB.prepare(`
UPDATE users
SET subscription_status = 'canceled'
WHERE stripe_customer_id = ?
`)
.bind(event.data.object.customer)
.run();
}
⑤ よくある失敗(重要)
❌ trialを別ロジックにする
→ バグの温床
👉 必ず middlewareで統一判定
❌ trial期限チェックしてない
→ 永久無料になる
❌ StripeとDBのズレ
→ 課金済みなのに使えない
⑥ 実務で強い設計(ワンランク上)
① トライアル1回制限
trial_used INTEGER DEFAULT 0
② 不正防止
-
IP制限
-
クッキー
-
メールドメイン制限
③ トライアル終了前リマインド
trial_end - 1日で通知
まとめ(本質)
👉 「trialもactiveも“使える状態”として同じ扱いにする」
違いはこれだけ:
trial = 無料だが期限あり
active = 有料で期限更新される