Tiêu đề email là một biến thể nào đó của “Your webhook endpoint is failing”, và nếu bạn đã bỏ qua email đầu tiên, email tiếp theo sẽ báo endpoint đã bị vô hiệu hóa. Giờ thì không có gì đến nữa. Subscription không được khởi tạo, hóa đơn không được đối soát, và bạn không biết thiệt hại bắt đầu từ bao giờ.
Tin tốt: Stripe giữ một bản của mọi sự kiện trong 30 ngày, dù lượt gửi có thành công hay không. Gần như mọi thứ đều khôi phục được nếu bạn hành động trong khoảng thời gian đó. Tin xấu: phần lớn mọi người bật lại endpoint, thấy dấu tích xanh trên traffic mới, rồi không bao giờ lấy bù (backfill) khoảng trống. Khoảng trống đó mới là phần tốn tiền.
TL;DR
- Bật lại endpoint trước, nhưng việc đó chỉ tiếp tục gửi các sự kiện mới. Nó không phát lại (replay) gì cả
- Lấy bù bằng
GET /v1/events?delivery_success=false, truy ngược được 30 ngày - Đảm bảo idempotent (xử lý nhiều lần vẫn cho cùng một kết quả) trước khi lấy bù, nếu không bạn sẽ xử lý đơn hàng hai lần
- Nguyên nhân gốc gần như luôn là một handler (đoạn code xử lý webhook trên server của bạn) chạy chậm, không phải một handler bị hỏng
- Lưu payload trước khi xử lý, và chuyện này sẽ không còn là tình huống khẩn cấp
Vì sao Stripe lại vô hiệu hóa endpoint
Ở live mode, Stripe thử lại (retry) một lượt gửi thất bại trong tối đa ba ngày theo exponential backoff (khoảng chờ tăng theo cấp số nhân). Khoảng thời gian đó khá rộng rãi, và nó tồn tại để một lần deploy hay một sự cố ngắn không khiến bạn mất gì. Bài Stripe webhook retry: hướng dẫn cho môi trường production đi qua lịch thử lại đó và cách thiết kế cho nó, trước khi bạn rơi vào tình huống mà bài này nói đến.
Tuy vậy, ba ngày không phải một tiêu chuẩn, và cứ mặc định như vậy sẽ khiến bạn trả giá ở lần tích hợp tiếp theo. Paddle gửi cùng một sự kiện thất bại tối đa sáu mươi lần trong khoảng hai ngày. Bài Paddle gửi 60 lần trong hai ngày. Stripe thì giãn cách các lần gửi. đặt hai lịch cạnh nhau, điều quan trọng nhất nếu bạn thu tiền qua cả hai.
Một endpoint thất bại liên tục là một tín hiệu khác. Địa chỉ đó không còn hoạt động, và tiếp tục dội request vào nó chỉ lãng phí năng lực gửi của Stripe và chôn nhật ký sự kiện của bạn trong nhiễu. Vì vậy Stripe cảnh báo bạn qua email, cho bạn biết ngày endpoint sẽ bị tắt nếu không có gì thay đổi, và cuối cùng tắt nó.
Điều cần ghi nhớ: việc vô hiệu hóa là thông báo thứ hai, không phải thứ nhất. Đến lúc nó xảy ra, các lỗi đã hiện trong Dashboard nhiều ngày rồi. Nếu email cảnh báo đi vào hộp thư của founder mà không ai đọc, hoặc vào một alias đã bị trả về (bounce), thì đó mới là bug thật sự.
Bước 1: Tìm ra cái gì hỏng, trước khi bật lại
Đừng vội bấm Enable ngay. Nếu nguyên nhân gốc vẫn còn, bạn sẽ đốt hết khoảng thời gian thử lại thêm một lần nữa.
Tìm ra nguyên nhân đó là cả một công việc riêng, và càng để lâu càng khó, vì các lượt gửi thất bại dần biến khỏi dashboard ngay trong lúc bạn còn đang xem. Bài Debug webhook thất bại trên production mà không mất cả cuối tuần là trình tự đáng làm theo.
Trong Dashboard, vào Developers → Webhooks (hoặc Workbench → Webhooks), mở endpoint và xem các lượt gửi gần đây. Stripe ghi lại response nhận được ở mỗi lần gửi. Bạn cần xác định mình đang gặp trường hợp nào dưới đây:
| Bạn thấy gì | Ý nghĩa |
|---|---|
Timed out | Handler của bạn quá chậm. Phổ biến nhất, bỏ xa các trường hợp khác |
500 / 502 | Code của bạn ném lỗi, hoặc nền tảng đứng trước nó ném lỗi |
503 | App của bạn ngừng hoạt động, hoặc đã scale to zero rồi không thức dậy nữa |
401 / 403 | Thứ gì đó đứng trước app đang chặn Stripe. WAF, rule của Cloudflare, middleware xác thực |
404 | Route đã đổi chỗ trong một lần refactor và không ai cập nhật URL endpoint |
Trường hợp 401/403 hay làm người ta bất ngờ sau khi thêm lớp chống bot hoặc một lớp xác thực mới, vì request webhook không mang session cookie và bị coi là traffic ẩn danh. Trường hợp 404 thường xuất hiện sau khi chuyển đổi framework.
Sửa cái đó trước. Rồi mới bật lại.
Bước 2: Bật lại, và hiểu rõ việc đó không làm gì
Bật lại sẽ tiếp tục gửi các sự kiện được tạo từ thời điểm đó trở đi. Những sự kiện Stripe đã ngừng thử lại sẽ không tự được gửi đi nữa. Có một ngoại lệ hẹp, được Stripe ghi trong tài liệu: nếu bạn tắt rồi bật lại trước lần thử lại kế tiếp của Stripe, các lần thử lại đang chờ vẫn sẽ đến. Nếu endpoint đã bị vô hiệu hóa cả tuần, sẽ không có gì tự quay về.
Vậy trạng thái hiện tại của bạn là: sự kiện mới đang chảy về, khoảng trống trong quá khứ vẫn y nguyên.
Bước 3: Lấy bù từ Events API
Đây là phần mọi người hay bỏ qua. List Events API của Stripe trả về mọi sự kiện trong 30 ngày gần nhất, và nhận bộ lọc delivery_success:
curl -G https://api.stripe.com/v1/events \
-u "$STRIPE_SECRET_KEY:" \
-d delivery_success=false \
-d "types[]=payment_intent.succeeded" \
-d "types[]=customer.subscription.created" \
-d "types[]=invoice.paid" \
-d limit=100
Vài điều quan trọng ở đây:
Lọc theo loại sự kiện thay vì lấy tất cả. Một tài khoản bận rộn tạo ra rất nhiều loại sự kiện mà bạn không có handler. Chỉ lấy những loại bạn thực sự xử lý.
Dùng ending_before cùng auto-pagination. Truyền ID của một sự kiện ngay trước khi sự cố bắt đầu, và auto-pagination sẽ trả kết quả theo thứ tự thời gian. Điều đó quan trọng: customer.subscription.created phải được xử lý trước customer.subscription.updated, trong khi thứ tự mặc định là mới nhất trước.
const missed = stripe.events.list({
delivery_success: false,
ending_before: "evt_lastKnownGood",
types: ["payment_intent.succeeded", "invoice.paid"],
limit: 100,
});
for await (const event of missed) {
await handleEvent(event); // must be idempotent (see below)
}
Mốc 30 ngày là cứng. Sự kiện cũ hơn mốc đó biến mất khỏi API. Nếu endpoint của bạn bị vô hiệu hóa từ năm tuần trước, tuần đầu tiên không thể khôi phục qua Stripe và bạn sẽ phải đối soát từ các object charges và subscriptions, một buổi chiều tệ hơn nhiều.
Bước 4: Đảm bảo idempotent, nếu không thì đừng chạy lấy bù
Đợt lấy bù có thể chứa những sự kiện mà handler đã xử lý xong sau khi Stripe ghi nhận timeout, và Stripe có thể vẫn đang thử lại một phần những gì bạn đang xử lý tay. Nếu handleEvent không idempotent, bạn sẽ gửi hai biên lai, cấp hai license key hoặc giao hai đơn hàng.
Phiên bản rẻ, và nó thực sự đủ dùng:
INSERT INTO processed_events (stripe_event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (stripe_event_id) DO NOTHING;
Không có dòng nào bị ảnh hưởng nghĩa là bạn đã gặp sự kiện này và nên dừng lại. Khi có thể, hãy thực hiện lệnh insert này trong cùng transaction với side effect. Nếu side effect là một lệnh gọi API bên ngoài không tham gia transaction được, đánh dấu dòng là processing trước, rồi processed sau, để một lần crash giữa chừng hiện ra rõ ràng thay vì bị thử lại âm thầm mãi mãi.
Chúng tôi đã viết về phiên bản rộng hơn của vấn đề này, gồm cả sự kiện đến sai thứ tự và vì sao ít nhất một lần (at-least-once) là một lựa chọn thiết kế có chủ đích, trong bài Bạn nhận cùng một webhook hai lần.
Bước 5: Sửa thứ thực sự gây ra chuyện này
Theo kinh nghiệm của chúng tôi, việc bị vô hiệu hóa gần như không bao giờ do handler sai. Nó do handler chậm. Mẫu hình trông như thế này:
// The shape that eventually gets your endpoint disabled
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
await db.insertOrder(event); // 40ms
await salesforce.sync(event); // 900ms, sometimes 8s
await resend.sendReceipt(event); // 300ms, sometimes times out
await slack.notify(event); // 200ms
return Response.json({ ok: true });
}
Vào một ngày thứ Ba yên ả, tổng cộng là 1,5 giây và không ai để ý. Khi Salesforce có một buổi chiều tồi tệ, nó treo, mọi lượt gửi cùng lúc bị timeout, và bạn chỉ còn vài ngày nữa là endpoint bị vô hiệu hóa. Dependency bị lỗi thậm chí không phải của bạn.
Cách sửa nằm ở cấu trúc, không phải ở một timeout lớn hơn:
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
await queue.publish(event); // durable write, ~10ms
return new Response(null, { status: 200 });
}
Nhận, lưu, xác nhận. Đồng bộ Salesforce trong một worker có thể tự thử lại theo lịch riêng mà không có Stripe đứng canh đồng hồ. Giờ một bên thứ ba chậm chỉ là một job chậm, không phải một lượt gửi thất bại.
Nửa còn lại là cảnh báo. Bạn muốn biết ngay ở lần thất bại liên tiếp thứ năm, chứ không phải nhiều ngày sau khi Stripe gửi email báo ngày vô hiệu hóa. Nếu thứ giám sát duy nhất bạn có là email cảnh báo của Stripe, hãy chắc chắn nó đến một nơi có người đọc.
AnyHook giúp được gì
AnyHook đứng giữa Stripe và server của bạn để một origin chậm không thể biến thành một endpoint bị vô hiệu hóa. Stripe gọi tới in.anyhook.net/you/stripe thay vì app của bạn, và chúng tôi trả 200 sau khoảng một giây, bất kể server của bạn đang làm gì.
- Stripe không bao giờ thấy timeout, nên không bao giờ đi vào con đường dẫn đến vô hiệu hóa
- Mọi sự kiện được lưu kèm đầy đủ header và body, mã hóa khi lưu trữ, trước khi thực hiện bất kỳ lần gửi nào
- Phát lại chỉ là một nút bấm. Nếu server của bạn ngừng hoạt động từ 14:00 đến 16:00, phát lại đúng khoảng đó. Dữ liệu phát lại đến từ nhật ký của bạn chứ không phải của Stripe, và một sự kiện vẫn phát lại được trong 3 ngày với gói Free, 30 ngày với gói Pro, 90 ngày với gói Scale. Phát lại sự kiện thất bại không bị tính vào hạn mức hằng tháng của bạn, dù vẫn có giới hạn hằng ngày và dùng đến hạn mức compute (tài nguyên tính toán đi kèm gói)
- Cảnh báo chuỗi lỗi được gửi khi có 1, 5 và 20 lần thất bại liên tiếp, tự động tạm dừng ở mốc 20 để một bản deploy hỏng không biến thành cơn bão thử lại
Khác biệt trong thực tế: có một dịch vụ trung chuyển webhook (webhook relay) phía trước, toàn bộ quy trình khôi phục ở trên rút gọn thành việc chọn một khoảng thời gian rồi bấm phát lại. Xem Cách phát lại webhook khi server của bạn ngừng hoạt động.
Điều cần nhớ
Bật lại endpoint chỉ là 10% của việc khôi phục. 90% còn lại là lấy bù khoảng trống trước khi thời hạn 30 ngày kết thúc, và đảm bảo handler đủ nhanh để Stripe không bao giờ có lý do vô hiệu hóa nó lần nữa. Nếu handler webhook của bạn gọi API bên thứ ba ngay trong luồng xử lý, bạn đã ở trên con đường đó rồi. Chỉ là bạn chưa gặp buổi chiều tồi tệ ấy thôi.