Webhook の送信元が待つのは数分ではなく数秒です。ハンドラーの応答を Slack は 3 秒、Shopify は 5 秒、GitHub は 10 秒まで待ちますが、LLM を呼ぶハンドラーはたいていそれ以上かかります。すると送信元は失敗を記録し、リトライするか、あきらめます。間に合わないことが続くと、Slack はイベントサブスクリプションをオフにし、Shopify は Webhook を削除することがあります。対処法は、リクエストを検証したらすぐに 200 を返し、モデルの呼び出しをレスポンスの後に回すことです。後に回した処理は、自分で動かすバックグラウンド処理で行うか、送信元に代わって応答する中継サービスの後ろで行います。
以下は、ここまでの要約を詳しく書いたものです。各送信元が実際にどれだけ待つのか、間に合わなかったときに送信元が何をするのか、先に応答して後で処理する短い方法 4 つ(それぞれの落とし穴付き)、そして中継サービスがイベントを預かると何が変わるのかを順に説明します。
TL;DR
- 送信元の制限時間には、署名検証、データベースの読み取り、モデルの呼び出しまで、ハンドラーの処理すべてが含まれます
- 間に合わなければ、送信元はリトライします。すでに処理を終えたハンドラーは、同じ処理をもう一度行います:Slack への返信が 2 回、同じ注文へのタグ付けが 2 回、メールが 2 通
- 間に合わないことが続くと、Slack はイベントサブスクリプションを無効にし、Shopify は Webhook を削除することがあります。GitHub はそもそもリトライしません
- 先に応答し、処理は後で:Next.js なら
after()、Cloudflare ならctx.waitUntil()(最長 30 秒)、Express や FastAPI ならバックグラウンドタスク - どの方法でも、バックグラウンド処理が途中で止まればイベントは残りません。永続的にするにはストレージ、ワーカー、リトライスケジュールが必要で、それはつまりキューです
- 中継サービスは、自分で書かなくて済むキューです:AnyHook は送信元に約 1 秒で応答し、ハンドラーには Free で 60 秒の持ち時間を、プランのコンピュート枠の範囲内で与えます。リトライもあります
各送信元が待つ時間
以下の数値は各ベンダー自身のドキュメントによるもので、出典とともに Webhook プロバイダー別リファレンスにまとめています。
| 送信元 | 2xx を待つ時間 | 配信に失敗した後 |
|---|---|---|
| Slack | 3 秒 | リトライは 3 回:すぐ、1 分後、5 分後。60 分以内に試行の 95% 超が失敗すると、イベントサブスクリプションが無効になる |
| LINE | 2 秒(それより遅い応答は request_timeout として記録される) | Webhookの再送(Webhook redelivery)は初期設定でオフ。サーバーが失敗し続けると、Webhook の送信を停止することがある |
| Shopify | 5 秒(接続は 1 秒) | 4 時間で 8 回リトライ。Admin API で作成したサブスクリプションは、8 回連続で失敗すると削除される |
| HubSpot | 5 秒(最大 100 件の通知をまとめたバッチ全体で) | 24 時間にわたって最大 10 回試行 |
| Paddle | 5 秒 | 本番環境:約 2 日間で最大 60 回試行 |
| GitHub | 10 秒 | なし。3 日間は手動で再配信できる |
| Twilio | デフォルトで 15 秒、変更可能 | デフォルトでリトライ 1 回、0〜5 回の範囲で変更可能 |
| Stripe | 公開されていない。ドキュメントは、時間のかかる処理の前に 2xx を返すよう求めている | ライブモードでは最長 3 日間、指数バックオフでリトライ |
実際の期限は、受信している送信元の中でいちばん短いものです。Slack のボットなら 3 秒しかなく、コールドスタートがあると、コードの最初の行が実行される前にその大半を使ってしまうこともあります。
LLM の呼び出しで間に合わなくなる理由
2026年に書かれるほぼすべてのハンドラーで、いちばん遅い処理はモデルの呼び出しです。短い補完でも数秒かかります。ツール呼び出しや長い回答、SDK 内部でのリトライが入れば数十秒です。そこにコールドスタートが加われば、最初のトークンが届く前に送信元は接続を切っています。
その後どうなるかは送信元次第ですが、どれも良い結果ではありません:
- Slack はリトライします。最初の実行がまだモデルを待っている間に、ハンドラーは同じイベントをもう一度受け取ります。両方の実行が最後まで進み、両方が投稿します。ユーザーから見ると、ボットが 2 回返信しています
- Shopify はリトライし、その後削除します。4 時間で 8 回連続して失敗すると、Admin API で作成したサブスクリプションは消えます。注文は入り続けますが、アプリにはもう通知が届きません
- GitHub は何もしません。配信は失敗として記録され、3 日以内に誰かが手動で再配信しない限り、それで終わりです
- LINE はリトライがオフになっています。自分でオンにしていなければ、タイムアウトしたメッセージにボットは返信しないままです
ハンドラーが失敗したわけではありません。見えない時計より遅かっただけです。
対処法:先に応答し、処理は後で
リクエストを検証し、200 を返してから、モデルを呼びます。どのプラットフォームにも、レスポンスの後も処理を続ける方法があります。どれにも落とし穴があり、コードの下に書いています。
Next.js(App Router)
// app/api/slack/events/route.ts
import { after } from "next/server";
export async function POST(req: Request) {
const raw = await req.text();
if (!verifySlackSignature(raw, req.headers)) {
return new Response("bad signature", { status: 401 });
}
const body = JSON.parse(raw);
if (body.type === "url_verification") {
return Response.json({ challenge: body.challenge });
}
after(async () => {
const answer = await askModel(body.event.text); // the slow part
await postToSlack(body.event.channel, answer);
});
return new Response(null, { status: 200 }); // well inside 3 seconds
}
落とし穴:after() が動けるのはルートの最大実行時間までで、それ以上は動きません。モデルの呼び出しが例外を投げたり、インスタンスが入れ替わったりすると、イベントは消えます。Slack はすでに 200 を受け取っているので、もう一度送ってくることはありません。イベントが存在したという記録もどこにも残りません。
Cloudflare Workers
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
const raw = await req.text();
if (!(await verify(raw, req.headers, env))) {
return new Response("bad signature", { status: 401 });
}
ctx.waitUntil(handle(JSON.parse(raw), env)); // runs after the response
return new Response(null, { status: 200 });
},
};
落とし穴:waitUntil が使えるのはレスポンス後の最長 30 秒で、渡した処理すべてでこの時間を分け合います。それを過ぎるとキャンセルされます。遅いモデル呼び出しは収まりません。もっと長い処理に対する Cloudflare 自身の答えは Queue とコンシューマー Worker の組み合わせで、これが正しい選択です。このプラットフォームのほかの落とし穴は Workers ガイドで扱っています。
Express などの常駐型 Node サーバー
app.post("/webhooks/slack", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySlackSignature(req.body, req.headers)) return res.sendStatus(401);
res.sendStatus(200); // answer first
setImmediate(() => handle(JSON.parse(req.body)).catch(console.error));
});
落とし穴:プロセスが再起動するまでは問題ありません。呼び出しの途中でデプロイ、クラッシュ、メモリ不足による強制終了が起きると、処理中だったものは失われます。サーバーレスのホストでは、レスポンスを送った瞬間にプロセスが凍結されることもあります。
Python(FastAPI)
from fastapi import BackgroundTasks, FastAPI, Request, Response
app = FastAPI()
@app.post("/webhooks/line")
async def line_webhook(request: Request, background: BackgroundTasks):
raw = await request.body()
if not verify_line_signature(raw, request.headers.get("x-line-signature")):
return Response(status_code=401)
background.add_task(handle_events, raw) # runs after the response is sent
return Response(status_code=200)
落とし穴は Express と同じです:タスクはプロセスの中にしか存在しません。プロセスを再起動すればタスクは消え、リトライも記録も残りません。
4 つの方法に共通すること
どれも、その時点ではメモリ上にしか存在しないイベントに対して 200 を返しています。送信元はそれ以上関知しません。バックグラウンド処理が失敗すれば、やり直す機会も、後から調べられるものもありません。
永続的な形はよく知られています:応答する前にイベントを書き込み、ワーカーに処理させて、失敗したらリトライし、何が失敗したかを確認して送り直せるだけの記録を残します。つまり、デッドレターのリストと再送ボタンを備えたキューです。作るのは難しくありません。ただ、運用し、監視し、障害時に呼び出される対象がまた 1 つ増えます。しかもそれは、ボットが本来やるべきことではありません。自前で持ちたい場合は、Vercel ガイドで保存してから応答する実装を紹介しています。
キューを運用したくない場合
ハンドラーの前に中継サービスを置きます。変えるのは、送信元に設定した Webhook の URL を AnyHook の URL にすることだけです。
- 送信元は約 1 秒で 200 を受け取ります。その 200 を返す前に、イベントはすでに保存されています
- その後、ハンドラーにイベントが届きます。持ち時間は Free で 60 秒、Pro で 120 秒、Scale で 5 分で、いずれもプランのコンピュート枠の範囲内です。モデルの呼び出しはこの中に収まるので、ハンドラーは処理をその場で行い、終わってから応答できます
- ハンドラーが失敗するか時間切れになると、AnyHook が独自のスケジュールでリトライします:5 分 → 30 分 → 3 時間 → 18 時間 → 以降は 1 日ごと。試行は最大で Free が 3 回、Pro が 5 回、Scale が 10 回です
- すべてのイベントは検索できるログに残ります(Free は 3 日、有料プランはそれより長く)。バグを直した後で、どのイベントでも再送できます
- Slack の
url_verificationチャレンジには AnyHook が代わりに応答するので、URL は最初の入力でそのまま保存できます
配信は少なくとも 1 回(at-least-once)です。処理を終えていても、持ち時間を過ぎてから応答したハンドラーには、同じイベントがもう一度届きます。そのためハンドラーは冪等にしてください:X-AnyHook-Delivery ヘッダー(イベント ID で、リトライのたびに同じ値)が処理済みのものはスキップします。Slack の event_id や GitHub の X-GitHub-Delivery など、送信元自身の ID も転送されます。実装パターンは重複イベントと冪等性にあります。
中継サービスにできないのは、送信元が実際のレスポンスボディを必要とする場合に、代わりに応答することです:
- Slack のスラッシュコマンドとインタラクティブボタンは、レスポンスでの返信か、すばやい応答を求めます。自分のエンドポイントから空の 200 で応答し、その後 Slack が送ってくる
response_urlに本当の回答を投稿します。この URL には 30 分以内に最大 5 件のメッセージを送れます - Discord のインタラクションは、3 秒以内に最初のレスポンスが必要です。遅延レスポンス(type 5)で応答し、モデルの回答が届いたらそれを編集します。インタラクショントークンは 15 分間有効です
- Twilio の音声とメッセージングの Webhook は TwiML の返却を求めます。すぐに TwiML で応答し、時間のかかる回答は REST API で送ります
中継サービスが向いているのは、2xx を返すだけでよいイベント型の Webhook です:Slack の Events API、LINE、Shopify、GitHub、HubSpot、Stripe、そのほか大半のサービスがこれにあたります。
ハンドラーの所要時間を確かめる
ハンドラーが期限の内側にいるのか外側にいるのか、推測する必要はありません。Webhook タイムアウトチェッカーは、エンドポイントにテストリクエストを送って所要時間を計り、その結果を各送信元の期限と並べて表示します。リクエストは 2 回送るので、しばらくアクセスがなかった後の遅い 1 回目は、そのままコールドスタートとして表れます。
よくある質問
Slack でボットがタイムアウトと表示されたり、2 回返信したりするのはなぜ?
Slack は 2xx を 3 秒待ちます。ハンドラーが応答の前にモデルを呼ぶとそれより時間がかかり、Slack は失敗と数えてイベントをもう一度送ります。そして両方の実行が返信を投稿します。先に応答し、返信は後から chat.postMessage で投稿してください。一度受け取ったイベントは無視します。
Vercel で 200 を返してから処理を続けてもよいですか?
はい。next/server の after()(または waitUntil)を使えば、ルートの最大実行時間まで処理が続きます。ただし永続的ではありません:処理が失敗したりインスタンスが入れ替わったりするとイベントは失われ、送信元がもう一度送ってくることもありません。通知なら問題ありませんが、なくなるとユーザーが気づくものには向きません。
LINE は Webhook の応答をどのくらい待ちますか?
2 秒です。LINE は、ボットサーバーが 2 秒以内に応答しなかった Webhook をエラー統計で request_timeout として数え、処理を非同期で行うよう推奨しています。Webhookの再送は初期設定でオフです。チャネルの Messaging API 設定でオンにしてください。
前にキューを置くと、ボットの返信は遅くなりますか? いいえ。中継サービスはイベントを保存するとすぐに転送するので、ハンドラーが動き出すのは、キューがない場合とほぼ同じタイミングです。変わるのは、モデルが考えている間、送信元が接続を保持し続けなくなることです。リトライで時間が延びるのは配信に失敗したときだけで、リトライがなければそのイベントは失われていました。
ハンドラーの処理が 60 秒を超える場合は? Free では、AnyHook はハンドラーの応答を 60 秒待ちます。Pro は 120 秒、Scale は 5 分で、いずれもプランのコンピュート枠の範囲内です。それより長い処理は、ジョブをキューに入れたらすぐに 202 を返し、残りは自分の側で終わらせてください。中継サービスは 202 を配信済みとして扱います。
Discord のボットや Slack のスラッシュコマンドでも使えますか?
中継サービス経由では使えません。どちらも 3 秒以内に決まった内容のレスポンスボディが必要で、それを返せるのは自分のコードだけです。上で説明したとおり、Discord の遅延レスポンスと Slack の response_url を使ってください。2xx だけでよい Slack の Events API は、中継サービスの後ろでも動きます。
ハンドラーが同じイベントを 2 回受け取ることはありますか? あります。中継サービスの有無にかかわらず起こります:送信元はリトライしますし、ハンドラーが失敗すれば AnyHook もリトライします。どのハンドラーも少なくとも 1 回(at-least-once)の配信を前提にして、処理済みの ID はスキップしてください。