Stripe Webhookでサブスクリプション解約時の自動ダウングレードを実装する (Supabase + Next.js/Astro)

個人開発SaaSなどでStripeのサブスクリプションを導入する際、「ユーザーが解約ボタンを押した後、有効期限が切れたタイミングで自動的に無料プランに戻す」という処理は意外と見落としがちです。

今回は、Astro (Next.js等でも同様) + Supabase + Stripeの構成で、この自動ダウングレード処理をWebhookを使って実装する方法をまとめました。

課題:解約即ダウングレードではない

Stripeの仕様上、ユーザーが「キャンセル」しても、基本的には「期間終了時にキャンセル(cancel_at_period_end: true)」という扱いになります。つまり、ユーザーはお金を払った期間内はPremiumプランのまま使い続けられるべきです。

そのため、解約ボタン押下時にアプリ側のDBを即座に is_premium: false に更新してはいけません。「有効期限が切れて完全に解約状態になった瞬間」を検知して更新する必要があります。

これを実現するのがStripe Webhookの customer.subscription.deleted イベントです。

実装ステップ

1. データベースの準備

まず、StripeからのWebhookイベント(customer.subscription.deleted)には、アプリ側の user_id は含まれておらず、Stripe側の customer_id しか含まれていません。 そのため、アプリ側のユーザーテーブル(profiles)に stripe_customer_id を保存しておく必要があります。

plain text
-- profilesテーブルにstripe_customer_idカラムを追加
altertable profilesadd columnifnotexists stripe_customer_idtext;

-- 検索用にインデックスを作成
createindexifnotexists idx_profiles_stripe_customer_idon profiles(stripe_customer_id);

2. Webhookハンドラの実装

Webhookのエンドポイント(/api/stripe-webhook)で、以下の2つの処理を行います。

  1. 購入完了時 (checkout.session.completed)stripe_customer_id を保存する
  2. 完全解約時 (customer.subscription.deleted): プランをダウングレードする

RLS(Row Level Security)が有効なテーブルを操作するため、supabase-admin クライアント(Service Role Key使用)を利用する点に注意してください。

plain text
import {getSupabaseAdmin }from'../../lib/supabase-admin';
importStripefrom'stripe';

exportconstPOST:APIRoute=async ({ request })=> {
// ... 署名検証などの前処理 ...

// RLSをバイパスするためにAdminクライアントを使用
constsupabaseAdmin=getSupabaseAdmin();

// 1. 購入完了時の処理
if (event.type==='checkout.session.completed') {
constsession=event.data.objectasStripe.Checkout.Session;
constcustomerId=session.customerasstring;
constuserId=session.client_reference_id;// Checkout作成時に渡しておく

if (session.payment_status==='paid') {
// is_premiumをtrueにしつつ、customer_idも紐付けて保存
awaitsupabaseAdmin
                .from('profiles')
                .update({
                    is_premium:true,
                    stripe_customer_id:customerId
                })
                .eq('id',userId);
        }
    }

// 2. 完全解約(期間終了)時の処理
if (event.type==='customer.subscription.deleted') {
constsubscription=event.data.objectasStripe.Subscription;
constcustomerId=subscription.customerasstring;

if (customerId) {
console.log(`Processing subscription deletion for customer:${customerId}`);

// customer_id をキーにしてユーザーを特定し、ダウングレード
awaitsupabaseAdmin
                .from('profiles')
                .update({ is_premium:false })
                .eq('stripe_customer_id',customerId);

console.log(`Successfully downgraded user with customer ID:${customerId}`);
        }
    }

returnnewResponse(JSON.stringify({ received:true }), { status:200 });
};

3. ポイント

  • checkout.session.completed で紐付け: ユーザーが最初に課金したタイミングで、StripeのCustomer IDを確実に保存しておきます。これがないと、解約イベントが飛んできた時に「誰の解約か」がわかりません。
  • customer.subscription.deleted を使う: ユーザーがキャンセル操作をした時点では customer.subscription.updated が飛びますが、ステータスはまだ active のまま(cancel_at_period_end: true)です。完全に終了した時に飛んでくる deleted イベントを待つのが正解です。
  • Admin権限でのDB操作: Webhookはサーバー間通信なので、ログインユーザーのセッションはありません。supabase-admin などを使って権限をバイパスして書き込む必要があります。

まとめ

これで「解約しても期間内は使えて、期間終了後に自動で無料プランに戻る」という、ユーザーに優しく管理コストの低いサブスクリプション機能が実現できました。