Bỏ qua để đến nội dung
Knowledge Hub

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

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.

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ày
app.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.
  • timingSafeEqual yê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 NOTHING là điểm phân xử cuộc đua giữa hai request đồng thời; if (!exists) insert thì 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.
Một webhook hợp lệ có thể được giao nhiều lần
Một webhook hợp lệ có thể được giao nhiều lầnBước 1/4: Provider gửi raw body, event ID và chữ ký. Handler chưa tin dữ liệu này.ProviderWebhook handlerEvent storePOST + signature
Provider → Webhook handlerPOST + signature
Bước 1/4

Provider 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.

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 (pending sang paid chỉ 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ọ.

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

Chạy demo (không cần server hay tài khoản bên thứ ba):

Terminal window
npx --prefix examples ts-node examples/src/webhook-delivery/webhook-demo.ts

Demo 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.

  1. Route webhook dùng raw body; test với payload có whitespace lạ.
  2. Chữ ký gồm timestamp; so sánh bằng timingSafeEqual sau khi kiểm tra độ dài; tolerance khoảng 5 phút.
  3. Inbox có unique constraint trên (provider, event_id); dedupe bằng ON CONFLICT.
  4. Trả 2xx sau khi ghi bền; xử lý nặng chạy ở worker.
  5. Worker có lease để nhặt lại row pending; effect cùng transaction với đổi status.
  6. 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.
  7. 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.

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.

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?