Bên gửi webhook chỉ chờ vài giây, không phải vài phút: Slack cho handler (đoạn code xử lý webhook trên server của bạn) 3 giây để trả lời, Shopify 5 giây, GitHub 10 giây. Một handler gọi mô hình ngôn ngữ lớn (LLM) thường cần lâu hơn thế, nên bên gửi ghi nhận lỗi rồi thử lại (retry) hoặc bỏ cuộc; nếu trễ hạn đủ nhiều lần, Slack sẽ tắt event subscription của bạn, còn Shopify có thể xóa webhook. Cách khắc phục là trả về 200 ngay khi đã xác minh request, rồi gọi mô hình sau khi phản hồi: hoặc trong tác vụ nền do bạn tự chạy, hoặc đặt handler sau một dịch vụ trung chuyển webhook (webhook relay) trả lời bên gửi thay bạn.
Phần còn lại của bài là bản dài của hai câu trên: mỗi bên gửi thực sự chờ bao lâu, nó làm gì khi bạn trễ hạn, bốn cách ngắn gọn để trả lời trước, xử lý sau (kèm điểm vướng của từng cách) và điều gì thay đổi khi một dịch vụ trung chuyển giữ sự kiện thay bạn.
TL;DR
- Đồng hồ của bên gửi tính cho toàn bộ handler: xác minh chữ ký, đọc database và lệnh gọi mô hình
- Trễ hạn là bên gửi thử lại. Handler đã làm xong việc sẽ làm lại lần nữa: hai câu trả lời trên Slack, một đơn hàng bị gắn tag hai lần, hai email
- Trễ hạn đủ nhiều lần thì Slack vô hiệu hóa event subscription của bạn, Shopify có thể xóa webhook. GitHub thì không bao giờ thử lại
- Trả lời trước, xử lý sau:
after()trong Next.js,ctx.waitUntil()trên Cloudflare (tối đa 30 giây), một tác vụ nền trong Express hoặc FastAPI - Không cách nào trong số đó giữ được sự kiện nếu tác vụ nền chết giữa chừng. Phiên bản bền vững cần nơi lưu trữ, một worker và lịch thử lại, tức là một hàng đợi
- Dịch vụ trung chuyển là hàng đợi bạn không phải tự viết: AnyHook trả lời bên gửi sau khoảng một giây và cho handler của bạn 60 giây trên gói Free, trong phạm vi hạn mức compute (tài nguyên tính toán đi kèm gói), kèm cơ chế thử lại
Mỗi bên gửi chờ bao lâu
Các số liệu dưới đây lấy từ tài liệu của chính từng nhà cung cấp, được tổng hợp kèm nguồn trong trang tra cứu nhà cung cấp webhook của chúng tôi.
| Bên gửi | Chờ mã 2xx của bạn | Sau một lần gửi thất bại |
|---|---|---|
| Slack | 3 giây | 3 lần thử lại: ngay lập tức, sau 1 phút, sau 5 phút. Event subscription bị vô hiệu hóa nếu hơn 95% số lần gửi thất bại trong vòng 60 phút |
| LINE | 2 giây (trả lời muộn hơn bị ghi là request_timeout) | Redelivery (tự thử lại) mặc định tắt. Có thể tạm ngưng gửi webhook nếu server của bạn liên tục lỗi |
| Shopify | 5 giây (1 giây để kết nối) | 8 lần thử lại trong 4 giờ. Webhook đăng ký qua Admin API sẽ bị xóa sau 8 lần thất bại liên tiếp |
| HubSpot | 5 giây, cho cả lô tối đa 100 thông báo | Tối đa 10 lần gửi, trải đều trong 24 giờ |
| Paddle | 5 giây | Chế độ live: tối đa 60 lần gửi trong khoảng 2 ngày |
| GitHub | 10 giây | Không có. Bạn có thể gửi lại thủ công trong vòng 3 ngày |
| Twilio | Mặc định 15 giây, có thể cấu hình | Mặc định 1 lần thử lại, cấu hình được từ 0 đến 5 |
| Stripe | Không công bố. Tài liệu của Stripe bảo trả về 2xx trước mọi logic chậm | Thử lại theo exponential backoff (khoảng chờ tăng theo cấp số nhân) trong tối đa 3 ngày ở live mode |
Hạn chót thật sự của bạn là bên gửi khắt khe nhất mà bạn nhận webhook. Một bot trên Slack có 3 giây, và một lần cold start có thể ngốn gần hết số thời gian đó trước khi dòng code đầu tiên của bạn kịp chạy.
Vì sao lệnh gọi LLM làm hỏng chuyện
Gọi mô hình là bước chậm trong gần như mọi handler viết vào năm 2026. Một completion ngắn mất vài giây; một completion có lệnh gọi tool, câu trả lời dài hay một lần thử lại bên trong SDK thì mất hàng chục giây. Cộng thêm cold start là bên gửi đã ngắt kết nối trước khi token đầu tiên về tới.
Chuyện gì xảy ra tiếp theo tùy vào bên gửi, và không có kết cục nào tốt:
- Slack thử lại. Handler của bạn nhận lại cùng sự kiện đó trong khi lượt chạy đầu tiên vẫn đang chờ mô hình. Cả hai lượt chạy đều xong và đều đăng tin. Người dùng thấy bot trả lời hai lần
- Shopify thử lại, rồi xóa. Tám lần thất bại liên tiếp trong bốn giờ là webhook đăng ký qua Admin API biến mất. Đơn hàng vẫn đổ về, còn app của bạn không còn nghe được gì về chúng
- GitHub không làm gì cả. Lượt gửi bị đánh dấu thất bại và mọi chuyện dừng ở đó, trừ khi có người gửi lại thủ công trong vòng ba ngày
- LINE để redelivery ở trạng thái tắt nếu bạn chưa tự bật, nên một lần timeout là một tin nhắn bot của bạn không bao giờ trả lời
Handler không hề lỗi. Nó chỉ chậm hơn một chiếc đồng hồ mà nó không nhìn thấy.
Cách khắc phục: trả lời trước, xử lý sau
Xác minh request, trả về 200, rồi mới gọi mô hình. Nền tảng nào cũng có cách để tiếp tục chạy sau khi đã phản hồi. Cách nào cũng có điểm vướng, được ghi ngay dưới đoạn code.
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
}
Điểm vướng: after() chỉ chạy trong thời lượng tối đa của route, không hơn. Nếu lệnh gọi mô hình ném lỗi, hoặc instance bị thu hồi, sự kiện mất luôn. Slack đã nhận mã 200 nên sẽ không thử lại, và không có gì ghi lại rằng sự kiện đó từng tồn tại.
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 });
},
};
Điểm vướng: waitUntil được tối đa 30 giây sau khi phản hồi, chia chung cho mọi thứ bạn truyền vào, rồi bị hủy. Một lệnh gọi mô hình chậm không lọt vừa. Câu trả lời của chính Cloudflare cho việc chạy lâu hơn là một Queue với một consumer Worker, và đó là câu trả lời đúng. Hướng dẫn về Workers của chúng tôi nói về những cái bẫy còn lại của nền tảng này.
Express hoặc bất kỳ server Node chạy lâu dài nào
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));
});
Điểm vướng: cách này ổn cho tới khi process khởi động lại. Một lần deploy, một lần crash hay bị kill vì hết bộ nhớ giữa chừng là mất mọi thứ đang xử lý dở. Trên host serverless, process còn có thể bị đóng băng ngay khi phản hồi được gửi đi.
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)
Điểm vướng giống Express: tác vụ sống trong process. Khởi động lại process là tác vụ mất, không thử lại, không có bản ghi nào.
Điểm chung của cả bốn cách
Cách nào cũng trả về 200 cho một sự kiện mà ở thời điểm đó chỉ tồn tại trong bộ nhớ. Bên gửi không còn quan tâm nữa, và nếu tác vụ nền thất bại thì không có cơ hội thứ hai, cũng chẳng có gì để xem lại sau đó.
Phiên bản bền vững có hình dạng quen thuộc: ghi sự kiện lại trước khi trả lời, để một worker xử lý và thử lại khi lỗi, rồi giữ đủ bản ghi để biết cái gì thất bại và gửi nó đi lần nữa. Đó là một hàng đợi kèm danh sách dead-letter và một nút phát lại (replay). Xây nó không khó. Nhưng đó là thêm một thứ phải vận hành, giám sát và bị gọi dậy khi có sự cố, và đó không phải việc bot của bạn sinh ra để làm. Hướng dẫn về Vercel của chúng tôi trình bày phiên bản lưu trước rồi mới trả lời, nếu bạn muốn tự làm chủ phần này.
Nếu bạn không muốn tự vận hành hàng đợi
Đặt một dịch vụ trung chuyển trước handler. Bạn đổi URL webhook ở phía bên gửi thành URL AnyHook của bạn, ngoài ra không đổi gì khác.
- Bên gửi nhận mã 200 sau khoảng một giây. Sự kiện được lưu trước khi mã 200 đó được gửi đi
- Sau đó handler của bạn nhận sự kiện với thời hạn phản hồi 60 giây trên gói Free, 120 giây trên gói Pro và 5 phút trên gói Scale, trong phạm vi hạn mức compute của gói bạn dùng. Lệnh gọi mô hình lọt vừa trong đó, nên handler có thể làm việc ngay trong request và trả lời khi xong
- Nếu handler lỗi hoặc hết giờ, AnyHook tự thử lại theo lịch riêng: 5 phút, 30 phút, 3 giờ, 18 giờ, sau đó hằng ngày, tối đa 3 lần gửi trên gói Free, 5 lần trên gói Pro và 10 lần trên gói Scale
- Mọi sự kiện được lưu trong một nhật ký tìm kiếm được (3 ngày trên gói Free, lâu hơn trên các gói trả phí), và sự kiện nào cũng có thể được phát lại sau khi bạn sửa lỗi
- AnyHook trả lời challenge
url_verificationcủa Slack thay bạn, nên URL được lưu ngay từ lần đầu
Cơ chế gửi là ít nhất một lần (at-least-once). Một handler đã làm xong việc nhưng trả lời sau khi hết thời hạn sẽ nhận lại sự kiện đó, nên hãy làm cho nó idempotent (xử lý nhiều lần vẫn cho cùng một kết quả): bỏ qua mọi sự kiện có header X-AnyHook-Delivery (id của sự kiện, giống nhau ở mọi lần thử lại) mà bạn đã xử lý. Id riêng của bên gửi, như event_id của Slack hay X-GitHub-Delivery của GitHub, cũng được chuyển tiếp. Bài Sự kiện trùng lặp và idempotency có sẵn khuôn mẫu.
Điều một dịch vụ trung chuyển không làm được là trả lời thay bạn khi bên gửi cần một body phản hồi thật:
- Slash command và nút tương tác của Slack chờ câu trả lời của bạn ngay trong phản hồi hoặc một xác nhận nhanh. Trả lời chúng từ endpoint của chính bạn bằng một mã 200 rỗng, rồi đăng câu trả lời thật lên
response_urlmà Slack gửi kèm; URL này nhận tối đa năm tin nhắn trong vòng 30 phút - Interaction của Discord cần phản hồi đầu tiên trong vòng 3 giây. Trả lời bằng một deferred response (type 5), rồi sửa nó bằng câu trả lời của mô hình khi có; interaction token còn hiệu lực trong 15 phút
- Webhook thoại và tin nhắn của Twilio chờ nhận lại TwiML. Trả lời bằng TwiML ngay, rồi gửi câu trả lời chậm qua REST API
Webhook sự kiện, loại chỉ cần mã 2xx, mới là thứ dịch vụ trung chuyển sinh ra để phục vụ: Events API của Slack, LINE, Shopify, GitHub, HubSpot, Stripe và phần lớn các bên khác.
Đo thời gian của handler
Bạn không cần đoán handler của mình nằm ở phía nào của vạch. Công cụ kiểm tra timeout webhook gửi một request thử tới endpoint của bạn, đo thời gian và đặt kết quả cạnh hạn chót của từng bên gửi. Công cụ chạy hai lần, nên một request đầu tiên chậm sau một khoảng im lặng sẽ lộ ra đúng bản chất của nó: một lần cold start.
Câu hỏi thường gặp
Vì sao Slack báo bot bị timeout, hoặc vì sao bot trả lời hai lần?
Slack chờ mã 2xx trong 3 giây. Nếu handler gọi mô hình trước khi trả lời, nó sẽ mất lâu hơn thế, Slack ghi nhận thất bại và thử lại, rồi cả hai lượt chạy đều đăng câu trả lời. Hãy trả lời trước rồi mới đăng câu trả lời bằng chat.postMessage, và bỏ qua những sự kiện bạn đã thấy.
Có thể trả về 200 trước rồi tiếp tục xử lý trên Vercel không?
Có, bằng after() từ next/server (hoặc waitUntil), và phần việc đó chạy trong thời lượng tối đa của route. Cách này không bền vững: nếu phần việc lỗi hoặc instance bị thu hồi, sự kiện mất và bên gửi sẽ không thử lại. Ổn cho một thông báo, sai với bất cứ thứ gì người dùng sẽ nhận ra là bị thiếu.
LINE chờ phản hồi webhook bao lâu?
Hai giây. LINE tính một webhook mà bot server của bạn chưa trả lời trong vòng 2 giây là một request_timeout trong thống kê lỗi, và khuyên xử lý bất đồng bộ. Redelivery của webhook mặc định tắt; bật nó trong phần cài đặt Messaging API của channel.
Đặt một hàng đợi phía trước có làm bot trả lời chậm hơn không? Không. Dịch vụ trung chuyển chuyển tiếp sự kiện ngay khi đã lưu, nên handler của bạn bắt đầu vào khoảng thời điểm mà lẽ ra nó vẫn bắt đầu. Điều thay đổi là bên gửi không còn phải giữ kết nối trong lúc mô hình suy nghĩ. Các lần thử lại chỉ thêm thời gian khi một lần gửi đã thất bại, và nếu không có chúng thì sự kiện đó đã mất.
Nếu handler chạy lâu hơn 60 giây thì sao? Trên gói Free, AnyHook chờ handler trả lời trong 60 giây; gói Pro chờ 120 giây và gói Scale 5 phút, trong phạm vi hạn mức compute của gói bạn dùng. Với việc lâu hơn thế, trả về 202 ngay khi bạn đã đưa job vào hàng đợi, rồi hoàn tất nó ở phía bạn. Dịch vụ trung chuyển tính mã 202 là đã gửi thành công.
Cách này có dùng được cho bot Discord và slash command của Slack không?
Không qua dịch vụ trung chuyển được. Cả hai đều cần một body phản hồi cụ thể trong vòng 3 giây, thứ chỉ code của chính bạn mới đưa ra được. Dùng deferred response của Discord và response_url của Slack như mô tả ở trên. Events API của Slack, vốn chỉ cần mã 2xx, chạy được sau dịch vụ trung chuyển.
Handler có bao giờ nhận cùng một sự kiện hai lần không? Có thể, dù có hay không có dịch vụ trung chuyển: bên gửi thử lại, và AnyHook cũng thử lại khi handler của bạn lỗi. Bạn nên coi mọi handler là nhận ít nhất một lần và bỏ qua những id đã xử lý.