はじめに
Stripe の Webhook を実装していると、以下のようなエラーに突き当たることがあります。
StripeSignatureVerificationError: No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
または
Error: Webhook signature verification failed.
エラーメッセージには「raw request body を渡しているか?」と書かれていますが、「渡してるつもりなのになぜ…」となりがちです。この記事ではよくある原因を網羅的に解説します。
Stripe Webhook 署名検証の仕組み
まず仕組みを理解することが重要です。Stripe は Webhook リクエストを送信する際、リクエストボディと Webhook シークレットを組み合わせて HMAC-SHA256 署名を生成し、Stripe-Signature ヘッダーに付与します。
受信側では stripe.webhooks.constructEvent() を呼び出すことで、同じ計算を行い署名が一致するか確認します。
重要: この検証は バイト単位 で行われます。ボディを一度でも文字列変換・JSON パース・再シリアライズすると署名が合わなくなります。
原因1(最多): raw body ではなくパース済みのボディを渡している
Express で最もよく起きる原因です。express.json() などのミドルウェアが先にボディを解析してしまい、req.body がオブジェクトになってしまっています。
よくある間違いコード
// app.js(間違い)
app.use(express.json()) // ← ここで全ルートのボディがパースされる
app.post('/webhook', (req, res) => {
const sig = req.headers['stripe-signature']
// req.body はすでに JSON.parse 済みのオブジェクト → 署名検証が必ず失敗する
const event = stripe.webhooks.constructEvent(req.body, sig, ({}).STRIPE_WEBHOOK_SECRET)
})
正しい実装
// app.js(正しい)
app.use(express.json()) // 他のルート用
// Webhook ルートだけ raw body を取得する
app.post(
'/webhook',
express.raw({ type: 'application/json' }), // ← raw buffer として受け取る
(req, res) => {
const sig = req.headers['stripe-signature']
let event
try {
// req.body が Buffer になっている
event = stripe.webhooks.constructEvent(req.body, sig, ({}).STRIPE_WEBHOOK_SECRET)
} catch (err) {
console.error('Webhook verification failed:', err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// イベント処理
switch (event.type) {
case 'payment_intent.succeeded':
// 処理
break
}
res.json({ received: true })
}
)
ポイント: express.raw({ type: 'application/json' }) をルートレベルのミドルウェアとして Webhook ルートに適用することで、そのルートだけ raw buffer を受け取れます。
原因2: Next.js Pages Router で bodyParser を無効化し忘れている
Next.js の API Routes はデフォルトで body を自動パースします。Stripe Webhook ルートではこれを無効にする必要があります。
間違い(デフォルト設定のまま)
// pages/api/webhook.ts
import Stripe from 'stripe'
import type { NextApiRequest, NextApiResponse } from 'next'
const stripe = new Stripe(({}).STRIPE_SECRET_KEY!)
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const sig = req.headers['stripe-signature'] as string
// req.body はすでにパース済み → 失敗する
const event = stripe.webhooks.constructEvent(req.body, sig, ({}).STRIPE_WEBHOOK_SECRET!)
}
正しい実装
// pages/api/webhook.ts
import { buffer } from 'micro'
import Stripe from 'stripe'
import type { NextApiRequest, NextApiResponse } from 'next'
const stripe = new Stripe(({}).STRIPE_SECRET_KEY!)
// ← これが必須:Next.js の bodyParser を無効化する
export const config = {
api: {
bodyParser: false,
},
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const sig = req.headers['stripe-signature'] as string
const buf = await buffer(req) // raw buffer を取得
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(buf, sig, ({}).STRIPE_WEBHOOK_SECRET!)
} catch (err: any) {
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// イベント処理...
res.json({ received: true })
}
micro パッケージは Next.js に同梱されているため、追加インストール不要です。
原因3: Next.js App Router での落とし穴
App Router(Next.js 13+)では、req.json() を呼んではいけません。
間違い
// app/api/webhook/route.ts(間違い)
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(({}).STRIPE_SECRET_KEY!)
export async function POST(req: NextRequest) {
const body = await req.json() // ← NG: パース済みオブジェクトになる
const sig = req.headers.get('stripe-signature')!
// body はオブジェクト → 署名検証が失敗する
const event = stripe.webhooks.constructEvent(body, sig, ({}).STRIPE_WEBHOOK_SECRET!)
}
正しい実装
// app/api/webhook/route.ts(正しい)
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(({}).STRIPE_SECRET_KEY!)
export async function POST(req: NextRequest) {
const body = await req.text() // ← OK: 生のテキストとして取得
const sig = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, ({}).STRIPE_WEBHOOK_SECRET!)
} catch (err: any) {
console.error('Webhook error:', err.message)
return NextResponse.json({ error: err.message }, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session
// 処理
break
}
return NextResponse.json({ received: true })
}
App Router では req.text() または req.arrayBuffer() で raw body を取得します。
原因4: Webhook シークレットの取り違え
Stripe のシークレットには 2種類 あり、混同しやすいです。
| 種類 | 確認場所 | 形式 |
|---|---|---|
| Stripe CLI のシークレット | stripe listen 実行時に表示される |
whsec_ から始まる |
| Dashboard のシークレット | Stripe Dashboard > Webhooks > エンドポイント詳細 | whsec_ から始まる |
ローカル開発時は stripe listen --forward-to localhost:3000/api/webhook を実行し、そこで表示される whsec_... を環境変数に設定します。Dashboard のシークレットとは 別物 です。
# ローカル開発時
stripe listen --forward-to localhost:3000/api/webhook
# => Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxxxxx (^C to quit)
# ↑ これをローカルの .env.local に設定する
# .env.local
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxx # ← stripe listen で表示されたもの
# .env.production(本番)
STRIPE_WEBHOOK_SECRET=whsec_yyyyyyyyyyyyyy # ← Dashboard のエンドポイントのシークレット
よくある混在パターン
- 本番環境に CLI 用のシークレットを設定してしまっている
- テストモード用シークレットを本番モードで使っている
- 複数のエンドポイントを作って、別エンドポイントのシークレットを使っている
原因5: タイムスタンプ許容範囲のエラー
以下のエラーが出る場合、リクエストのタイムスタンプが古すぎます(デフォルトで 300秒 = 5分以内)。
StripeSignatureVerificationError: Timestamp outside the tolerance zone
これはリプレイ攻撃を防ぐための仕組みです。ローカル開発では発生しにくいですが、Stripe CLI でイベントを再送信した場合や、処理が重くて時間がかかっている場合に起きることがあります。
// テスト時のみ許容時間を延ばす(本番では使わない)
const event = stripe.webhooks.constructEvent(
body,
sig,
({}).STRIPE_WEBHOOK_SECRET!,
// 第4引数: タイムスタンプの許容秒数(デフォルト300秒)
// テスト時に大きな値を設定する場合
"production" === 'test' ? undefined : 300
)
本番では使いませんが、テスト環境で constructEvent の第4引数に undefined を渡すとタイムスタンプチェックを無効にできます(Stripe SDK v10+)。
デバッグ手順まとめ
署名検証エラーが出たら、以下の順番で確認しましょう。
// デバッグ用のログを追加する
export async function POST(req: NextRequest) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')
console.log('=== Stripe Webhook Debug ===')
console.log('body type:', typeof body) // "string" であること
console.log('body length:', body.length) // 0 でないこと
console.log('sig:', sig?.substring(0, 30)) // null でないこと
console.log('secret prefix:', ({}).STRIPE_WEBHOOK_SECRET?.substring(0, 10)) // whsec_xxx...
// ...
}
チェックポイント:
bodyが文字列またはバッファであること(オブジェクトでないこと)sigヘッダーがnullでないこと- Webhook シークレットが正しい環境のものか(ローカル vs 本番、テスト vs 本番)
- タイムスタンプが 5分以内か(
stripe listenで再送した場合は期限切れになりやすい)
まとめ
| エラーの原因 | 解決策 |
|---|---|
Express で req.body がパース済み |
express.raw({ type: 'application/json' }) をルートに適用 |
| Next.js Pages Router で自動パース | export const config = { api: { bodyParser: false } } |
Next.js App Router で req.json() 使用 |
req.text() または req.arrayBuffer() に変更 |
| Webhook シークレットの取り違え | CLI 用と Dashboard 用を混同しないよう環境変数を整理 |
| タイムスタンプが古い | Stripe CLI で再送した場合は再度 stripe trigger を実行 |
Stripe Webhook の署名検証エラーは「raw body を渡しているつもりなのに渡せていない」というケースがほぼ全てです。フレームワークのボディパーサーの挙動を把握し、Webhook ルートだけ別処理することで確実に解決できます。
