Khách hàng báo: "Đơn hàng thanh toán rồi mà hệ thống bên em không thấy." Bạn check log — webhook đã bắn, nhận về 503 vì bên kia đang deploy, và... hết. Không retry, không hàng đợi, không cách nào bắn lại. Sự kiện đó mất vĩnh viễn, và người đi lục database đối soát bằng tay là bạn.
Đó là khoảng cách giữa webhook "chạy được" và webhook "chạy đáng tin". Năm 2026, khoảng cách này không còn là chuyện tự nghĩ tự làm nữa — đã có chuẩn, có pattern, có cả hạ tầng dựng sẵn. Bài này là checklist cho cả hai phía: SaaS Việt mở webhook cho khách, và đội backend ngồi nhận webhook từ Stripe, GitHub, Shopify.
Hết thời mỗi vendor một kiểu ký
Trước đây nhận webhook từ 5 vendor là viết 5 hàm verify khác nhau: chỗ ký HMAC-SHA256 lên raw body, chỗ ký lên body + timestamp, chỗ đặt signature ở header X-Hub-Signature-256, chỗ khác Stripe-Signature với format riêng. Copy nhầm một dòng là silently bỏ qua verify luôn.
Spec Standard Webhooks ra đời để chấm dứt chuyện đó. Ba header thống nhất:
webhook-id: msg_2ZxTpM... # định danh duy nhất của message
webhook-timestamp: 1752739200 # Unix timestamp lúc gửi
webhook-signature: v1,K5oZfzN95Z9U... # chữ ký trên (id + timestamp + body)
Chữ ký được tính trên cả id và timestamp, không chỉ body — nghĩa là kẻ tấn công bắt được một request hợp lệ cũng không sửa được timestamp để tái sử dụng. SDK verify chính thức có sẵn cho hầu hết ngôn ngữ phổ biến, bên nhận không phải tự viết crypto nữa.
Về payload, CloudEvents đang là lựa chọn mặc định cho envelope (type, source, id, time, data), còn AsyncAPI để mô tả contract — tương đương OpenAPI cho REST. Nếu bạn đang thiết kế webhook mới cho SaaS của mình trong 2026, đi theo bộ ba này thay vì phát minh format riêng: khách của bạn sẽ tích hợp trong một buổi chiều thay vì một tuần.
HMAC secret sống 3 năm là một quả bom hẹn giờ
Mô hình phổ biến: cấp cho khách một shared secret, khách dán vào env var, và secret đó sống... mãi mãi. Secret lộ (log nhầm, commit nhầm, nhân viên nghỉ việc) thì kẻ tấn công forge được webhook hợp lệ vô thời hạn — và bạn không có cách nào biết.
Xu hướng 2026: thay HMAC secret tĩnh bằng signing key ngắn hạn, ký bất đối xứng, xoay tự động, publish qua JWKS endpoint — đúng mô hình OIDC đã dùng cả chục năm. Bên phát ký bằng private key, xoay key theo lịch (vài ngày đến vài tuần); bên nhận fetch public key từ /.well-known/jwks.json, cache theo kid, gặp kid lạ thì refresh. Không còn secret nào để lộ phía nhận, rotation không cần khách làm gì.
Cặp bài trùng của signature là chống replay bằng timestamp tolerance: request có webhook-timestamp lệch quá 5 phút so với đồng hồ server thì từ chối thẳng, kể cả chữ ký đúng. Thiếu bước này, một request hợp lệ bị bắt hôm nay có thể được phát lại tháng sau và vẫn pass verify.
Phía phát: retry là tính năng, không phải may rủi
Bên nhận down là chuyện chắc chắn xảy ra. Câu hỏi là hệ thống của bạn làm gì khi đó. Chuẩn production 2026, nhìn từ cách Stripe, GitHub, Shopify vận hành:
| Thành phần | Yêu cầu tối thiểu |
|---|---|
| Delivery semantics | At-least-once — chấp nhận duplicate, không chấp nhận mất |
| Retry | Exponential backoff có jitter, kéo dài vài giờ đến vài ngày |
| Phân loại lỗi | Timeout, 429, 5xx → retry; 400, 401, 404 → permanent, đừng đấm mãi |
| DLQ | Endpoint chết hẳn → đẩy vào dead-letter queue, disable + báo khách |
| Replay | Khách fix bug xong bấm một nút bắn lại toàn bộ event đã miss |
| Dashboard | Retry history, response code, latency từng lần deliver — khách tự debug được |
Điểm hay bị bỏ qua nhất là replay sau DLQ. Webhook fail không đáng sợ; đáng sợ là fail xong mất luôn. Dashboard của Stripe hay Shopify năm 2026 đều cho khách xem lịch sử từng lần deliver và resend từng event — đó là chuẩn mà khách hàng của bạn sẽ mang ra so.
Phía nhận: duplicate là mặc định, không phải exception
At-least-once nghĩa là bạn sẽ nhận cùng một event hai lần — bên phát timeout trước khi nhận được 200 của bạn rồi gửi lại, dù bạn đã xử lý xong. Hai nguyên tắc:
Idempotency key là bắt buộc. Dùng webhook-id làm khóa dedup: đã xử lý rồi thì trả 200 và bỏ qua. Một unique constraint trong database là đủ, đừng để "trừ tiền hai lần" thành ticket production.
Ack nhanh, xử lý async. Verify chữ ký, ghi event vào queue nội bộ, trả 200 trong vài trăm ms. Xử lý nghiệp vụ nặng để worker làm sau. Handler mất 20 giây gọi ba service khác sẽ timeout đều đặn phía bên phát, ăn retry liên tục, và biến chính bạn thành "endpoint không đáng tin" trong dashboard của vendor.
@app.post("/webhooks/payments")
def handle(request):
wh = Webhook.from_jwks(JWKS_URL) # SDK Standard Webhooks
event = wh.verify(request.body, request.headers) # sig + timestamp tolerance
if not store.insert_once(event_id=request.headers["webhook-id"]):
return 200 # duplicate — đã xử lý rồi
queue.publish(event) # xử lý async ở worker
return 200
Tự xây hay dùng hạ tầng có sẵn?
Nhận webhook thì cứ tự làm — SDK verify + queue + idempotency là đủ. Còn phát webhook ở quy mô nghiêm túc là một hệ thống queue-retry-DLQ-dashboard hoàn chỉnh, và đó là lúc cân nhắc hạ tầng chuyên dụng: Hookdeck nếu muốn managed, Outpost nếu muốn open-source tự host. Ngưỡng hợp lý: dưới vài nghìn event/ngày với một hai khách hàng thì tự xây bằng queue sẵn có; mở webhook như một tính năng sản phẩm cho hàng trăm khách thì đừng tự viết lại retry engine.
Cuối cùng, nhìn cho đúng bản chất: webhook chỉ là event-driven architecture thò ra ngoài biên giới công ty. Nội bộ bạn có Kafka với consumer group, retry, DLQ; webhook là đúng những bài toán đó nhưng consumer là hệ thống của người khác, chạy trên hạ tầng bạn không kiểm soát, viết bởi đội bạn không quen. Mọi giả định lạc quan về network và uptime đều phải vứt đi — phần còn lại chỉ là kỹ thuật quen thuộc, làm cho tử tế.