サーバーレスで動く Webhook ハンドラーの失敗の仕方は決まっていて、再現性があります。しかも、そのほとんどは本当の原因とは別のバグに見えます。正しいと証明できるペイロードなのに、署名検証が失敗します。レスポンスの後に回した処理が、一度も実行されません。ローカルのテストをすべて通るハンドラーが、本番環境で、しかも負荷がかかったときだけタイムアウトします。
どれも Vercel が扱いにくいせいではありません。リクエスト単位の実行モデルが、常駐するサーバーを前提にしたプロトコルと出会った結果です。以下は、よく起きる 5 つの問題を、出くわす順に並べたものです。
TL;DR
- ボディが何かにパースされる前に
await req.text()を呼びます。そうしないと、署名検証はすべて失敗します runtime = "edge"には手を出さないでください。正しいデフォルトは Fluid Compute 上の Node.js ですwaitUntilは関数の実行をレスポンスの後まで延ばしますが、永続的ではありません。キューではありません- コールドスタートの時間はプロバイダーのタイムアウトから差し引かれます。Shopify の応答期限は 5 秒しかありません
- ハンドラーの本当のタイムアウトは、Vercel の上限ではなく、最も厳しいプロバイダーの応答期限です
1. 生のリクエストボディの問題
誰もが最初にぶつかる問題です。ログではペイロードが完璧に見えるため、エラーメッセージはまったく役に立ちません。
どのプロバイダーも、送信したバイト列そのものに署名します。Stripe はタイムスタンプを先頭に付けた生のリクエストボディで HMAC を計算します。Shopify も GitHub も、生のリクエストボディで HMAC を計算します。受信してから検証処理を呼ぶまでの間に何かが JSON をパースして再シリアライズすると、キーの順序や空白が 1 バイトずれ、HMAC は一致しなくなります。データは同じでも、署名は一致しません。
App Router では、まずテキストとして読み込み、手元にあるその文字列からパースします。
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const raw = await req.text(); // exact bytes, before any parsing
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("invalid signature", { status: 400 }); // no details to the caller
}
await queue.publish(event);
return new Response(null, { status: 200 });
}
押さえておくべき点が 2 つあります。
最初に await req.json() を呼ばないでください。呼んでしまうと、元のバイト列は取り戻せません。ストリームはすでに消費されていて、再シリアライズしたものは署名されたものと同じではないからです。
Pages Router では、ボディパーサーを明示的に無効にする必要があります。まだ pages/api を使っている場合は、export const config = { api: { bodyParser: false } } を設定し、生のストリームを自分で読み込みます。「ローカルでは動くのに、デプロイすると動かない」という報告の多くは、この設定の書き忘れが原因です。
プロバイダーごとの署名形式(Stripe の t=/v1= 方式、GitHub の sha256= プレフィックス、Shopify の base64)は、Stripe、GitHub、Shopify の Webhook 署名を検証するを参照してください。また、プロバイダーの SDK に検証関数があるなら、自作せずにそれを使ってください。定数時間での比較とタイムスタンプの許容範囲の処理を代わりにやってくれます。どちらも、気づかないうちに間違えやすい部分です。
2. Webhook ハンドラーを Edge ランタイムに載せない
Webhook の受信側は速くなければならないから runtime = "edge" にすべきだ、という思い込みは根強く残っています。これは古い助言です。
Vercel のデフォルトは Fluid Compute 上の Node.js です。同時に届くリクエストの間でインスタンスを使い回すので、Edge を魅力的に見せていたコールドスタートの差はほぼ埋まりました。一方で Edge ランタイムを選ぶと、Webhook ハンドラーに必要なものを実際に失います。完全な node:crypto、多くのプロバイダー SDK が前提にしている Node の API、そして長い実行時間です。よく挙げられる別の理由であるストリーミングレスポンスも、Node.js でしばらく前から問題なく動きます。
具体的な失敗はこうです。crypto.createHmac を呼ぶプロバイダー SDK は、Edge ランタイムでは例外を投げます。そこで WebCrypto を使って検証を自作すると、その自作版でタイミングセーフな比較がいつの間にか抜け落ちます。ほぼ解消されたコールドスタートの差と引き換えに、実際のセキュリティ上の性質を手放したことになります。
デフォルトのままにしてください。
ローカルでは動くのに本番環境では失敗するハンドラーをすでに抱えているなら、「Webhook が本番環境で失敗するときのデバッグ」に、どの層がリクエストを握りつぶしているかを絞り込むための判断フローがあります。
3. waitUntil はキューではない
Vercel で「すぐに返して、処理は後で」を実現する自然な方法は waitUntil です。
import { waitUntil } from "@vercel/functions";
export async function POST(req: Request) {
const event = await verify(req);
waitUntil(syncToSalesforce(event)); // continues after the response
return new Response(null, { status: 200 });
}
これは本当に便利です。そして、永続性とはまったく別物です。この違いは、聞こえる以上に重要です。
- インスタンスが処理の途中でリサイクルされたりクラッシュしたりすると、その処理は消えます。リトライも、デッドレターも、記録もありません
- プロバイダーにはすでに 200 を返しています。プロバイダーがリトライすることは決してありません。そのイベントは、もうどこにも存在しません
waitUntilの中で起きた失敗は、リクエストを失敗させません。明示的に捕捉して報告しない限り、失敗は見えません
失敗の性質としては最悪の部類です。まれにしか起きず、何も知らせず、後から再構成することもできません。イベントの唯一のコピーがメモリ上にあったからです。
waitUntil が向いているのは、失っても困らない投げっぱなしの処理です。アナリティクスへの送信、キャッシュのウォームアップ、Slack への通知などです。顧客が欠けていることに気づくようなものには向いていません。
トラブルを避けるためのルールは、応答する前に永続化することです。イベントを Postgres、Redis、または本物のキューに書き込み、それから 200 を返し、その後で処理します。これで 200 は事実を述べたことになります。イベントは確かに手元にあるからです。そして処理は、必要なだけ何度でも失敗し、リトライできます。
export async function POST(req: Request) {
const event = await verify(req);
await db.insert(events).values({ id: event.id, payload: event }); // durable
waitUntil(processNow(event.id)); // fast path; a cron sweeps anything left behind
return new Response(null, { status: 200 });
}
通常のケースはファストパスが処理します。途中で止まったインスタンスの分は、定期的なスイープが拾います。どちらの場合も、イベントは失われません。
4. コールドスタートはプロバイダーの持ち時間から引かれる
Vercel のデフォルトの実行時間の上限は 300 秒です。これならタイムアウトは問題にならないように聞こえますが、そうはなりません。時間を計っているのは Vercel ではないからです。計っているのは呼び出し元で、その応答期限はずっと短いものです。
| プロバイダー | 応答期限 |
|---|---|
| Slack、Discord | 3 秒 |
| Shopify | 5 秒 |
| GitHub | 10 秒 |
| Twilio | 15 秒 |
| Stripe | 公表されていない |
ハンドラーの本当のタイムアウトは、受信しているプロバイダーのうち最も厳しいものの応答期限です。そのため、覚えているものだけでなく、関係するすべての応答期限を知っておく価値があります。一覧は Webhook プロバイダー別リファレンスにあります。Shopify の 5 秒に対して、2 秒のコールドスタートは、最初の行が実行される前に持ち時間の 40% を使い切っています。タイムアウトチェッカーはルートに 2 件のリクエストを送って時間を計るので、コールドスタート時の応答とウォーム時の応答が並んで表示されます。遅いのがモデルの呼び出しなら、Webhook ハンドラーが LLM を呼ぶときで after() と、それが効かなくなる境界を説明しています。
実際に効くのは次のことです。
- ルートのインポートグラフを小さく保ちます。検証してキューに入れるだけの Webhook ルートが、ORM や管理用の SDK、メールクライアントを間接的にインポートするべきではありません。そのどれもが、コールドスタートのたびにパースされます
- 重いものは、モジュールスコープではなく、それを必要とする分岐の中で遅延インポートします
- Webhook ルートでは
maxDurationを低く設定します。export const maxDuration = 10で速くなるわけではありません。しかし、暴走したハンドラーを、プロバイダーがリトライする素早い失敗に変えられます。そうしなければ、コンピュートを消費するだけで何も進まない 300 秒のハングになります
5. 午前3時の同時実行は味方ではない
サーバーレスはスケールアウトします。タイムセールのときにはまさに望みどおりの動きですが、多くの冪等性のコードが前提にしている仮定を壊すのも、まさにこの動きです。
同時に 100 個のインスタンスが動けば、100 個のハンドラーが同時に動き、それぞれがデータベース接続を開きます。Postgres には接続数の上限があり、その値は想像より低いものです。プラットフォームのコネクションプーリングは、このためにあります。これを省くと、トラフィックの急増が、実際のユーザーからのものも含むすべてのリクエストでの too many connections に変わります。
もっと気づきにくい問題もあります。同じイベントの 2 回のリトライが、同じミリ秒に 2 つのインスタンスに届くことがあります。読んでから書く冪等性のロジックには、そこで競合状態が生じ、逐次実行のテストでは決して見つかりません。判定を担うのは if ではなく、一意制約でなければなりません。
// Racy: both instances read "not processed", both proceed
if (await db.hasProcessed(event.id)) return ok();
await doWork(event);
// Safe: the database arbitrates
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING`,
[event.id],
);
if (rowCount === 0) return ok();
await doWork(event);
順不同の配信も含めた詳しい話は、同じ Webhook を 2 回受け取ったときにあります。
AnyHook の位置づけ
生のリクエストボディの件を除けば、上の項目はどれも実は同じ問題です。リクエスト単位のランタイムに、単独では守れない永続性の約束をさせようとしているのです。
AnyHook は、その約束を関数の外に移します。プロバイダーが in.anyhook.net/you/app に POST すると、次のようになります。
- 永続化と応答だけを担うエッジのワーカーが、約 1 秒で 200 を返します。Shopify の 5 秒の応答期限に縛られることはなくなります
- イベントは AnyHook が応答する前に永続的に保存され、保存時に暗号化されます。そのため「インスタンスが止まった」は損失ではなく、リトライで済みます
- Vercel の関数への配信は、5 分、30 分、3 時間、18 時間、以降は 1 日ごとにリトライされます。試行回数はプランによって 3〜10 回です。それでも失敗したものはログに残り、再送できます
- プロバイダーごとの検証方式は、1 つの
AnyHook-Signatureヘッダーに置き換わります。このヘッダーは配信試行のたびに新しいタイムスタンプで再署名されるので、転送されたプロバイダーの署名のように、リトライが許容範囲を外れて失敗することはありません - 再送は期間を指定して行えます。デプロイの間に 1 時間分のイベントを取りこぼしたときに使えます
それでも、ハンドラーは冪等である必要があります。それは何をしても変わりません。ただし、永続的で、速く、常にウォームであることを同時に求められることはなくなります。
何かに登録する前に試すなら、アカウントなしでコマンドを 1 つ実行すれば、実際に使える受信 URL が返ってきます。
curl -X POST https://anyhook.net/api/v1/quickstart
レスポンスには、プロバイダーに設定する inbound_url、API でイベントログを読むための api_key、そして claim_url が含まれます。このエンドポイントは一時的なものです。紐付け用の URL を開いてログインし、アカウントに紐付けない限り、エンドポイントとそのイベントは 7 日後に削除されます。紐付ければ、同じ URL をそのまま使い続けられます。
まとめ
まず生のリクエストボディを読み、Node.js ランタイムにとどまり、waitUntil はキューではなくベストエフォートとして扱います。設計上の考え方を 1 つだけ持ち帰るなら、永続的に書き込んでいないイベントに 200 を返してはならない、ということです。このリストのほかの項目は、すべて性能の問題です。これだけはデータ損失の問題で、後からデバッグできない唯一の問題でもあります。
Workers 版は内容がかなり違うため、「Cloudflare Workers での Webhook 受信」として別にまとめています。