テストモードで通ったのに、本番で二重課金の問い合わせが来た
サブスクリプションの実装で一番怖いのは、テストモードのチェックリストを全部通過して安心した数日後に届く「二重に請求されています」という一通のメールでした。個人開発でメンバーシップ課金を運用していて、私が最初に痛感したのはこの点です。決済フローを組むこと自体は難しくありません。難しいのは、Stripe から届くイベントが「必ず一度だけ、順番どおりに来る」という暗黙の前提を、コードが勝手に置いてしまうことでした。
Stripe の Webhook は、同じイベントを複数回送ることがあります。ネットワークの都合で subscription.updated が subscription.created より先に着くこともあります。テストモードで手動クリックしているうちは、これらはまず起きません。起きるのは、実ユーザーが増えて、リトライとタイムアウトが日常になった本番だけです。
以下では、Rork Max と Stripe でサブスクリプションを立ち上げる基本手順をおさえたうえで、その「本番でだけ壊れる」層に絞って実装を掘り下げます。基本のつなぎ込みができている方は、前半を飛ばして「Webhookを冪等にする」から読み進めていただければと思います。
前提条件
このガイドに取り組む前に、以下の準備を整えておいてください。
Rork Max アカウント : 基本的な操作に慣れていることが望ましいです。はじめての方はStripe 決済連携ガイドから着手すると流れをつかみやすいです。
Stripe アカウント : 管理画面にアクセスでき、公開可能キーとシークレットキーを取得できる状態にしてください。
REST API と JSON の基礎 : Webhook の署名検証やイベント処理を読み解くうえで必要です。
データベース : ユーザーとサブスクリプション情報を保存します。Supabase / Firebase いずれでも構いません(バックエンドガイド参照)。
Stripe 側の準備
Rork Max で実装に入る前に、Stripe 側で商品・価格・キーを整えます。
商品と価格の作成
Stripe ダッシュボードで販売対象を定義します。
カタログ → 商品 を開く
新しい商品を追加 をクリック
商品名(例:「Pro Plan」)と説明を入力
価格を追加 から、金額・請求期間(月次)・繰り返し(Recurring)を設定
複数プラン(Basic / Pro / Enterprise など)を用意する場合は、この手順を繰り返します。あとで価格を変更したくなっても、既存の価格オブジェクトは編集せず、新しい価格を作って切り替えるのが安全です。過去の契約者が参照している価格を壊さずに済みます。
API キーとWebhook署名シークレット
開発者 → API キー で公開可能キーとシークレットキーを控えます。あわせて Webhooks セクションの署名シークレット(whsec_ で始まる値)の場所を確認しておきます。この署名シークレットが、後述する「そのリクエストが本当に Stripe から来たか」を検証する鍵になります。
Rork Max での接続とテーブル設計
Stripe の接続
Rork Max ダッシュボードで 統合 → 決済サービス → Stripe を選び、シークレットキーを貼り付けて接続します。
サブスクリプションテーブル
サブスクリプションの状態を保存するテーブルを作ります。ここで大切なのは、DB を「真実の源」にしないことです。真実は常に Stripe 側にあり、DB はその写像にすぎません。この前提を最初に決めておくと、後で照合ロジックを書くときに迷いません。
-- Supabase の例
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE ,
stripe_subscription_id TEXT UNIQUE NOT NULL ,
stripe_customer_id TEXT NOT NULL ,
plan_id TEXT NOT NULL ,
status TEXT NOT NULL , -- 'active','trialing','past_due','canceled','incomplete'
current_period_end TIMESTAMP NOT NULL ,
cancel_at_period_end BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMP DEFAULT NOW ()
);
-- イベント台帳: Webhookの冪等処理に使う
CREATE TABLE stripe_events (
event_id TEXT PRIMARY KEY , -- Stripe の evt_xxx
type TEXT NOT NULL ,
processed_at TIMESTAMP DEFAULT NOW ()
);
stripe_events テーブルが、この記事の中心になります。イベントIDを主キーにしておくだけで、二重配信を DB の一意制約で弾けるようになります。
Webhookを冪等にする
ここからが本番の核心です。素朴に書いた Webhook ハンドラは、同じ invoice.payment_succeeded を二度受け取ると、ポイント付与や課金ログを二重に走らせてしまいます。防ぐ考え方はシンプルで、「このイベントIDは処理済みか」を最初に確認し、処理済みなら即座に 200 を返して何もしないことです。
// Rork Max Webhook Handler(冪等版)
// エンドポイント: /webhooks/stripe
exports . handler = async ( event ) => {
const sig = event.headers[ 'stripe-signature' ];
// 1. 署名検証: 生ボディで検証する(パース済みJSONでは失敗する)
let stripeEvent;
try {
stripeEvent = stripe.webhooks. constructEvent (
event.body, sig, process.env. STRIPE_WEBHOOK_SECRET
);
} catch (err) {
// 検証失敗は 400 を返す。ここで 200 を返すとStripeが再送を止めてしまう
return { statusCode: 400 , body: `Signature verification failed` };
}
// 2. 冪等ガード: 既に処理済みなら何もせず 200
const inserted = await insertEventIfNew (stripeEvent.id, stripeEvent.type);
if ( ! inserted) {
return { statusCode: 200 , body: JSON . stringify ({ duplicate: true }) };
}
// 3. 種別ごとに処理(副作用はここだけ)
try {
switch (stripeEvent.type) {
case 'customer.subscription.created' :
case 'customer.subscription.updated' :
await upsertSubscription (stripeEvent.data.object);
break ;
case 'customer.subscription.deleted' :
await markCanceled (stripeEvent.data.object);
break ;
case 'invoice.payment_failed' :
await handlePaymentFailed (stripeEvent.data.object);
break ;
}
} catch (e) {
// 処理に失敗したら台帳から削除し、Stripeの再送に委ねる
await deleteEvent (stripeEvent.id);
return { statusCode: 500 , body: 'processing failed' };
}
return { statusCode: 200 , body: JSON . stringify ({ received: true }) };
};
// insertEventIfNew: INSERT ... ON CONFLICT DO NOTHING の結果で真偽を返す
async function insertEventIfNew ( id , type ) {
const res = await db. query (
`INSERT INTO stripe_events (event_id, type) VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING RETURNING event_id` ,
[id, type]
);
return res.rowCount === 1 ; // 1なら新規、0なら重複
}
ここには三つの判断が入っています。署名検証の失敗で 400 を返すのは、200 を返すと Stripe が「受理された」と解釈して再送を止めてしまうからです。冪等ガードを副作用の前 に置くのは、二重配信を副作用に到達させないためです。そして処理に失敗したときは台帳から削除して 500 を返し、Stripe の自動リトライに再処理を任せます。台帳に残したまま 500 を返すと、次の再送が「処理済み」と誤判定されて永久に処理されません。
順序逆転に備える
subscription.updated が subscription.created より先に届くことがあります。対策は、個別イベントの中身を信じすぎず、upsertSubscription の中で毎回 Stripe から最新状態を取り直すことです。イベントは「変化があった」という通知として扱い、値そのものは API で引き直します。
async function upsertSubscription ( subObject ) {
// イベント内の値ではなく、最新をStripeから取得する
const sub = await stripe.subscriptions. retrieve (subObject.id);
// 期間フィールドは新しいAPIバージョンで items 側へ移動している
const periodEnd = sub.items.data[ 0 ]?.current_period_end
?? sub.current_period_end;
await db. query (
`INSERT INTO subscriptions
(user_id, stripe_subscription_id, stripe_customer_id, plan_id, status, current_period_end, cancel_at_period_end)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (stripe_subscription_id) DO UPDATE
SET status=$5, current_period_end=$6, cancel_at_period_end=$7, updated_at=NOW()` ,
[sub.metadata.userId, sub.id, sub.customer, sub.items.data[ 0 ].price.id,
sub.status, new Date (periodEnd * 1000 ), sub.cancel_at_period_end]
);
}
current_period_end の取得に注意してください。Stripe は API バージョン 2025-03-31 以降、current_period_start / current_period_end をサブスクリプション本体からサブスクリプションアイテム側へ移しました。古い記事のコードをそのまま使うと、本番で undefined が入り、次回請求日の表示が壊れます。上記のように items を優先し、本体をフォールバックにしておくと、アカウントの API バージョンが混在していても安全です。
サブスクリプションの状態遷移を扱う
status を active か否かの二値で判定していると、支払い失敗の局面で必ず破綻します。Stripe のサブスクリプションは状態機械であり、少なくとも次の遷移を理解しておく必要があります。
状態 意味 アプリ側の扱い
trialing 無料トライアル中 機能は開放。トライアル終了日を表示
active 正常課金中 機能を開放
past_due 請求失敗・再試行中 猶予として開放しつつ、支払い更新を促す
incomplete 初回決済が未完了 まだ開放しない
canceled 解約済み 期末まで開放、以降は停止
判定は「開放してよい状態かどうか」を一箇所に集約します。散らばると、片方だけ直し忘れて権限が食い違います。
const GRANT_STATES = new Set ([ 'trialing' , 'active' , 'past_due' ]);
function canAccess ( subscription ) {
if ( GRANT_STATES . has (subscription.status)) return true ;
// 解約予約中でも期末まではアクセス可
if (subscription.status === 'canceled'
&& subscription.current_period_end > Date. now () / 1000 ) {
return true ;
}
return false ;
}
past_due を即座に締め出さないのは、カード更新の猶予を与えるためです。ここを厳しくしすぎると、一時的な決済失敗で優良な継続ユーザーを失います。Stripe の請求設定(Smart Retries と dunning メール)と組み合わせ、数日の再試行の間はアクセスを保つのが実務的な落としどころです。
作成・変更・キャンセルの実装
基本操作は次のとおりです。プラン変更では proration_behavior で日割りの扱いを明示します。
// プラン変更(日割りあり)
async function updatePlan ( subscriptionId , newPriceId ) {
const sub = await stripe.subscriptions. retrieve (subscriptionId);
return stripe.subscriptions. update (subscriptionId, {
items: [{ id: sub.items.data[ 0 ].id, price: newPriceId }],
proration_behavior: 'create_prorations'
});
}
// 期末解約(即時停止ではなく予約)
async function cancelAtPeriodEnd ( subscriptionId ) {
return stripe.subscriptions. update (subscriptionId, {
cancel_at_period_end: true
});
}
即時解約(stripe.subscriptions.cancel)は、原則として避けることをおすすめします。ユーザーは支払った期間の途中で機能を失うことに強い不満を持ちますし、返金対応の手間も増えます。cancel_at_period_end: true で期末解約を予約し、その間に再検討の導線を用意するほうが、解約率の観点でも穏当です。私はこの期末解約を既定にする設計を好みます。
Stripe CLI でローカル検証する
本番で初めて Webhook を動かすのは危険です。Stripe CLI を使うと、手元の開発サーバーへ実イベントを転送し、二重配信や失敗系まで再現できます。
# 手元のエンドポイントへイベントを転送(署名シークレットが払い出される)
stripe listen --forward-to localhost:3000/webhooks/stripe
# 別ターミナルで任意のイベントを発火
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted
stripe listen が表示する whsec_ を、ローカルの環境変数に設定してから検証します。冪等ガードのテストは、同じイベントを二度 trigger して、二度目が duplicate: true を返すことを確認するのが手早い方法です。ここまで確認してから本番へ出すと、冒頭のような問い合わせをほぼ防げます。
手数料を織り込んだ損益分岐
サブスクリプションは「売上 = 継続 × 単価」で語られがちですが、実際に手元に残る額は決済手数料に強く影響されます。少額・高頻度の課金ほど手数料の比率が効いてきます。日本国内カード決済の目安(3.6%)で、月額別に手数料と実受取を試算すると次のようになります。
月額 決済手数料(3.6%) 実受取/件 1,000円の粗利に必要な件数
¥500 ¥18 ¥482 約2.1件
¥1,000 ¥36 ¥964 約1.1件
¥3,000 ¥108 ¥2,892 約0.35件
この表から読み取れるのは、低単価プランでは手数料の絶対額より、失敗した再試行やチャージバックの対応コストのほうが利益を削るという点です。だからこそ past_due の扱いと dunning の設計が、単なる親切ではなく収益に直結します。私自身、数字を眺めていると、機能を作ることよりも「一度課金できたユーザーに、なるべく長く安心して続けてもらう」ことのほうが効くのだと実感します。
トラブルシューティング
Webhook が届かない : URL の登録を確認し、Stripe の Webhooks → イベント のログでレスポンスコードを見ます。400 が並ぶなら署名検証、500 が並ぶなら処理側の例外です。
署名検証が常に失敗する : パース済みの JSON ではなく、生のリクエストボディで検証しているかを確認します。ミドルウェアが自動で JSON 化していると、この一点で必ず失敗します。
解約したのにアクセスが残る : canAccess が current_period_end を見て期末まで開放している正常動作の可能性があります。期末を過ぎても残る場合は、subscription.deleted の処理が失敗していないかログを確認します。
照合ジョブで最後の穴をふさぐ
どれだけ Webhook を丁寧に書いても、配信が完全に失われる可能性はゼロにはなりません。最後の保険として、1日1回、Stripe を正として DB を突き合わせる照合ジョブを走らせておくと安心です。active のはずが DB で past_due のまま、あるいはその逆、といったズレを検出して直します。この一本があるだけで、「たまに権限がおかしい」という再現しにくい不具合の大半が消えます。
サブスクリプションは、作って終わりではなく、静かに動き続けてくれる状態を保つ仕事だと感じています。本番で一度つまずくと、その一つひとつの判断の意味が身にしみて分かります。この記事が、その最初のつまずきを一つでも減らせたなら嬉しいです。お読みいただきありがとうございました。