5/13 ローンチ予定!
PiloTube

PiloTube 開発日誌

← 「ひとり社長のAI開発記」一覧へ

Stripe Webhookで「署名検証エラー」が本番だけ出る——rawBodyを読めない問題の全記録

約11分で読めます
StripeWebhookNext.jsFastAPI開発秘話

Stripe連携の実装を終えて、ローカルのstripe listenでテストした。決済イベントが来た。Webhookエンドポイントが受け取った。ステータスは200。

「よし、動いた。デプロイしよう。」

本番にデプロイした翌朝、StripeのダッシュボードにはWebhookの失敗ログが並んでいた。全件400エラー。


Stripe Webhookの仕組みとrawBodyの話

Stripeは決済完了などのイベントが発生すると、設定したエンドポイントURLにPOSTリクエストを送ってくる。このとき、リクエストの正当性を確認するために署名検証が必要になる。

署名検証の仕組みはこうだ。

  1. StripeはリクエストヘッダーにStripe-Signatureを付ける
  2. このヘッダーには、リクエストボディの生のバイト列をもとに生成したHMAC署名が含まれている
  3. こちらの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_SECRETundefinedになり、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がチャプターを自動生成。

1

YouTubeのURLをコピーして貼る

2

「生成する」を押す

3

概要欄にコピペして完了

無料でチャプターを生成する →

月3回まで無料 · クレジットカード不要