Menu

Webhook処理メカニズム

Webhookは決済システムの重要なコンポーネントであり、決済プロバイダーが決済イベントが発生したときにアプリケーションに積極的に通知できるようにします。このドキュメントは、StripeとCreemのWebhook処理メカニズムを詳しく説明します。

Webhook概要

Webhookとは?

Webhookは、決済プロバイダーがアプリケーションに送信するHTTP POSTリクエストで、以下のような決済関連イベントを通知します:

  • 決済成功
  • サブスクリプションの作成/更新/キャンセル
  • 返金処理
  • インボイス決済失敗

Webhookが必要な理由

  1. 信頼性: フロントエンドの検証が失敗した場合でも、Webhookにより注文が正しく処理されます
  2. リアルタイム: 決済プロバイダーはアプリケーションに即座に通知できます
  3. 完全性: 更新、キャンセルなど、すべての決済関連イベントを処理できます

Stripe Webhook

Webhookエンドポイント

URL: /api/stripe/webhook

メソッド: POST

署名検証

すべてのStripe Webhookリクエストは、セキュリティを確保するために署名を検証する必要があります:

export async function POST(req: Request) {
  const body = await req.text();
  const headersList = await headers();
  const sig = headersList.get('stripe-signature');
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
 
  if (!sig || !webhookSecret) {
    return apiResponse.badRequest('Webhookシークレットが設定されていません');
  }
 
  // 署名を検証
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
  } catch (err) {
    return apiResponse.badRequest(`Webhookエラー: ${err.message}`);
  }
 
  // イベントを処理
  await processWebhookEvent(event);
}

サポートされているイベントタイプ

システムは以下のStripe Webhookイベントを処理します:

1. checkout.session.completed

トリガー: ユーザーがチェックアウトを完了(一回限りの決済のみ)

処理ロジック

app/api/stripe/webhook/webhook-handlers.ts
export async function handleCheckoutSessionCompleted(
  session: Stripe.Checkout.Session
) {
  // 一回限りの決済のみを処理
  if (session.mode !== 'payment') return;
 
  const userId = session.metadata?.userId;
  const planId = session.metadata?.planId;
  const priceId = session.metadata?.priceId;
 
  // 注文記録を作成
  const orderData = {
    userId,
    provider: 'stripe',
    providerOrderId: session.payment_intent,
    orderType: 'one_time_purchase',
    status: 'succeeded',
    planId,
    priceId,
    amountTotal: toCurrencyAmount(session.amount_total),
    // ... その他のフィールド
  };
 
  const { order, existed } = await createOrderWithIdempotency(
    'stripe',
    orderData,
    session.payment_intent
  );
 
  if (!existed && order) {
    // 一回限りのクレジットを付与
    await upgradeOneTimeCredits(userId, planId, order.id);
  }
}

2. invoice.paid

トリガー: サブスクリプションインボイス決済成功(初回決済と更新を含む)

処理ロジック

app/api/stripe/webhook/webhook-handlers.ts
export async function handleInvoicePaid(invoice: Stripe.Invoice) {
  const subscriptionId = invoice.subscription as string;
  const customerId = invoice.customer as string;
 
  // サブスクリプションデータを同期
  await syncSubscriptionData(subscriptionId, customerId);
 
  // 更新インボイスの場合、注文を作成してクレジットを付与
  if (invoice.billing_reason === 'subscription_cycle') {
    const subscription = await stripe.subscriptions.retrieve(subscriptionId);
    const userId = subscription.metadata?.userId;
    const planId = subscription.metadata?.planId;
 
    // 更新注文を作成
    const orderData = {
      userId,
      provider: 'stripe',
      providerOrderId: invoice.payment_intent,
      orderType: 'subscription_renewal',
      status: 'succeeded',
      planId,
      subscriptionId,
      amountTotal: toCurrencyAmount(invoice.amount_paid),
      // ... その他のフィールド
    };
 
    const { order, existed } = await createOrderWithIdempotency(
      'stripe',
      orderData,
      invoice.payment_intent
    );
 
    if (!existed && order) {
      // サブスクリプションクレジットを付与(月額クレジットをリセット)
      const currentPeriodStart = subscription.current_period_start * 1000;
      await upgradeSubscriptionCredits(
        userId,
        planId,
        order.id,
        currentPeriodStart
      );
    }
  }
}

3. customer.subscription.created / customer.subscription.updated

トリガー: サブスクリプションが作成または更新された

処理ロジック

app/api/stripe/webhook/webhook-handlers.ts
export async function handleSubscriptionUpdate(
  subscription: Stripe.Subscription
) {
  const customerId = typeof subscription.customer === 'string'
    ? subscription.customer
    : subscription.customer.id;
 
  // サブスクリプションデータをデータベースに同期
  await syncSubscriptionData(subscription.id, customerId);
}

4. customer.subscription.deleted

トリガー: サブスクリプションがキャンセルまたは削除された

処理ロジック

app/api/stripe/webhook/webhook-handlers.ts
export async function handleSubscriptionUpdate(
  subscription: Stripe.Subscription,
  isDeleted: boolean = false
) {
  if (isDeleted) {
    // サブスクリプションステータスを同期
    await syncSubscriptionData(subscription.id, customerId);
 
    // 残りのサブスクリプションクレジットを取り消し
    const userId = subscription.metadata?.userId;
    if (userId) {
      await revokeRemainingSubscriptionCreditsOnEnd(
        'stripe',
        subscription.id,
        userId,
        subscription.metadata
      );
    }
  }
}

5. invoice.payment_failed

トリガー: サブスクリプションインボイス決済失敗

処理ロジック

app/api/stripe/webhook/webhook-handlers.ts
export async function handleInvoicePaymentFailed(
  invoice: Stripe.Invoice
) {
  const subscriptionId = invoice.subscription as string;
  const customerId = invoice.customer as string;
 
  // 決済失敗通知メールを送信
  await sendInvoicePaymentFailedEmail({
    invoice,
    subscriptionId,
    customerId,
    invoiceId: invoice.id
  });
}

6. charge.refunded

トリガー: 決済が返金された

処理ロジック

app/api/stripe/webhook/webhook-handlers.ts
export async function handleRefund(charge: Stripe.Charge) {
  const paymentIntentId = charge.payment_intent as string;
 
  // 元の注文を検索
  const originalOrder = await findOriginalOrderForRefund(
    'stripe',
    paymentIntentId
  );
 
  if (!originalOrder) {
    console.warn(`返金の元の注文が見つかりません: ${paymentIntentId}`);
    return;
  }
 
  // 返金注文が既に存在するか確認
  const refundId = charge.refunds.data[0]?.id;
  if (await refundOrderExists('stripe', refundId)) {
    return; // 既に処理済み
  }
 
  // 返金注文を作成
  const refundOrderData = {
    userId: originalOrder.userId,
    provider: 'stripe',
    providerOrderId: refundId,
    orderType: 'refund',
    status: 'succeeded',
    planId: originalOrder.planId,
    amountTotal: `-${toCurrencyAmount(charge.amount_refunded)}`,
    // ... その他のフィールド
  };
 
  await createOrderWithIdempotency('stripe', refundOrderData, refundId);
 
  // 元の注文ステータスを更新
  await updateOrderStatusAfterRefund(
    originalOrder.id,
    charge.amount_refunded,
    toCents(originalOrder.amountTotal)
  );
 
  // クレジットを取り消し
  if (originalOrder.orderType === 'one_time_purchase') {
    await revokeOneTimeCredits(
      charge.amount_refunded,
      originalOrder,
      refundId
    );
  } else {
    await revokeSubscriptionCredits(originalOrder);
  }
}

7. radar.early_fraud_warning.created

トリガー: Stripe Radarが潜在的な不正行為を検出

処理ロジック

app/api/stripe/webhook/webhook-handlers.ts
export async function handleEarlyFraudWarningCreated(
  warning: Stripe.Radar.EarlyFraudWarning
) {
  const chargeId = warning.charge as string;
  const charge = await stripe.charges.retrieve(chargeId);
 
  // 管理者警告メールを送信
  await sendFraudWarningAdminEmail({
    warningId: warning.id,
    chargeId,
    customerId: charge.customer as string,
    amount: charge.amount,
    currency: charge.currency,
    fraudType: warning.actionable ? 'Actionable' : 'Informational',
    chargeDescription: charge.description,
    actionsTaken: ['管理者にメールを送信']
  });
 
  // 不正行為として確認された場合、自動的に返金
  if (warning.actionable) {
    // 返金を実行
    const refund = await stripe.refunds.create({ charge: chargeId });
    
    // ユーザー返金通知を送信
    await sendFraudRefundUserEmail({
      charge,
      refundAmount: refund.amount
    });
  }
}

Creem Webhook

Webhookエンドポイント

URL: /api/creem/webhook

メソッド: POST

署名検証

Creem WebhookはHMAC-SHA256署名検証を使用します:

app/api/creem/webhook/route.ts
function verifySignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const crypto = require('crypto');
  const computedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return computedSignature === signature;
}
 
export async function POST(req: Request) {
  const rawBody = await req.text();
  const headersList = await headers();
  const signature = headersList.get('creem-signature');
  const secret = process.env.CREEM_WEBHOOK_SECRET;
 
  if (!verifySignature(rawBody, signature, secret)) {
    return apiResponse.badRequest('署名検証に失敗しました');
  }
 
  const payload = JSON.parse(rawBody);
  await processWebhookEvent(payload);
}

サポートされているイベントタイプ

1. checkout.completed

トリガー: 一回限りの決済が完了

処理ロジック

app/api/creem/webhook/handlers.ts
export async function handleCreemPaymentSucceeded(
  payload: CreemCheckoutCompletedEvent
) {
  const payment = payload.object;
  const order = payment.order;
 
  // 一回限りの決済のみを処理
  if (order.type !== 'onetime') return;
 
  const userId = payment.metadata.userId;
  const planId = payment.metadata.planId;
 
  // 注文記録を作成
  const orderData = {
    userId,
    provider: 'creem',
    providerOrderId: order.id,
    orderType: 'one_time_purchase',
    status: payment.status === 'completed' ? 'succeeded' : payment.status,
    planId,
    amountTotal: toCurrencyAmount(order.amount_paid),
    // ... その他のフィールド
  };
 
  const { order: insertedOrder, existed } = await createOrderWithIdempotency(
    'creem',
    orderData,
    order.id
  );
 
  if (!existed && insertedOrder) {
    // 一回限りのクレジットを付与
    await upgradeOneTimeCredits(userId, planId, insertedOrder.id);
  }
}

2. subscription.paid

トリガー: サブスクリプション決済成功(初回決済と更新を含む)

処理ロジック

app/api/creem/webhook/handlers.ts
export async function handleCreemInvoicePaid(
  payload: CreemSubscriptionPaidEvent
) {
  const subscription = payload.object;
  const lastTransaction = subscription.last_transaction;
  const orderId = lastTransaction.order;
 
  const userId = subscription.metadata.userId;
  const planId = subscription.metadata.planId;
 
  // 注文記録を作成
  const orderData = {
    userId,
    provider: 'creem',
    providerOrderId: orderId,
    orderType: 'recurring',
    status: lastTransaction.status === 'paid' ? 'succeeded' : lastTransaction.status,
    planId,
    subscriptionId: subscription.id,
    amountTotal: toCurrencyAmount(lastTransaction.amount_paid),
    // ... その他のフィールド
  };
 
  const { order: insertedOrder, existed } = await createOrderWithIdempotency(
    'creem',
    orderData,
    orderId
  );
 
  if (!existed && insertedOrder) {
    // サブスクリプションクレジットを付与
    const currentPeriodStart = new Date(subscription.current_period_start_date).getTime();
    await upgradeSubscriptionCredits(
      userId,
      planId,
      insertedOrder.id,
      currentPeriodStart
    );
  }
}

3. subscription.active / subscription.update

トリガー: サブスクリプションが有効化または更新された

処理ロジック

app/api/creem/webhook/handlers.ts
export async function handleCreemSubscriptionUpdated(
  payload: CreemSubscriptionUpdateEvent | CreemSubscriptionActiveEvent
) {
  const subscription = payload.object;
  
  // サブスクリプションデータを同期
  await syncCreemSubscriptionData(
    subscription.id,
    subscription.metadata
  );
}

4. subscription.canceled / subscription.expired

トリガー: サブスクリプションがキャンセルまたは期限切れ

処理ロジック

app/api/creem/webhook/handlers.ts
export async function handleCreemSubscriptionUpdated(
  payload: CreemSubscriptionCanceledEvent | CreemSubscriptionExpiredEvent,
  isDeleted: boolean = false
) {
  const subscription = payload.object;
 
  // サブスクリプションステータスを同期
  await syncCreemSubscriptionData(subscription.id, subscription.metadata);
 
  if (isDeleted) {
    // 残りのサブスクリプションクレジットを取り消し
    let userId = subscription.metadata?.userId;
    if (!userId) {
      // データベースからユーザーIDをクエリ
      const storeSubscription = await db
        .select({ userId: subscriptionsSchema.userId })
        .from(subscriptionsSchema)
        .where(eq(subscriptionsSchema.subscriptionId, subscription.id))
        .limit(1);
      userId = storeSubscription[0]?.userId;
    }
 
    if (userId) {
      await revokeRemainingSubscriptionCreditsOnEnd(
        'creem',
        subscription.id,
        userId,
        subscription.metadata
      );
    }
  }
}

5. refund.created

トリガー: 返金が作成された

処理ロジック

app/api/creem/webhook/handlers.ts
export async function handleCreemPaymentRefunded(
  payload: CreemRefundCreatedEvent
) {
  const refund = payload.object;
  const orderId = refund.order;
 
  // 返金注文が既に存在するか確認
  if (await refundOrderExists('creem', refund.id)) {
    return; // 既に処理済み
  }
 
  // 元の注文を検索
  const originalOrder = await findOriginalOrderForRefund('creem', orderId);
 
  if (!originalOrder) {
    console.warn(`返金の元の注文が見つかりません: ${orderId}`);
    return;
  }
 
  // 返金注文を作成
  const refundOrderData = {
    userId: originalOrder.userId,
    provider: 'creem',
    providerOrderId: refund.id,
    orderType: 'refund',
    status: 'succeeded',
    planId: originalOrder.planId,
    amountTotal: `-${toCurrencyAmount(refund.amount)}`,
    // ... その他のフィールド
  };
 
  await createOrderWithIdempotency('creem', refundOrderData, refund.id);
 
  // 元の注文ステータスを更新
  await updateOrderStatusAfterRefund(
    originalOrder.id,
    refund.amount,
    toCents(originalOrder.amountTotal)
  );
 
  // クレジットを取り消し
  if (originalOrder.orderType === 'one_time_purchase') {
    await revokeOneTimeCredits(
      refund.amount,
      originalOrder,
      refund.id
    );
  } else {
    await revokeSubscriptionCredits(originalOrder);
  }
}

Webhookテスト

Stripe Webhookテスト

  1. Stripe CLIを使用
# Stripe CLIをインストール
brew install stripe/stripe-cli/stripe
 
# ログイン
stripe login
 
# Webhookをローカルに転送
stripe listen --forward-to localhost:3000/api/stripe/webhook
 
# テストイベントをトリガー
stripe trigger checkout.session.completed
  1. Stripeダッシュボードを使用
  • Stripeダッシュボード > Developers > Webhooksに移動
  • Webhookエンドポイントを追加
  • テストイベントを送信

Creem Webhookテスト

  1. Creemダッシュボードを使用
  • Creemダッシュボード > Settings > Webhooksに移動
  • Webhook URLを追加
  • テストイベントを送信
  1. ローカルテスト

VS Code/CursorのPorts機能やngrokなどのツールを使用して、ローカルサービスをパブリックネットワークに公開します:

ngrok http 3000
# ngrokが提供するURLを使用してWebhookを設定

本番環境での検証

製品が公開された後、100%割引コードを作成して、決済フローが正しく動作しているかテストできます。