Webhook 發送端等的是幾秒,不是幾分鐘:Slack 給處理程式 3 秒回應,Shopify 給 5 秒,GitHub 給 10 秒。呼叫 LLM 的處理程式通常需要更久,於是發送端記下一次失敗,接著重試或放棄;錯過的次數一多,Slack 會把你的事件訂閱關掉,Shopify 可能會刪除這個 Webhook。解法是請求一通過驗證就回 200,等回應送出後再呼叫模型:放進你自己執行的背景工作,或放在替你回應發送端的 Webhook 中繼服務(webhook relay)後面。
以下是這兩句話的完整版:每個發送端實際等多久、錯過時會發生什麼事、四種先回應再處理的簡短寫法(以及各自的問題),還有改由中繼服務代收事件之後有什麼不同。
重點摘要
- 發送端的計時涵蓋整個處理程式:簽章驗證、資料庫讀取,還有模型呼叫
- 錯過時限,發送端就會重試。已經做完工作的處理程式會再做一次:Slack 裡出現兩則回覆、同一筆訂單被標記兩次、同一封信寄兩次
- 錯過的次數一多,Slack 會停用你的事件訂閱,Shopify 可能會刪除 Webhook。GitHub 則完全不重試
- 先回應,再處理:Next.js 用
after(),Cloudflare 用ctx.waitUntil()(最多 30 秒),Express 或 FastAPI 用背景工作 - 背景工作要是中途當掉,以上做法都留不住事件。持久的版本需要儲存空間、一個 worker 和重試排程,也就是一個佇列
- 中繼服務就是你不必自己寫的那個佇列:AnyHook 大約一秒就回應發送端,Free 方案在運算額度內給處理程式 60 秒,並且會重試
各發送端等多久
以下數字取自各服務商自己的文件,出處整理在我們的 Webhook 服務商參考資料。
| 發送端 | 等你回 2xx 的時間 | 傳遞失敗之後 |
|---|---|---|
| Slack | 3 秒 | 重試 3 次:立即、1 分鐘後、5 分鐘後。60 分鐘內超過 95% 的嘗試失敗,就會停用事件訂閱 |
| LINE | 2 秒(之後才回應會記為 request_timeout) | 重新傳送預設關閉。伺服器持續失敗時,可能停止傳送 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 內部重試了一次,就要幾十秒。再加上冷啟動,第一個 token 還沒到,發送端就已經掛斷。
接下來會怎樣,取決於發送端,而且沒有一種是好結果:
- Slack 會重試。第一次執行還在等模型時,處理程式又收到同一個事件。兩次執行都跑完,也都發了訊息。使用者看到機器人回答兩次
- Shopify 先重試,再刪除。四小時內連續失敗八次,透過 Admin API 建立的訂閱就沒了。訂單照樣進來,你的程式卻再也收不到通知
- GitHub 什麼都不做。這次傳遞被標為失敗,事情就此結束,除非有人在三天內手動重新傳送
- 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 搭配一個 consumer 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));
});
問題在於:這種寫法只撐到行程(process)重啟為止。部署、當機,或呼叫途中因記憶體不足被強制終止,進行中的工作都會遺失。在無伺服器平台上,回應送出的那一刻,行程也可能被凍結。
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 一樣:任務只存在於行程裡。行程一重啟,任務就沒了,不會重試,也沒有紀錄。
這四種做法的共同點
每一種都在事件只存在於記憶體裡的那一刻,就回了 200。發送端從此不再過問;背景工作要是失敗,沒有第二次機會,事後也沒有任何東西可以查。
持久的版本有固定的做法:回應之前先把事件寫下來,再讓 worker 處理,失敗時重試,並留下足夠的紀錄,看得出哪些失敗、可以重新傳送。這就是一個附帶死信清單和重播按鈕的佇列。做起來不難,但它又多了一個要維運、要監控、出事要值班處理的東西,而且不是你的機器人存在的目的。想自己掌握這一套,我們的 Vercel 指南示範了先寫入再回應的版本。
如果你不想自己維運佇列
在處理程式前面放一個中繼服務。你只要在發送端把 Webhook 網址換成你的 AnyHook 網址,其他什麼都不用改。
- 發送端大約一秒就會收到 200。這個 200 送出之前,事件就已經儲存
- 接著事件送到處理程式,在方案的運算額度內,Free 方案最多等 60 秒,Pro 方案 120 秒,Scale 方案 5 分鐘。模型呼叫放得進去,所以處理程式可以直接在請求裡做完工作,做完再回應
- 處理程式失敗或逾時,AnyHook 會依自己的排程重試:5 分鐘 → 30 分鐘 → 3 小時 → 18 小時 → 之後每天一次,Free 方案最多嘗試 3 次,Pro 方案 5 次,Scale 方案 10 次
- 每個事件都保存在可搜尋的事件紀錄裡(Free 方案 3 天,付費方案更久),修好 bug 之後,任何一個都能重播
- AnyHook 會替你回應 Slack 的
url_verification驗證,網址第一次就能儲存成功
傳遞是至少一次(at-least-once)。處理程式做完了工作,卻在逾時之後才回應,就會再收到同一個事件,所以要讓它冪等:X-AnyHook-Delivery 標頭(事件 ID,每次重試都相同)已經處理過的,就直接跳過。發送端自己的 ID,例如 Slack 的 event_id 或 GitHub 的 X-GitHub-Delivery,也會一併轉送。完整的寫法在重複事件與冪等性。
中繼服務做不到的,是在發送端需要真正的回應主體時替你回答:
- Slack 斜線指令(slash command)和互動按鈕要求回應裡就有你的回覆,或要很快確認收到。先從你自己的端點回一個空的 200,再把真正的答案發到 Slack 傳來的
response_url,它在 30 分鐘內最多接受五則訊息 - Discord 互動(interaction)必須在 3 秒內給出第一個回應。先回一個延遲回應(type 5),等模型的答案到了再編輯它;interaction token 在 15 分鐘內有效
- Twilio 的語音和訊息 Webhook 要求回傳 TwiML。先立刻回 TwiML,慢的答案再透過 REST API 送出
只需要 2xx 的事件 Webhook,正是中繼服務的用途:Slack 的 Events API、LINE、Shopify、GitHub、HubSpot、Stripe,以及其他大多數服務。
測一下處理程式要花多久
處理程式落在時限的哪一邊,不必用猜的。Webhook 逾時檢查工具會對你的端點發一個測試請求、計時,再把結果和各發送端的時限並排列出。它會跑兩次,所以閒置一段時間後特別慢的第一個請求,會如實顯示為冷啟動。
常見問題
為什麼 Slack 說我的機器人逾時,又為什麼回了兩次?
Slack 等 2xx 只等 3 秒。處理程式在回應之前呼叫模型,就會超過這個時間,Slack 記一次失敗並再送一次事件,結果兩次執行都發了回覆。先回應,之後再用 chat.postMessage 發回覆,並丟掉已經看過的事件。
在 Vercel 上,可以先回 200 再繼續處理嗎?
可以,用 next/server 的 after()(或 waitUntil),工作最多執行到路由的最長執行時間。但這不持久:工作失敗或執行個體被回收,事件就會遺失,發送端也不會重新傳送。拿來發通知沒問題;只要是少了會被使用者發現的東西,就不該這樣做。
LINE 等 Webhook 回應會等多久?
兩秒。機器人伺服器 2 秒內沒有回應的 Webhook,LINE 會在錯誤統計裡記為 request_timeout,並建議以非同步方式處理。Webhook redelivery(Webhook 重新傳送)預設關閉,可以在 channel 的 Messaging API 設定裡開啟。
前面多放一個佇列,機器人會回得比較慢嗎? 不會。中繼服務一儲存好事件就轉送,所以處理程式開始執行的時間點,和原本差不多。改變的是,模型在思考時,發送端不再一直佔著連線。只有傳遞失敗時,重試才會多花時間;而沒有重試的話,那個事件早就遺失了。
處理程式要跑超過 60 秒怎麼辦? Free 方案下,AnyHook 最多等處理程式 60 秒回應;Pro 方案等 120 秒,Scale 方案等 5 分鐘,以方案的運算額度為限。比這更久的工作,把任務排進佇列後就回 202,再在你這邊做完。中繼服務會把 202 視為已送達。
Discord 機器人和 Slack 斜線指令也適用嗎?
透過中繼服務不行。兩者都需要在 3 秒內拿到特定的回應主體,只有你自己的程式碼給得出來。照上面的說明,改用 Discord 的延遲回應和 Slack 的 response_url。只需要 2xx 的 Slack Events API,就可以放在中繼服務後面。
處理程式會收到同一個事件兩次嗎? 會,不管有沒有中繼服務都可能:發送端會重試,處理程式失敗時 AnyHook 也會重試。把每個處理程式都當成至少一次傳遞來寫,跳過已經處理過的 ID。