Handler webhook (đoạn code xử lý webhook trên server của bạn) khi chạy trên serverless hỏng theo một số kiểu cụ thể và lặp lại được, và hầu như không kiểu nào trông giống nguyên nhân thật của nó. Bước xác minh chữ ký báo lỗi với một payload mà bạn chứng minh được là đúng. Phần việc hẹn chạy sau khi đã trả response không bao giờ chạy. Một handler qua mọi test ở local lại bị timeout, chỉ trên production và chỉ khi tải cao.
Không lỗi nào trong số này là do Vercel khó tính. Đó là hệ quả khi một mô hình thực thi gói gọn trong từng request gặp một giao thức vốn giả định có một server chạy lâu dài. Dưới đây là năm lỗi hay gặp nhất, theo đúng thứ tự chúng sẽ tìm đến bạn.
TL;DR
await req.text()trước khi bất cứ thứ gì parse body, nếu không mọi lần xác minh chữ ký đều không qua- Đừng vội dùng
runtime = "edge". Node.js trên Fluid Compute là mặc định đúng waitUntilkéo dài function qua thời điểm trả response, nhưng nó không bền vững. Nó không phải hàng đợi- Cold start bị tính vào timeout của nhà cung cấp, và Shopify chỉ cho bạn 5 giây
- Thời hạn thật của handler là thời hạn của nhà cung cấp khắt khe nhất, không phải của Vercel
1. Vấn đề raw body
Đây là lỗi đầu tiên ai cũng vấp phải, và thông báo lỗi chẳng giúp được gì vì payload trong log trông hoàn hảo.
Mọi nhà cung cấp đều ký đúng từng byte họ đã gửi. Stripe tính HMAC trên raw body kèm tiền tố dấu thời gian (timestamp), Shopify tính HMAC trên raw body, GitHub tính HMAC trên raw body. Nếu bất cứ thứ gì nằm giữa đường truyền và lệnh gọi xác minh của bạn parse JSON rồi serialize lại, thứ tự key hoặc khoảng trắng lệch đi một byte và HMAC không còn khớp. Dữ liệu y hệt, nhưng chữ ký thì không.
Trong App Router, đọc text trước rồi parse từ chuỗi bạn đã có:
// 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 });
}
Có hai điều cần nắm chắc.
Không bao giờ gọi await req.json() trước. Sau đó bạn không lấy lại được các byte gốc nữa: stream đã bị đọc hết, và thứ bạn serialize lại không phải thứ đã được ký.
Với Pages Router, body parser phải được tắt tường minh. Nếu bạn vẫn dùng pages/api, đặt export const config = { api: { bodyParser: false } } và tự đọc raw stream. Phần lớn báo lỗi kiểu “chạy được ở local nhưng deploy thì không” đều quy về việc thiếu dòng này.
Về định dạng chữ ký của từng nhà cung cấp (cơ chế t=/v1= của Stripe, tiền tố sha256= của GitHub, base64 của Shopify), xem Xác minh chữ ký webhook của Stripe, GitHub và Shopify. Và nếu SDK của nhà cung cấp có hàm verify thì nên dùng nó thay vì tự viết: hàm đó làm sẵn phép so sánh constant-time và khoảng dung sai cho dấu thời gian, hai thứ rất dễ sai ở những chỗ khó thấy.
2. Đừng đặt handler webhook trên edge runtime
Nhiều người vẫn giữ phản xạ rằng endpoint nhận webhook nên dùng runtime = "edge" vì nó phải nhanh. Đó là lời khuyên đã lỗi thời.
Mặc định của Vercel là Node.js trên Fluid Compute, vốn dùng lại instance cho các request đồng thời và đã gần như xóa khoảng cách về cold start từng khiến edge hấp dẫn. Trong khi đó, edge runtime lấy đi của bạn những thứ có thật mà một handler webhook cần: node:crypto đầy đủ, các API Node mà phần lớn SDK của nhà cung cấp mặc định là có, và thời gian thực thi dài hơn. Streaming response, lý do khác mà người ta hay viện dẫn, chạy tốt trên Node.js, và đã như vậy từ khá lâu.
Lỗi cụ thể: SDK của nhà cung cấp gọi crypto.createHmac nên ném lỗi trên edge, thế là bạn tự viết phần xác minh bằng WebCrypto, và chính bản tự viết là nơi phép so sánh timing-safe lặng lẽ biến mất. Bạn đã đổi một tính chất bảo mật có thật lấy một khác biệt về cold start mà phần lớn đã không còn.
Cứ dùng mặc định.
Nếu bạn đang nhìn một handler chạy được ở local nhưng hỏng trên production, bài Debug webhook bị lỗi trên production có cây quyết định giúp khoanh vùng tầng nào đang nuốt mất request.
3. waitUntil không phải hàng đợi
Trên Vercel, cách tự nhiên để “trả về nhanh, làm việc sau” là 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 });
}
Nó thật sự hữu ích, và nó thật sự không phải là tính bền vững. Khác biệt này quan trọng hơn bạn tưởng:
- Nếu instance bị thu hồi hoặc crash giữa chừng, phần việc đó mất luôn. Không có thử lại (retry), không có dead-letter (hàng đợi chứa các message không xử lý được), không có bản ghi nào
- Bạn đã trả 200 cho nhà cung cấp. Họ sẽ không bao giờ thử lại. Sự kiện đó giờ không còn ở đâu cả
- Lỗi bên trong
waitUntilkhông làm request thất bại, nên trừ khi bạn chủ động bắt lỗi và báo cáo, bạn sẽ không thấy chúng
Kiểu lỗi này thuộc loại tệ nhất: hiếm, im lặng và không thể dựng lại sau khi đã xảy ra, vì bản sao duy nhất của sự kiện nằm trong bộ nhớ.
waitUntil là công cụ đúng cho việc kiểu fire-and-forget mà mất cũng không sao: ping analytics, làm nóng cache, một thông báo Slack. Nó là công cụ sai cho bất cứ thứ gì khách hàng sẽ nhận ra nếu thiếu.
Quy tắc giúp bạn tránh rắc rối là lưu trước rồi mới xác nhận (ack). Ghi sự kiện vào Postgres, Redis hoặc một hàng đợi thật, rồi mới trả 200, sau đó mới xử lý. Lúc này mã 200 là một lời khẳng định đúng sự thật, vì bạn thật sự đang giữ sự kiện, còn phần xử lý có thể thất bại và thử lại bao nhiêu lần cũng được.
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 });
}
Fast path lo trường hợp bình thường. Lượt quét định kỳ lo instance đã chết. Không đường nào làm mất sự kiện.
4. Cold start bị tính vào đồng hồ của nhà cung cấp
Giới hạn thực thi mặc định của Vercel là 300 giây, nghe như thể timeout không còn là vấn đề. Không phải vậy, vì người bấm giờ bạn không phải Vercel. Người bấm giờ là bên đang gọi bạn, và thời hạn của họ ngắn hơn nhiều:
| Nhà cung cấp | Thời hạn phản hồi |
|---|---|
| Slack, Discord | 3 giây |
| Shopify | 5 giây |
| GitHub | 10 giây |
| Twilio | 15 giây |
| Stripe | không công bố |
Timeout thật của handler là timeout của nhà cung cấp khắt khe nhất trong số các nguồn bạn nhận webhook, nên bạn nên biết mọi thời hạn mình phải chịu, thay vì chỉ cái mình nhớ. Tài liệu tham chiếu các nhà cung cấp webhook của chúng tôi liệt kê đủ. So với năm giây của Shopify, một cold start 2 giây đã tiêu mất 40% thời hạn trước khi dòng code đầu tiên của bạn chạy. Công cụ kiểm tra timeout đo thời gian hai request tới route của bạn, nên request lạnh hiện ngay cạnh request ấm, và nếu phần chậm là một lệnh gọi mô hình, bài Handler webhook của bạn gọi LLM nói về after() và chỗ nó dừng lại.
Những gì thật sự có ích:
- Giữ import graph của route thật nhỏ. Một route webhook chỉ xác minh rồi đưa vào hàng đợi thì không nên import gián tiếp ORM, admin SDK và email client của bạn. Thứ nào trong số đó cũng phải được parse lúc cold start
- Lazy import những thứ nặng ngay trong nhánh cần chúng, không phải ở cấp module
- Đặt
maxDurationthấp cho các route webhook.export const maxDuration = 10không làm bạn nhanh hơn, nhưng nó biến một handler chạy mất kiểm soát thành một lỗi nhanh mà nhà cung cấp sẽ thử lại, thay vì treo 300 giây vừa đốt compute vừa chẳng đưa bạn tới gần kết quả hơn
5. Concurrency không đứng về phía bạn lúc 3 giờ sáng
Serverless tự scale ngang, đúng thứ bạn muốn trong một đợt flash sale, và cũng đúng thứ phá vỡ giả định mà phần lớn code idempotency (xử lý nhiều lần vẫn cho cùng một kết quả) dựa vào.
Một trăm instance đồng thời nghĩa là một trăm handler chạy đồng thời, mỗi cái mở kết nối database riêng. Postgres có giới hạn số kết nối, và giới hạn đó thấp hơn bạn nghĩ. Connection pooling trên nền tảng sinh ra cho đúng chuyện này, và bỏ qua nó là cách một đợt tăng traffic biến thành lỗi too many connections trên mọi request, kể cả request từ người dùng thật.
Chuyện khó thấy hơn: hai lần thử lại của cùng một sự kiện có thể rơi vào hai instance trong cùng một mili giây. Logic idempotency kiểu đọc rồi mới ghi có race condition ở đó mà test tuần tự không bao giờ bắt được. Việc này phải để một unique constraint đảm nhận, không phải một câu 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);
Chi tiết hơn, kể cả chuyện sự kiện đến sai thứ tự, có trong bài Bạn nhận cùng một webhook hai lần.
AnyHook giúp được gì
Mọi mục ở trên, trừ mục raw body, thực chất là cùng một vấn đề: một runtime gói gọn trong từng request đang bị yêu cầu đưa ra một lời hứa về tính bền vững mà tự nó không giữ được.
AnyHook chuyển lời hứa đó ra khỏi function. Nhà cung cấp POST tới in.anyhook.net/you/app, và:
- Mã 200 được trả về sau khoảng một giây, từ một worker ở edge (lớp máy chủ biên của Cloudflare) chỉ làm đúng một việc: lưu rồi xác nhận. Năm giây của Shopify không còn là ràng buộc của bạn nữa
- Sự kiện được lưu bền vững trước khi chúng tôi xác nhận, được mã hóa khi lưu trữ, nên “instance chết” chỉ dẫn tới một lần thử lại chứ không làm mất sự kiện
- Lượt gửi tới function trên Vercel của bạn được thử lại sau 5 phút, 30 phút, 3 giờ, 18 giờ, rồi hằng ngày, từ 3 đến 10 lần gửi tùy gói, và sự kiện nào vẫn thất bại sẽ nằm lại trong nhật ký để phát lại (replay)
- Một header
AnyHook-Signatureduy nhất thay cho các cơ chế xác minh riêng của từng nhà cung cấp, được ký lại ở mỗi lần gửi với dấu thời gian mới, nên các lần thử lại không bị trượt khoảng dung sai như chữ ký gốc của nhà cung cấp khi được chuyển tiếp - Phát lại được theo cả một khoảng thời gian, cho lúc một đợt deploy nuốt mất một giờ sự kiện
Handler của bạn vẫn cần idempotent. Không gì gỡ bỏ được yêu cầu đó. Nhưng nó không còn phải vừa bền vững, vừa nhanh, vừa luôn ấm cùng lúc nữa.
Để xem thử trước khi đăng ký bất cứ thứ gì, một lệnh sẽ trả về một URL nhận (inbound URL) đang hoạt động mà không cần tài khoản:
curl -X POST https://anyhook.net/api/v1/quickstart
Response chứa một inbound_url để trỏ nhà cung cấp tới, một api_key để đọc nhật ký sự kiện qua API và một claim_url. Endpoint này là tạm thời: nó và các sự kiện của nó bị xóa sau 7 ngày, trừ khi bạn mở claim URL và đăng nhập, khi đó URL vẫn giữ nguyên.
Điều cần nhớ
Đọc raw body trước, ở lại Node.js runtime, và coi waitUntil là best-effort chứ không phải hàng đợi. Nếu chỉ mang theo một ý về cấu trúc từ bài này, hãy nhớ rằng bạn không bao giờ được trả 200 cho một sự kiện mà bạn chưa ghi lại bền vững. Mọi thứ khác trong danh sách này là vấn đề hiệu năng. Riêng điều đó là vấn đề mất dữ liệu, và là thứ duy nhất bạn không thể gỡ lỗi sau khi sự đã rồi.
Phiên bản Workers của danh sách này khác đủ nhiều để chúng tôi viết riêng: Nhận webhook trên Cloudflare Workers.