Webhook: xác minh chữ ký, xử lý retry và chống xử lý trùng Trung cấp
Thời gian đọc ước tính: 7 phút
Vấn đề
Phần tiêu đề “Vấn đề”Hệ thống bán hàng nhận webhook order.paid. Handler cập nhật đơn rồi trả 200. Một ngày cùng event đến hai lần: lần đầu đã ghi database nhưng response 200 mất trên đường về, provider không biết kết quả nên retry.
Nếu handler cứ cộng doanh thu mỗi lần nhận POST, một thanh toán tạo hai business effect. Ngược lại, ai biết URL cũng gửi được JSON giả để đánh dấu đơn đã trả.
Hai lỗi cần hai cơ chế khác nhau, cộng thêm quy ước về ACK:
| Cơ chế | Trả lời câu hỏi | Không trả lời |
|---|---|---|
| Xác minh chữ ký | Payload có được ký bằng secret của bên gửi, còn mới không? | Event này đã xử lý chưa |
| Idempotency (dedupe) | Event hợp lệ này đã tạo effect chưa? | Nguồn gửi có đáng tin |
ACK (2xx) |
Handler đã nhận tới mốc nào? | Downstream đã hoàn tất chưa |
Bài dùng mô hình chung: HTTP POST, HMAC, event ID ổn định qua các lần retry. Demo trong repo không tương thích chữ ký của Stripe; với Stripe thật, dùng SDK và đúng định dạng Stripe-Signature.
Handler: verify rồi mới dedupe
Phần tiêu đề “Handler: verify rồi mới dedupe”Thứ tự bắt buộc: raw body → chữ ký → timestamp → parse → insert inbox → ACK.
// express.raw giữ nguyên Buffer; KHÔNG dùng express.json() cho route nàyapp.post('/webhooks/pay', express.raw({ type: 'application/json' }), async (req, res) => { const raw: Buffer = req.body; const { t, v1 } = parseSignatureHeader(req.header('x-signature')); // "t=...,v1=..."
const expected = createHmac('sha256', SECRET).update(`${t}.`).update(raw).digest(); const given = Buffer.from(v1 ?? '', 'hex'); const ok = given.length === expected.length && timingSafeEqual(given, expected); const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300; // 5 phút if (!ok || !fresh) return res.status(400).end(); // chưa ghi gì
const event = JSON.parse(raw.toString('utf8')); const r = await db.query( `INSERT INTO webhook_inbox (provider, event_id, payload, status) VALUES ('pay', $1, $2, 'pending') ON CONFLICT (provider, event_id) DO NOTHING`, [event.id, raw.toString('utf8')]); // r.rowCount === 0 nghĩa là retry của event đã nhận; vẫn ACK res.status(200).end(); // worker xử lý sau});Ghi chú về các điểm hay hỏng:
- Raw body: parse rồi serialize lại có thể đổi whitespace, thứ tự key và làm chữ ký sai. Đây là lỗi số một trên Express, Nest, Next.
timingSafeEqualyêu cầu hai buffer cùng độ dài, nên phải kiểm tra độ dài trước.- Timestamp tolerance: thư viện Stripe mặc định 5 phút; đừng đặt 0 vì sẽ tắt kiểm tra. Cần đồng hồ server đồng bộ NTP.
ON CONFLICT DO NOTHINGlà điểm phân xử cuộc đua giữa hai request đồng thời;if (!exists) insertthì không.- Inbox row đã là bản ghi bền, nên ACK sau insert là an toàn. Pattern này là idempotent consumer; effect ra hệ thống khác dùng transactional outbox.
POST + signatureProvider gửi raw body, event ID và chữ ký. Handler chưa tin dữ liệu này.
Failure matrix: crash ở đâu, chuyện gì xảy ra
Phần tiêu đề “Failure matrix: crash ở đâu, chuyện gì xảy ra”| Điểm crash | Provider thấy | Kết quả | Cách phòng |
|---|---|---|---|
| Trước insert inbox | Không có 2xx, retry |
Xử lý bình thường ở lần sau | Không cần thêm |
Sau insert, trước 2xx |
Không có 2xx, retry |
Retry bị nhận là trùng, vẫn ACK; worker xử lý từ inbox | Inbox là nguồn sự thật |
| Worker chết trước effect | Đã 2xx, sẽ không gửi lại |
Row pending mãi nếu không ai nhặt |
Worker quét pending, lease/timeout |
| Effect xong, status chưa cập nhật | Đã 2xx |
Worker chạy lại effect lần hai | Effect + đổi status trong cùng transaction, hoặc effect idempotent |
Dòng cuối là lý do không thể chỉ dựa vào dedupe ở cửa vào: cần idempotency ở mỗi ranh giới có effect.
Retry, replay và retention
Phần tiêu đề “Retry, replay và retention”Với Stripe (đã đối chiếu tài liệu):
| Chủ đề | Giá trị |
|---|---|
| Retry live mode | Tối đa 3 ngày, exponential backoff |
| Retry sandbox | 3 lần trong vài giờ |
| Replay tolerance (thư viện) | 5 phút |
| Resend thủ công | Dashboard 15 ngày, CLI 30 ngày |
| Thứ tự | Không đảm bảo; dùng event ID, không dùng created |
| Rotate secret | Giữ secret cũ tối đa 24 giờ, Stripe ký một chữ ký cho mỗi secret |
| Phản hồi | Trả 2xx nhanh, xử lý phức tạp bất đồng bộ |
Hệ quả cho thiết kế:
- Giữ event ID đã thấy lâu hơn cửa sổ retry (ít nhất 3 ngày cộng margin, dài hơn nếu hay resend thủ công).
- Với thứ tự không đảm bảo, dùng state machine (
pendingsangpaidchỉ khi trạng thái hiện tại cho phép) thay vì tin “event đến sau là mới nhất”.
Provider khác có số khác; đọc tài liệu của họ.
Khi nào dùng
Phần tiêu đề “Khi nào dùng”| Dùng inbox + dedupe | Không cần |
|---|---|
| Effect không được lặp (tiền, hàng, email) | Effect tự idempotent (ghi đè trạng thái cùng giá trị) |
| Provider có retry at-least-once | Nguồn nội bộ đã có exactly-once ở tầng khác |
| Xử lý chậm cần ACK nhanh | Xử lý cực nhanh, cùng transaction |
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Chạy demo (không cần server hay tài khoản bên thứ ba):
npx --prefix examples ts-node examples/src/webhook-delivery/webhook-demo.tsDemo ký raw body bằng HMAC-SHA256, gọi handler hai lần với cùng event rồi sửa một byte body giữ chữ ký cũ. Kết quả kỳ vọng: ack: evt-42, ack: duplicate event, reject: invalid signature, effects bằng 1.
Demo ký chỉ body, không có timestamp, dùng secret hard-code và Set trong RAM; không copy sang production. Thử crash giữa processed.add và effects += 1 để thấy dòng cuối của failure matrix.
Checklist production
Phần tiêu đề “Checklist production”- Route webhook dùng raw body; test với payload có whitespace lạ.
- Chữ ký gồm timestamp; so sánh bằng
timingSafeEqualsau khi kiểm tra độ dài; tolerance khoảng 5 phút. - Inbox có unique constraint trên
(provider, event_id); dedupe bằngON CONFLICT. - Trả
2xxsau khi ghi bền; xử lý nặng chạy ở worker. - Worker có lease để nhặt lại row
pending; effect cùng transaction với đổi status. - Giữ event ID lâu hơn cửa sổ retry; xử lý rotate secret bằng cách chấp nhận hai secret trong khoảng chuyển.
- Giới hạn kích thước body; log không chứa payload nhạy cảm; có quy trình replay thủ công.
Trade-offs
Phần tiêu đề “Trade-offs”Cơ chế này đem lại gì
- Chữ ký trên raw body loại request bị sửa hoặc không biết secret.
- Unique event ID giúp retry không nhân đôi effect.
- Inbox bền cho phép ACK sớm và tách xử lý chậm.
Chi phí và ranh giới
- Rotate secret, tolerance và raw-body middleware cần cấu hình đúng cho từng provider.
- Bảng dedupe chiếm lưu trữ; phải chọn retention đủ dài.
- Side effect ngoài database vẫn cần outbox và idempotency ở đích.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao JSON “giống nội dung” vẫn sai chữ ký sau khi serialize lại? (2) Worker chết giữa chừng sau khi đã ACK thì row nào cứu event? (3) Vì sao retention của event ID phải lớn hơn cửa sổ retry?