Stripe Webhookで「署名検証エラー」が本番だけ出る——rawBodyを読めない問題の全記録
Stripe連携の実装を終えて、ローカルのstripe listenでテストした。決済イベントが来た。Webhookエンドポイントが受け取った。ステータスは200。
「よし、動いた。デプロイしよう。」
本番にデプロイした翌朝、StripeのダッシュボードにはWebhookの失敗ログが並んでいた。全件400エラー。
Stripe Webhookの仕組みとrawBodyの話
Stripeは決済完了などのイベントが発生すると、設定したエンドポイントURLにPOSTリクエストを送ってくる。このとき、リクエストの正当性を確認するために署名検証が必要になる。
署名検証の仕組みはこうだ。
- Stripeはリクエストヘッダーに
Stripe-Signatureを付ける - このヘッダーには、リクエストボディの生のバイト列をもとに生成したHMAC署名が含まれている
- こちらのWebhookシークレット(
whsec_...)を使って同じ計算をして、Stripeの署名と一致すれば正規のリクエストと判断する
ポイントは「リクエストボディの生のバイト列」が必要なこと。
Next.jsのAPIルート(route handler)はデフォルトでリクエストボディをJSONとしてパースして、JavaScriptオブジェクトとして渡してくれる。これは通常便利な機能だが、Stripe Webhookの署名検証には困る。
JSONパースされたオブジェクトをJSON.stringify()で文字列に戻しても、バイトレベルでは元のボディと一致しない。スペースの有無、キーの順序、エスケープの違いなどで変わってしまう。
だから署名検証が失敗する。
なぜローカルでは通ったか
ローカル開発ではstripe listen --forward-to localhost:3000/api/webhooks/stripeを使っていた。
このコマンドはStripeのイベントをローカルに転送するが、転送時の処理がVercelの本番環境と微妙に異なる。また、ローカルのNode.jsサーバーとVercelのEdge Runtimeでは、リクエストの処理方法も違う。
「ローカルで通る = 本番でも通る」は成立しない。
これはStripeに限らず、Webhookを受ける実装全般に言えることだ。
修正方法: rawBodyを取得するために自動パースを無効にする
Next.js App RouterのRoute Handlerでは、以下の設定でリクエストボディの自動パースを無効にできる。
// app/api/webhooks/stripe/route.ts
export const config = {
api: {
bodyParser: false, // ← 自動JSONパースを無効化
},
};
ただし、App Router(route.ts)ではこの設定が効かない。config.api.bodyParserはPages RouterのAPIルート(pages/api/)専用の設定だ。
App RouterのRoute Handlerでは別の書き方が必要になる。
// app/api/webhooks/stripe/route.ts
import { NextRequest } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
// rawBodyを取得する
const rawBody = await request.text(); // ← JSON.json()ではなくtext()を使う
const signature = request.headers.get('stripe-signature');
if (!signature) {
return new Response('Missing signature', { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody, // ← 生の文字列を渡す
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return new Response('Invalid signature', { status: 400 });
}
// イベント処理
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object);
break;
// 他のイベント...
}
return new Response('OK', { status: 200 });
}
request.json()ではなくrequest.text()を使う。これだけで署名検証が通るようになった。
ついでに踏んだ: FastAPIでの204問題
同じプロジェクトでFastAPIのAPIサーバーも書いていた。Webhookのイベントをバックエンドで処理する設計で、StripeイベントをNext.jsで受けてFastAPIに転送する構成だった。
FastAPIで削除系のエンドポイントを書いたとき、こういうコードを書いた。
from fastapi import APIRouter, status
from pydantic import BaseModel
router = APIRouter()
class SubscriptionDeleteResponse(BaseModel):
message: str
@router.delete(
"/subscriptions/{subscription_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=SubscriptionDeleteResponse, # ← ここが問題
)
async def delete_subscription(subscription_id: str):
# サブスクリプション削除処理
pass
このコードはローカルで動いていた。uvicornを起動してもエラーにならない。
しかし本番デプロイ(Railway)後、APIサーバーが起動に失敗した。
ログを見るとこうなっていた:
AssertionError: Status code 204 is not allowed to have a response body
原因: HTTP 204 No Content は「レスポンスボディなし」を意味するステータスコードだ。response_modelを指定するとFastAPIがレスポンスのシリアライズを試みるが、204では矛盾が生じる。
FastAPIはこの矛盾をimport時のAssertionErrorとして検出する。uvicornでサーバーを起動するとき(from app.main import appの時点)に落ちる。
なぜローカルで気づかなかったか: ローカルでも同じエラーが出るはずなのだが、開発中はuvicorn --reloadで起動していて、エラーが出ても特定のエンドポイントにアクセスするまで気づかないケースがあった。本番では起動時に全エンドポイントのimportが走るため、即座に落ちた。
修正: 204を使うならresponse_modelを外す。レスポンスボディが必要なら200に変更する。
# 204にするなら response_model を削除
@router.delete(
"/subscriptions/{subscription_id}",
status_code=status.HTTP_204_NO_CONTENT,
# response_model は削除
)
async def delete_subscription(subscription_id: str):
pass
# または200にしてレスポンスを返す
@router.delete(
"/subscriptions/{subscription_id}",
response_model=SubscriptionDeleteResponse,
)
async def delete_subscription(subscription_id: str):
return {"message": "deleted"}
FastAPIのAPIに変更を加えたときは、デプロイ前にローカルで以下を実行する習慣をつけると良い。
cd apps/api && python3 -c "from app.main import app; print('OK')"
これが通ればimportエラーはない。デプロイ後に初めてエラーに気づく、という事態を防げる。
環境変数の確認漏れ問題
Stripe Webhookで詰まったもうひとつの原因が、環境変数の設定漏れだった。
ローカルでは.env.localに書いていた:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Vercelにデプロイしたとき、STRIPE_SECRET_KEYは設定していたが、STRIPE_WEBHOOK_SECRETを設定し忘れていた。
結果: Webhookの処理でprocess.env.STRIPE_WEBHOOK_SECRETがundefinedになり、stripe.webhooks.constructEvent()がエラーを出す。
エラーメッセージ:
Error: No webhook secret provided.
これ自体は分かりやすいエラーだが、ローカルのStripe CLIで使うWebhookシークレットと本番のWebhookシークレットは別物だということも要注意。
- ローカル:
stripe listen実行時に表示されるwhsec_...(セッションごとに変わる) - 本番: StripeダッシュボードのWebhookエンドポイント設定画面にある
whsec_...(固定)
本番Vercelには本番用のシークレットを設定する必要がある。
Webhookのべき等性について
Stripeのドキュメントには「Webhookは複数回届く可能性がある」と書いてある。ネットワーク障害や5xxレスポンスの場合、Stripeは一定時間後にリトライする。
つまりcheckout.session.completedが2回来ることがある。同じ決済に対して2回処理すると、データが二重に作成されたり、メールが2通送られたりする。
べき等性(同じ操作を何回実行しても結果が変わらない性質)を持たせる基本パターン:
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const sessionId = session.id;
// 処理済みかチェック
const existing = await db.processedEvents.findUnique({
where: { stripeSessionId: sessionId },
});
if (existing) {
console.log(`Already processed: ${sessionId}`);
return; // 二重処理を防ぐ
}
// 処理を実行
await createSubscription(session);
// 処理済みとして記録
await db.processedEvents.create({
data: { stripeSessionId: sessionId, processedAt: new Date() },
});
}
まとめ
| 問題 | 原因 | 解決策 |
|------|------|--------|
| 署名検証が本番だけ失敗 | Next.jsがボディをJSONパースしてしまう | request.text()でrawBodyを取得 |
| FastAPIが本番デプロイで起動失敗 | 204 + response_modelの組み合わせ禁止 | response_modelを外すか200に変更 |
| Webhook処理でundefined | 環境変数の設定漏れ(本番用シークレット) | Vercelに本番用STRIPE_WEBHOOK_SECRETを設定 |
| 決済処理が二重実行 | Stripeのリトライによる複数回配信 | べき等性チェック(処理済みIDの記録) |
Stripe Webhookの実装で最もハマりやすいのは「ローカルと本番の挙動差」だ。署名検証のためのrawBody取得は必須の知識として押さえておくと、移行時の苦労が減る。
チャプター生成AI
URL貼るだけ。AIがチャプターを自動生成。
YouTubeのURLをコピーして貼る
「生成する」を押す
概要欄にコピペして完了
月3回まで無料 · クレジットカード不要