実際のプロダクトに決済を組み込むことは、プロバイダーのドキュメントに従うだけでは到底済みません。実際のお金がシステムを流れているとき、サイレントな障害はすべて問題になります — 二重請求、不整合なステータス、あるいは確定されなかった売上です。本記事では、Node.js アプリケーションで Stripe、Mercado Pago、Asaas といったプロバイダーの Webhook を受信・処理するために私が使っているアーキテクチャを紹介します。
本当の問題
Webhook は、決済プロバイダーがアプリケーションにイベントを通知する仕組みです — PIX の確定、サブスクリプションのキャンセル、ディスピュート(異議申し立て)の発生など。問題は、この仕組みが本質的に信頼できないということです。Webhook は重複して届いたり、順序が前後したり、遅延したり、あるいは単に届かないことがあります。
アプリケーションがこうしたシナリオに備えていなければ、顧客から「支払ったのにアクセスできない」とクレームが来たとき — あるいはもっと悪いことに、財務が数字が合わないと気づいたときに、ようやく問題に気づくことになります。
下の図は、本記事で構築していく完全なフローを示しています。
決済 Webhook のライフサイクル図 — 検証、冪等性、キュー、ワーカー、リトライ、リコンシリエーション署名検証
最初のセキュリティ層は、その Webhook が本当にプロバイダーから来たものかを検証することです。各プロバイダーはこれを異なる方法で実装しています。
- Stripe: タイムスタンプとボディの HMAC-SHA256 を含む
Stripe-Signatureヘッダーを送信します - Mercado Pago: ハッシュとクエリパラメータを含む
x-signatureを送信し、API 経由での検証が必要です - Asaas: ヘッダーにトークンを送信し、ダッシュボードで設定したものと照合する必要があります
ルールはシンプルです。署名を検証せずに Webhook を処理してはいけません。これがなければ、誰でもあなたのエンドポイントに POST を送って決済確定を偽装できてしまいます。
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text()
const signature = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
// 検証済みイベントを処理する
await processEvent(event)
return new Response('OK', { status: 200 })
}冪等性: 重複処理を避ける
決済プロバイダーは、2xx のレスポンスを受け取れなかった場合に Webhook を再送します。つまり、同じイベントが複数回届くことがあります。ハンドラーが冪等でなければ、顧客の残高を二重に加算したり、確認メールを2通送ってしまったりするかもしれません。
解決策は、イベント ID を保存し、処理する前にチェックすることです。
async function processEvent(event: PaymentEvent) {
// すでに処理済みかどうかを確認する
const existing = await db.webhookEvents.findUnique({
where: { eventId: event.id }
})
if (existing) {
return // 処理済みのためスキップ
}
// 処理前にイベントを記録する
await db.webhookEvents.create({
data: {
eventId: event.id,
provider: 'stripe',
type: event.type,
processedAt: new Date()
}
})
// 安全に処理する
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data)
break
case 'payment_intent.payment_failed':
await handlePaymentFailure(event.data)
break
}
}非同期処理: 即時 ack
重要なルールがひとつあります。できるだけ速く 200 を返すことです。ハンドラーの応答に時間がかかると(データベースの更新、メール送信、別の API の呼び出しなどのため)、プロバイダーは失敗したとみなして再送します — これがさらなる負荷と重複の可能性を引き起こします。
正しいパターンは、即時 ack + バックグラウンド処理です。
export async function POST(req: Request) {
const event = await validateAndParse(req)
if (!event) {
return new Response('Invalid', { status: 400 })
}
// 非同期処理のためにキューへ保存する
await db.webhookQueue.create({
data: {
eventId: event.id,
payload: JSON.stringify(event),
status: 'pending'
}
})
// 即座にレスポンスを返す
return new Response('OK', { status: 200 })
}
// 別のワーカーがキューを処理する
async function processQueue() {
const pending = await db.webhookQueue.findMany({
where: { status: 'pending' },
orderBy: { createdAt: 'asc' }
})
for (const item of pending) {
try {
await processEvent(JSON.parse(item.payload))
await db.webhookQueue.update({
where: { id: item.id },
data: { status: 'processed' }
})
} catch (err) {
await db.webhookQueue.update({
where: { id: item.id },
data: {
status: 'failed',
retryCount: { increment: 1 },
lastError: err.message
}
})
}
}
}順序が前後する Webhook
実際に頻繁に起こるシナリオがあります。payment.failed の Webhook が payment.created よりも先に届くというものです。あるいはプロバイダーが payment.succeeded より前に refund.completed を送ってくることもあります。システムが特定の順序に依存していると、壊れてしまいます。
これに対処する方法は2つあります。
- ステートマシン: 各決済ステータスに対して有効な遷移を定義します。イベントが無効な遷移を試みた場合(例: success より前の refund)、後で再処理するためにキューへ入れます。
- プロバイダーのタイムスタンプ: (受信時刻ではなく)イベントのタイムスタンプを使って、どの状態がより新しいかを判断します。現在の状態より古いイベントは無視します。
async function handlePaymentUpdate(event: PaymentEvent) {
const payment = await db.payments.findUnique({
where: { providerPaymentId: event.paymentId }
})
if (!payment) {
// 決済がまだ存在しないため、リトライ用にキューへ入れる
await enqueueForRetry(event)
return
}
// 現在の状態より古いイベントは無視する
if (event.timestamp <= payment.lastEventTimestamp) {
return
}
// 状態遷移を検証する
const validTransitions: Record<string, string[]> = {
pending: ['confirmed', 'failed', 'cancelled'],
confirmed: ['refunded', 'disputed'],
failed: ['pending'] // プロバイダーによるリトライ
}
if (!validTransitions[payment.status]?.includes(event.newStatus)) {
await enqueueForRetry(event)
return
}
await db.payments.update({
where: { id: payment.id },
data: {
status: event.newStatus,
lastEventTimestamp: event.timestamp
}
})
}PIX: プロバイダー間の違い
PIX はブラジルで最も広く使われている決済手段ですが、各プロバイダーは確定処理を異なる方法で実装しています。
- Stripe: ブラジルでは PIX をネイティブには提供していません(代替として銀行振込を使用します)
- Mercado Pago: PIX の確定は通常 Webhook 経由で数秒以内に届きますが、ピーク時には数分まで遅延することがあります
- Asaas: 確定には数秒から数分かかることがあり、確定の Webhook が作成の Webhook よりも先に届くことがあります
実際のところ、これは PIX において特定のイベント順序に依存できないことを意味します。システムは、作成より前に届く確定、長い遅延、そして Webhook が単に届かないケースに対して耐性を持つ必要があります。
リコンシリエーション: 状態が乖離したとき
上記のすべての保護策を講じても、アプリケーションのローカルの状態とプロバイダー側の状態が乖離する瞬間は出てきます。届かなかった Webhook、ハンドラーのバグ、ワーカーを数分間ダウンさせたデプロイなどです。
解決策は、定期的に実行されるリコンシリエーションジョブです。
async function reconcilePayments() {
// 30分以上 pending のままの決済を探す
const stalePayments = await db.payments.findMany({
where: {
status: 'pending',
createdAt: {
lt: new Date(Date.now() - 30 * 60 * 1000)
}
}
})
for (const payment of stalePayments) {
// プロバイダーに直接ステータスを問い合わせる
const providerStatus = await getProviderStatus(
payment.provider,
payment.providerPaymentId
)
if (providerStatus !== payment.status) {
await db.payments.update({
where: { id: payment.id },
data: { status: providerStatus }
})
// 監査用のログ
await db.reconciliationLog.create({
data: {
paymentId: payment.id,
previousStatus: payment.status,
newStatus: providerStatus,
reason: 'reconciliation_job'
}
})
}
}
}このジョブは最後のセーフティネットです。Webhook の喪失、ハンドラーのバグ、プロバイダーのダウンなど、すべてが失敗したときでも、システムが最終的に正しい状態へ収束することを保証します。
デッドレターキュー: 失敗したときどうするか
Webhook の処理が繰り返し失敗した場合(3〜5回の試行)、それはデッドレターキューへ送られるべきです — 手動での介入や調査を必要とするイベントのための別テーブルです。
async function processWithRetry(item: WebhookQueueItem) {
const MAX_RETRIES = 5
if (item.retryCount >= MAX_RETRIES) {
// デッドレターキューへ移動する
await db.deadLetterQueue.create({
data: {
eventId: item.eventId,
payload: item.payload,
lastError: item.lastError,
failedAt: new Date()
}
})
await db.webhookQueue.delete({
where: { id: item.id }
})
// チームへアラートを送る
await notify(`Webhook ${item.eventId} failed ${MAX_RETRIES}x and was moved to DLQ`)
return
}
// 指数バックオフで処理を試みる
try {
await processEvent(JSON.parse(item.payload))
} catch (err) {
const nextRetry = new Date(
Date.now() + Math.pow(2, item.retryCount) * 1000
)
await db.webhookQueue.update({
where: { id: item.id },
data: {
retryCount: { increment: 1 },
lastError: err.message,
nextRetryAt: nextRetry
}
})
}
}グレースフルデグラデーション
決済プロバイダーがダウンしているとき、アプリケーションが単に壊れるわけにはいきません。ユーザーは、確定に時間がかかっても、決済が処理中であることを知る必要があります。いくつかのプラクティスを挙げます。
- 中間ステータス: プロバイダーの確定を待っている間にユーザーに表示する
awaiting_confirmationのような状態を使います - 設定可能なタイムアウト: 確定が X 分以内に届かない場合は、サイレントに失敗させるのではなく
requires_reviewとしてマークします - プロバイダー間のフォールバック: あるプロバイダーがダウンしている場合は、代替として別の決済手段を提供します
- 明確なコミュニケーション: 永遠に続くローディングを表示するのではなく、ユーザーに実際のステータスを通知します
アーキテクチャのまとめ
本番環境での決済 Webhook の完全なアーキテクチャは、次のレイヤーに集約されます。
- 署名検証 — 認証されていない Webhook はすべて拒否する
- 冪等性 — イベント ID を保存し、重複を無視する
- 即時 ack — 200 を返してバックグラウンドで処理する
- ステートマシン — 状態遷移を検証し、順序が前後するイベントに対処する
- リコンシリエーション — プロバイダーと同期する定期ジョブ
- デッドレターキュー — 繰り返し失敗したイベントは調査へ回す
- グレースフルデグラデーション — プロバイダーが失敗してもシステムは動き続ける
各レイヤーは、その前のレイヤーに対するセーフティネットです。どれか単体で問題を解決するものはありません — 本番環境で実際のお金を扱うのに十分な信頼性をシステムにもたらすのは、その組み合わせです。
