Stripe Billing Implementation: Subscriptions, Invoices, and Webhooks

Complete Stripe billing implementation: subscription checkout, invoice management, webhook event handling, refunds, failed payment recovery, and NestJS integration patterns.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分で読める1.5k 語数|

Stripe Billing の実装: サブスクリプション、請求書、Webhook

Stripe は課金インフラストラクチャの事実上の標準ですが、これを正しく実装するには (Webhook のべき等な処理、サブスクリプション状態の遷移の管理、失敗した支払いからの回復など)、入門ドキュメントで示されているよりもかなり注意が必要です。 99% の確率で機能し、残りの 1% で静かに失敗する決済システムは、95% で大声で失敗するシステムよりも悪いです。

このガイドでは、6 つの Webhook イベント タイプ、サブスクリプション ライフサイクル管理、自動ライセンス プロビジョニング、返金処理を使用した実稼働の Stripe 実装について説明します。コード例では NestJS 11 を使用していますが、パターンはあらゆるバックエンド フレームワークに適用されます。

重要なポイント

  • チェックアウト リダイレクトに基づいてアクセスをプロビジョニングしないでください。常に checkout.session.completed Webhook を待ちます。
  • イベントを処理する前に stripe.webhooks.constructEvent() を使用して Webhook 署名を検証する
  • すべての Webhook ハンドラーを冪等にする — Stripe は失敗した Webhook を最大 72 時間再試行します
  • Stripe 顧客 ID、サブスクリプション ID、価格 ID をデータベースに保存します。すべてについて Stripe に再クエリする必要はありません
  • invoice.payment_failed を処理して、督促メールと購読停止をトリガーします
  • charge.refundedcheckout.session.completed は、購入ライフサイクル全体をカバーします
  • Stripe の metadata フィールドを使用して、Stripe オブジェクトを内部レコードにリンクします
  • 生の Stripe Webhook ペイロードをログに記録しないでください。機密性の高いカード データが含まれています。

プロジェクトのセットアップ

pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
  typescript: true,
});

次の環境変数を保存します。

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...

チェックアウトセッションの作成

チェックアウト フロー: バックエンドが Stripe Checkout セッションを作成し、フロントエンドが Stripe にリダイレクトし、Stripe が支払いを回収してから、Webhook を呼び出します。決して自分で支払いを処理しないでください。常に Stripe Checkout にリダイレクトしてください。

// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';

@Injectable()
export class BillingService {
  async createCheckoutSession(
    userId: string,
    orgId: string,
    priceId: string,
    productId: string
  ) {
    // Get or create Stripe customer
    const customer = await this.getOrCreateCustomer(userId, orgId);

    const session = await stripe.checkout.sessions.create({
      customer: customer.stripeCustomerId,
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode: 'payment', // or 'subscription' for recurring
      success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.FRONTEND_URL}/services`,
      metadata: {
        userId,
        organizationId: orgId,
        productId,
      },
      allow_promotion_codes: true,
      invoice_creation: {
        enabled: true,
      },
    });

    return { url: session.url };
  }

  async createSubscriptionCheckout(
    userId: string,
    orgId: string,
    priceId: string,
    productId: string
  ) {
    const customer = await this.getOrCreateCustomer(userId, orgId);

    const session = await stripe.checkout.sessions.create({
      customer: customer.stripeCustomerId,
      payment_method_types: ['card'],
      line_items: [{ price: priceId, quantity: 1 }],
      mode: 'subscription',
      subscription_data: {
        metadata: {
          userId,
          organizationId: orgId,
          productId,
        },
        trial_period_days: 14,
      },
      success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.FRONTEND_URL}/pricing`,
      metadata: {
        userId,
        organizationId: orgId,
        productId,
      },
    });

    return { url: session.url };
  }

  private async getOrCreateCustomer(userId: string, orgId: string) {
    // Check if customer already exists in your DB
    const existing = await db.query.billingCustomers.findFirst({
      where: and(
        eq(billingCustomers.userId, userId),
        eq(billingCustomers.organizationId, orgId)
      ),
    });

    if (existing) return existing;

    // Fetch user details for Stripe customer creation
    const user = await db.query.users.findFirst({
      where: eq(users.id, userId),
    });

    const stripeCustomer = await stripe.customers.create({
      email: user.email,
      name: user.name,
      metadata: {
        userId,
        organizationId: orgId,
      },
    });

    const [customer] = await db
      .insert(billingCustomers)
      .values({
        userId,
        organizationId: orgId,
        stripeCustomerId: stripeCustomer.id,
      })
      .returning();

    return customer;
  }
}

Webhook ハンドラー

Webhook ハンドラーは、Stripe 統合の最も重要な部分です。すべてのイベントは冪等である必要があります。

// billing/webhook.controller.ts
import {
  Controller,
  Post,
  Req,
  Headers,
  RawBodyRequest,
  HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';

@Controller('billing/webhook')
export class WebhookController {
  constructor(private webhookService: BillingWebhookService) {}

  @Post()
  @Public()
  @HttpCode(200) // Always return 200 — Stripe retries on non-2xx
  async handleWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Headers('stripe-signature') signature: string
  ) {
    let event: Stripe.Event;

    try {
      event = stripe.webhooks.constructEvent(
        req.rawBody!, // Requires raw body middleware
        signature,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
    } catch (err) {
      // Invalid signature — do not process
      throw new Error(`Webhook signature verification failed: ${err.message}`);
    }

    // Route to specific handler
    switch (event.type) {
      case 'checkout.session.completed':
        await this.webhookService.handleCheckoutCompleted(
          event.data.object as Stripe.Checkout.Session
        );
        break;

      case 'invoice.paid':
        await this.webhookService.handleInvoicePaid(
          event.data.object as Stripe.Invoice
        );
        break;

      case 'invoice.payment_failed':
        await this.webhookService.handlePaymentFailed(
          event.data.object as Stripe.Invoice
        );
        break;

      case 'customer.subscription.deleted':
        await this.webhookService.handleSubscriptionDeleted(
          event.data.object as Stripe.Subscription
        );
        break;

      case 'customer.subscription.updated':
        await this.webhookService.handleSubscriptionUpdated(
          event.data.object as Stripe.Subscription
        );
        break;

      case 'charge.refunded':
        await this.webhookService.handleChargeRefunded(
          event.data.object as Stripe.Charge
        );
        break;

      default:
        // Ignore unhandled event types
        break;
    }

    return { received: true };
  }
}

重要: NestJS では、Stripe 署名検証のために NestFactory.create()rawBody: true が必要です。

// main.ts
const app = await NestFactory.create(AppModule, {
  rawBody: true,
});

チェックアウト完了ハンドラー

これはコア プロビジョニング ハンドラーです。冪等にします:

// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
  async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
    // Check if already processed (idempotency)
    const existing = await db.query.orders.findFirst({
      where: eq(orders.stripeSessionId, session.id),
    });

    if (existing) {
      return; // Already processed — Stripe is retrying
    }

    const { userId, organizationId, productId } = session.metadata!;

    // Create order in transaction
    await db.transaction(async (tx) => {
      // Create order
      const [order] = await tx
        .insert(orders)
        .values({
          userId,
          organizationId,
          productId,
          stripeSessionId: session.id,
          stripePaymentIntentId: session.payment_intent as string,
          amount: session.amount_total! / 100, // Stripe uses cents
          currency: session.currency!,
          status: 'completed',
        })
        .returning();

      // Generate license key
      const licenseKey = generateLicenseKey(productId);

      // Create license
      await tx.insert(licenses).values({
        orderId: order.id,
        userId,
        organizationId,
        productId,
        licenseKey,
        status: 'active',
        issuedAt: new Date(),
      });
    });

    // Send confirmation email (non-blocking — don't fail the webhook)
    this.emailService
      .sendPurchaseConfirmation(userId, productId)
      .catch((err) => this.logger.error('Email send failed', err));
  }

  async handleInvoicePaid(invoice: Stripe.Invoice) {
    // For subscriptions — renewal
    if (!invoice.subscription) return;

    // Find existing subscription/license and extend it
    const license = await db.query.licenses.findFirst({
      where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
    });

    if (!license) return;

    await db
      .update(licenses)
      .set({
        status: 'active',
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
      })
      .where(eq(licenses.id, license.id));

    // Log renewal in audit table
    await db.insert(licenseAuditLogs).values({
      licenseId: license.id,
      action: 'renewed',
      details: { invoiceId: invoice.id },
    });
  }

  async handlePaymentFailed(invoice: Stripe.Invoice) {
    if (!invoice.subscription) return;

    const license = await db.query.licenses.findFirst({
      where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
    });

    if (!license) return;

    // Grace period before suspension (e.g., 3 days)
    const gracePeriod = new Date();
    gracePeriod.setDate(gracePeriod.getDate() + 3);

    if (new Date() > gracePeriod) {
      await db
        .update(licenses)
        .set({ status: 'suspended' })
        .where(eq(licenses.id, license.id));
    }

    // Send dunning email
    this.emailService
      .sendPaymentFailedNotification(license.userId, invoice)
      .catch(console.error);
  }

  async handleChargeRefunded(charge: Stripe.Charge) {
    const order = await db.query.orders.findFirst({
      where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
    });

    if (!order) return;

    await db.transaction(async (tx) => {
      // Update order status
      await tx
        .update(orders)
        .set({ status: 'refunded' })
        .where(eq(orders.id, order.id));

      // Revoke associated licenses
      await tx
        .update(licenses)
        .set({ status: 'revoked', revokedAt: new Date() })
        .where(eq(licenses.orderId, order.id));
    });

    // Log audit trail
    await db.insert(licenseAuditLogs).values({
      licenseId: (await db.query.licenses.findFirst({
        where: eq(licenses.orderId, order.id),
      }))!.id,
      action: 'revoked',
      details: { reason: 'refund', chargeId: charge.id },
    });
  }

  async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
    await db
      .update(licenses)
      .set({ status: 'cancelled', cancelledAt: new Date() })
      .where(eq(licenses.stripeSubscriptionId, subscription.id));
  }

  async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
    const status = subscription.status === 'active' ? 'active' : 'suspended';

    await db
      .update(licenses)
      .set({ status })
      .where(eq(licenses.stripeSubscriptionId, subscription.id));
  }
}

カスタマーポータル

顧客がサポートに連絡せずに自分のサブスクリプションを管理できるようにします。

async createPortalSession(userId: string, orgId: string) {
  const customer = await db.query.billingCustomers.findFirst({
    where: and(
      eq(billingCustomers.userId, userId),
      eq(billingCustomers.organizationId, orgId)
    ),
  });

  if (!customer) {
    throw new NotFoundException('No billing account found');
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: customer.stripeCustomerId,
    return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
  });

  return { url: portalSession.url };
}

カスタマー ポータルを使用すると、ユーザーはカスタム UI の作業を行わずに、サブスクリプションのキャンセル、支払い方法の更新、請求書のダウンロード、請求履歴の表示を行うことができます。


フロントエンドの統合

// web: lib/billing.ts
'use client';

import { apiFetch } from '@/lib/api';

export async function initiateCheckout(priceId: string, productId: string) {
  const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
    method: 'POST',
    body: JSON.stringify({ priceId, productId }),
  });

  // Redirect to Stripe Checkout
  window.location.href = url;
}

export async function openCustomerPortal() {
  const { url } = await apiFetch<{ url: string }>('/billing/portal', {
    method: 'POST',
  });

  window.open(url, '_blank');
}

Webhook をローカルでテストする

Stripe CLI は、ローカル Webhook 開発に不可欠です。

# Install Stripe CLI
npm install -g stripe

# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted

# View event logs
stripe events list

CLI はローカル Webhook シークレットを提供します。これを .env.local で使用します。

STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output

よくある落とし穴と解決策

落とし穴 1: Webhook ではなくチェックアウト リダイレクトでのアクセスのプロビジョニング

チェックアウト成功のリダイレクトは、ブラウザーの戻る/進む、タブの複製、またはネットワークの再試行によってトリガーされます。リダイレクト URL ではなく、常に checkout.session.completed Webhook に基づいてプロビジョニングしてください。

落とし穴 2: Webhook の再試行を処理しない (べき等性)

Stripe は失敗した Webhook を 72 時間再試行します。冪等性チェックを行わないと、再試行すると重複した注文、ライセンス、電子メールが作成されます。 Stripe セッション ID を一意の制約を付けて注文テーブルに保存します。重複した挿入は正常に失敗します。

落とし穴 3: raw ボディ ミドルウェアの誤った使用

Stripe 署名の検証には、未加工の未解析のリクエスト本文が必要です。 Express の JSON ボディ パーサーが最初に実行されると、生のボディは失われます。 NestJS で rawBody: true を使用して req.rawBody にアクセスするか、特に Webhook ルートの生の本文を保存するように JSON ミドルウェアを構成します。

落とし穴 4: Stripe API 呼び出しの再試行ロジックが欠落している

Stripe API 呼び出しは一時的に失敗することがあります。冪等操作の再試行ロジックを追加します。

import Stripe from 'stripe';

async function retryStripeOperation<T>(
  operation: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (err) {
      if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
        await new Promise((r) => setTimeout(r, 1000 * attempt));
        continue;
      }
      throw err;
    }
  }
  throw new Error('Max retries exceeded');
}

よくある質問

Stripe Checkout またはカスタム支払いフォームを使用する必要がありますか?

特定の要件がない限り、Stripe Checkout を常に使用してください (まれです)。 Checkout は、PCI 準拠、3D セキュア認証、Apple Pay、Google Pay、SEPA、その他多数の支払い方法を自動的に処理します。カスタム支払いフォーム (Stripe Elements を使用) では、自分で PCI 準拠を維持し、各支払い方法を手動で実装する必要があります。 SaaS 製品の 95% では、Checkout が正しい選択です。

Stripe のテスト モードとライブ モードをどのように処理すればよいですか?

別々の環境構成を使用します: 開発環境では STRIPE_SECRET_KEY=sk_test_...、実稼働環境では STRIPE_SECRET_KEY=sk_live_... です。ストライプ テスト モードはライブ モードから完全に分離されています。テスト料金がリアルマネーに影響することはなく、テスト モードではライブ データが表示されません。コード内で stripe.mode を使用して、正しいキーを使用していることを確認し、現在どのモードであるかを示す目立つインジケーターを管理 UI に追加します。

Webhook サーバーがダウンし、Stripe が配信できない場合はどうなりますか?

Stripe は、最大 72 時間の指数バックオフ スケジュールで失敗した Webhook を再試行します (5 分、30 分、1 時間、2 時間と間隔を増やしていきます)。 72 時間が経過すると、イベントは失敗したとみなされます。 Stripe のイベント ダッシュボードを使用して、失敗したイベントを手動で再配信するか、stripe.events.list() をクエリして再実行します。ハンドラーを冪等になるように設計して、再配信が安全であるようにします。

従量制/使用量ベースの課金を実装するにはどうすればよいですか?

Stripe Billing は、stripe.subscriptionItems.createUsageRecord() を介した従量制使用量をサポートしています。各請求期間の終了時に使用量を消費量とともにレポートします。 Stripe ダッシュボードで従量制プランの価格を「従量制」価格として設定します。重要な実装の詳細: 請求期間が終了する前ではなく、使用状況をレポートします。Stripe は期間終了時に請求書を完成させます。

Stripe ID をデータベースに保存するにはどうすればよいですか?

すべてのストライプ ID を text 列 (数値ではない) として保存します。 stripe_customer_idstripe_subscription_idstripe_payment_intent_id にインデックスを作成します。これらによってレコードを頻繁に検索します。必要に応じて一意の制約を追加します (ユーザーごとに 1 つの顧客レコード)。クレジット カード番号や未加工の支払いデータを独自のデータベースに保存しないでください。


次のステップ

Webhook 冪等性、サブスクリプションのライフサイクル管理、自動プロビジョニング、適切な返金処理などを備えた Stripe 課金を正しく実装することは、簡単なエンジニアリング作業ではありません。 ECOSIRE は、自動ライセンス プロビジョニングとカスタマー ポータルの統合により、本番環境で 6 つの Stripe Webhook イベント タイプを処理します。

Stripe の統合、サブスクリプション課金インフラストラクチャ、またはフルスタック SaaS 開発サポートが必要な場合は、開発サービスをご覧ください

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット