Webhook処理メカニズム
Webhookは決済システムの重要なコンポーネントであり、決済プロバイダーが決済イベントが発生したときにアプリケーションに積極的に通知できるようにします。このドキュメントは、StripeとCreemのWebhook処理メカニズムを詳しく説明します。
Webhook概要
Webhookとは?
Webhookは、決済プロバイダーがアプリケーションに送信するHTTP POSTリクエストで、以下のような決済関連イベントを通知します:
- 決済成功
- サブスクリプションの作成/更新/キャンセル
- 返金処理
- インボイス決済失敗
Webhookが必要な理由
- 信頼性: フロントエンドの検証が失敗した場合でも、Webhookにより注文が正しく処理されます
- リアルタイム: 決済プロバイダーはアプリケーションに即座に通知できます
- 完全性: 更新、キャンセルなど、すべての決済関連イベントを処理できます
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
トリガー: ユーザーがチェックアウトを完了(一回限りの決済のみ)
処理ロジック:
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
トリガー: サブスクリプションインボイス決済成功(初回決済と更新を含む)
処理ロジック:
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
トリガー: サブスクリプションが作成または更新された
処理ロジック:
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
トリガー: サブスクリプションがキャンセルまたは削除された
処理ロジック:
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
トリガー: サブスクリプションインボイス決済失敗
処理ロジック:
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
トリガー: 決済が返金された
処理ロジック:
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が潜在的な不正行為を検出
処理ロジック:
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署名検証を使用します:
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
トリガー: 一回限りの決済が完了
処理ロジック:
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
トリガー: サブスクリプション決済成功(初回決済と更新を含む)
処理ロジック:
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
トリガー: サブスクリプションが有効化または更新された
処理ロジック:
export async function handleCreemSubscriptionUpdated(
payload: CreemSubscriptionUpdateEvent | CreemSubscriptionActiveEvent
) {
const subscription = payload.object;
// サブスクリプションデータを同期
await syncCreemSubscriptionData(
subscription.id,
subscription.metadata
);
}4. subscription.canceled / subscription.expired
トリガー: サブスクリプションがキャンセルまたは期限切れ
処理ロジック:
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
トリガー: 返金が作成された
処理ロジック:
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テスト
- 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- Stripeダッシュボードを使用:
- Stripeダッシュボード > Developers > Webhooksに移動
- Webhookエンドポイントを追加
- テストイベントを送信
Creem Webhookテスト
- Creemダッシュボードを使用:
- Creemダッシュボード > Settings > Webhooksに移動
- Webhook URLを追加
- テストイベントを送信
- ローカルテスト:
VS Code/CursorのPorts機能やngrokなどのツールを使用して、ローカルサービスをパブリックネットワークに公開します:
ngrok http 3000
# ngrokが提供するURLを使用してWebhookを設定本番環境での検証
製品が公開された後、100%割引コードを作成して、決済フローが正しく動作しているかテストできます。