ClerkとStripeを連携させる無料トライアル付きサブスクリプション実装の作り方

この記事では、ClerkとStripeを連携させて無料トライアル付きのサブスクリプションシステムを実装する方法を解説しています。ユーザー新規登録時にStripeで顧客を作成し無料プランを付与、退会時に顧客情報を削除するなど、認証と課金を一元管理できます。Webhookの署名検証やユーザーメタデータの活用など、実装のポイントも紹介されています。

広告ここから
広告ここまで

目次

    SaaSやオンラインサービスを提供する多くの開発者が直面するのが、ユーザー認証とサブスクリプション課金の連携です。特にClerkとStripeを組み合わせることで、セキュアでスケーラブルな認証・課金システムを構築できますが、その実装には細かな注意点があります。今回は、ユーザーが新規登録した際に自動的に無料プランを付与し、その後有料プランへの切り替えやユーザー削除時の処理までを実装する方法を解説します。

    前提知識と環境設定

    この記事では以下の技術スタックを前提としています:

    • Remix (Cloudflare Pagesでデプロイ)
    • Clerk (認証サービス)
    • Stripe (決済サービス)
    • TypeScript

    同時に、以下の環境変数を設定する必要があります:

    CLERK_SECRET_KEY=sk_test_*****
    CLERK_PUBLISHABLE_KEY=pk_test_*****
    CLERK_WEBHOOK_SECRET=whsec_*****
    STRIPE_SECRET_KEY=sk_test_*****
    

    実装の全体像

    今回実装するのは以下のフローです:

    1. ユーザーがClerkで新規登録
    2. Webhook経由でユーザー作成イベントを受け取り
    3. Stripeで顧客(Customer)を作成し、無料プランのサブスクリプションを付与
    4. ClerkのユーザーメタデータにStripeのCustomer IDを保存
    5. ユーザーがアカウント設定でサブスクリプションを管理できるようにする
    6. ユーザーが退会した際にStripeの顧客情報も削除する

    このようにClerkとStripeを連携させることで、ユーザー管理と課金管理を一元化できます。

    Webhookの署名検証

    まず、ClerkからのWebhookリクエストが正当なものであることを検証するコードを実装します。この部分はセキュリティ上非常に重要です。

    import { WebhookEvent } from '@clerk/remix/ssr.server';
    import { ActionFunctionArgs, json } from '@remix-run/cloudflare';
    import { Webhook } from 'svix';
    import Stripe from 'stripe';
    import { createClerkClient } from '@clerk/remix/api.server'
    
    export async function action({ context, request }: ActionFunctionArgs) {
      const {
        CLERK_WEBHOOK_SECRET,
        STRIPE_SECRET_KEY,
      } = context.cloudflare.env;
      if (!CLERK_WEBHOOK_SECRET) {
        throw new Error(
          'Please add CLERK_WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local'
        );
      }
    
      const svixId = request.headers.get('svix-id');
      const svixTimestamp = request.headers.get('svix-timestamp');
      const svixSignature = request.headers.get('svix-signature');
    
      // If there are no headers, error out
      if (!svixId || !svixTimestamp || !svixSignature) {
        return new Response('Error occured -- no svix headers', {
          status: 400,
        });
      }
    
      const payload = await request.json();
      const body = JSON.stringify(payload);
      const webhook = new Webhook(CLERK_WEBHOOK_SECRET);
      let evt: WebhookEvent;
      try {
        evt = webhook.verify(body, {
          'svix-id': svixId,
          'svix-timestamp': svixTimestamp,
          'svix-signature': svixSignature,
        }) as WebhookEvent;
      } catch (err) {
        console.error('Error verifying webhook:', err);
        return new Response('Error occured', {
          status: 400,
        });
      }
      // ここまでが署名検証処理
      
      // 以降でイベントごとの処理を実装
    }
    

    このコードは、ClerkがSvixというライブラリを使用してWebhookの署名を生成していることを前提としています。ヘッダーからsvix-id、svix-timestamp、svix-signatureを取得し、Webhookライブラリを使って検証しています。

    ユーザー作成イベントの処理

    ユーザーがClerkで新規登録すると、user.createdイベントが発生します。このイベントを受け取り、Stripeで顧客を作成し、無料プランのサブスクリプションを付与します。

      const stripe = new Stripe(STRIPE_SECRET_KEY);
      const clerkClient = createClerkClient({ secretKey: context.cloudflare.env.CLERK_SECRET_KEY })
      try {
        if (evt.type === 'user.created') {
          const {
            id: newUserId,
            username,
            email_addresses: email,
            first_name: firstName,
            last_name: lastName,
          } = evt.data;
          const customerProps: Stripe.CustomerCreateParams = {
            metadata: {
              clerk_user_id: newUserId
            }
          };
          if (firstName || lastName) {
            customerProps.name = [firstName, lastName].filter(Boolean).join(' ');
          } else if (username) {
            customerProps.name = username;
          }
          if (email) {
            customerProps.email = email[0].email_address;
          }
          const customer = await stripe.customers.create(customerProps);
          const {
            data: [price],
          } = await stripe.prices.list({ lookup_keys: ['free'] });
          await stripe.subscriptions.create({
            customer: customer.id,
            items: [
              {
                price: price.id,
                quantity: 1,
              },
            ],
          });
    
          await clerkClient.users.updateUserMetadata(newUserId, {
            privateMetadata: {
              stripeCustomerId: customer.id
            }
          })
        }
        // ...この後にuser.deletedイベントの処理が続く
    

    ここでのポイント:

    1. ユーザー情報(名前、メールアドレスなど)をStripeの顧客情報として設定
    2. Stripeの顧客メタデータにClerkのユーザーIDを保存(後でユーザー検索に利用)
    3. 無料プランをlookup_keysで検索し、該当するPriceでサブスクリプションを作成
    4. ClerkのユーザーメタデータにStripeの顧客IDを保存(後でユーザーのサブスクリプション管理に利用)

    特に注意が必要なのは、ユーザーの入力情報が必ずしも全て存在するとは限らないため、filter(Boolean)などで適切に処理している点です。

    Stripe Customerの取得とCustomer Portalへのリダイレクト

    ユーザーがサブスクリプションを管理できるように、StripeのCustomer Portalへリダイレクトする機能を実装します。

    import { getAuth } from '@clerk/remix/ssr.server';
    import {
      LoaderFunction,
      LoaderFunctionArgs,
      redirect,
    } from '@remix-run/cloudflare';
    import Stripe from 'stripe';
    import { createClerkClient } from '@clerk/remix/api.server';
    
    export const loader: LoaderFunction = async (args: LoaderFunctionArgs) => {
      const { userId } = await getAuth(args, {
        // @ts-expect-error
        publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
      });
      if (!userId) {
        throw new Response('Unauthorized', {
          status: 401,
        });
      }
      const { CLERK_SECRET_KEY, STRIPE_SECRET_KEY } =
        args.context.cloudflare.env;
    
      const clerkClient = createClerkClient({ secretKey: CLERK_SECRET_KEY })
      const user = await clerkClient.users.getUser(userId)
      const stripeCustomerId = user.privateMetadata.stripe_customer_id as string
    
      const url = new URL(args.request.url);
      const baseUrl = `${url.protocol}//${url.host}`;
      const stripe = new Stripe(STRIPE_SECRET_KEY);
      console.log(userId)
    
      if (!stripeCustomerId) {
        throw new Response('Unauthorized', {
          status: 401,
        });
      }
    
      const billingPortalSession = await stripe.billingPortal.sessions.create({
        customer: stripeCustomerId,
        return_url: baseUrl,
      });
      if (billingPortalSession.url) {
        return redirect(billingPortalSession.url);
      }
      throw new Response('Internal server error', {
        status: 500,
      });
    };
    

    このコードでは:

    1. Clerkの認証情報からユーザーIDを取得
    2. ユーザーのmetadataからStripeの顧客IDを取得
    3. StripeのCustomer Portal Sessionを作成し、そのURLにリダイレクト

    これにより、ユーザーはプラン変更や支払い方法の管理などを行えるStripeのCustomer Portalに簡単にアクセスできます。

    ユーザー削除イベントの処理

    ユーザーがClerkからアカウントを削除した場合、対応するStripeの顧客情報も削除する必要があります。

        // ユーザー作成イベントの処理の後に続く
        } else if (evt.type === 'user.deleted') {
          const deletedUserId = evt.data.id;
          if (!deletedUserId) {
            throw new Error("user id not found")
          }
          
          const searchResult = await stripe.customers.search({
            query: `metadata['clerk_user_id']:'${deletedUserId}'`,
          });
          if (searchResult.data && searchResult.data.length > 1) {
            const stripeCustomer = searchResult.data.find(customer => customer.metadata.clerk_user_id === deletedUserId)
            if (stripeCustomer) {
              await stripe.customers.del(stripeCustomer.id)
            }
          }
        }
        return json({
          message: 'ok',
        });
    

    ここでの注意点:

    1. 削除イベントの時点ではClerkからユーザー情報を直接取得できないため、Stripeのメタデータに保存しておいたClerkのユーザーIDを使用
    2. Stripeの検索API(customers.search)を使ってメタデータから対象の顧客を検索
    3. 見つかった顧客情報を削除

    実装上の注意点

    メタデータの一貫性

    ClerkとStripeの間でユーザーIDと顧客IDを相互に参照できるようにしておくことが重要です。これにより、どちらのシステムからでも対応するエンティティを見つけることが可能になります。

    // Stripeの顧客メタデータにClerkのユーザーIDを保存
    const customerProps: Stripe.CustomerCreateParams = {
      metadata: {
        clerk_user_id: newUserId
      }
    };
    
    // ClerkのユーザーメタデータにStripeの顧客IDを保存
    await clerkClient.users.updateUserMetadata(newUserId, {
      privateMetadata: {
        stripeCustomerId: customer.id
      }
    });
    

    冪等性の確保

    Webhookは複数回送信される可能性があるため、同じイベントを何度処理しても問題ないように実装する必要があります。特にStripeの顧客作成やサブスクリプション作成は、既に存在するかどうかをチェックすることで重複を防げます。

    Stripeの価格設定

    無料プランを含むすべての価格(Price)は事前にStripeダッシュボードで設定し、lookup_keysを使って簡単に検索できるようにしておくことをお勧めします。

    // lookup_keyを使って価格を検索
    const {
      data: [price],
    } = await stripe.prices.list({ lookup_keys: ['free'] });
    

    まとめと次のステップ

    この記事では、ClerkとStripeを連携させて無料トライアル付きのサブスクリプションシステムを実装する方法を紹介しました。主なポイントは:

    1. Webhookの署名検証によるセキュリティ確保
    2. ユーザー作成時の自動的なStripe顧客登録と無料プラン付与
    3. メタデータを使った相互参照の実装
    4. ユーザー削除時の顧客情報クリーンアップ

    これらを組み合わせることで、ユーザーにとってシームレスでありながら、開発者にとって管理しやすいサブスクリプションシステムを構築できます。

    発展的な実装について

    さらに機能を拡張する場合は、以下のような実装を検討してみてください:

    1. プラン変更時のカスタムフロー: Stripeのビリングポータルではなく、独自のUI上でプラン変更を行えるようにする
    2. 利用制限の実装: プランに応じた機能制限やクォータの実装
    3. クーポンコードの対応: 割引やプロモーションのためのクーポンシステム
    4. チームプランの実装: 複数ユーザーを一つの支払いアカウントで管理する機能

    Clerk + Stripeの組み合わせは、初期実装はシンプルでありながら、ビジネスの成長に合わせて拡張性の高いシステムを構築できるのが魅力です。皆さんのサービスにぜひ取り入れてみてください!

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark