Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Async patterns: webhook receiver design

API and Technical Fundamentals cho BA Bài 51/60

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn là BA cho một sàn thương mại điện tử. Khách hàng bấm thanh toán qua cổng VNPay, tiền chuyển đi, nhưng hệ thống của bạn vẫn hiển thị đơn "Chờ thanh toán" suốt 5 phút. Khách hoang mang, gọi tổng đài, đánh giá 1 sao. Vấn đề không nằm ở việc tiền có vào hay không — tiền đã vào — mà nằm ở chỗ: hệ thống của bạn nhận tin về sự kiện đó như thế nào.

Đây chính là địa hạt của webhook receiver. Ở bài 26 trong khóa học, bạn đã học sự khác nhau giữa Webhooks và Polling ở mức "chọn cách nào". Bài 51 này đi sâu hơn một bậc và rất thực chiến: khi đối tác (payment gateway, SMS provider, logistics, CRM) đẩy event vào hệ thống của bạn, thì cái endpoint nhận event đó phải được thiết kế ra sao để không mất tiền, không trùng đơn, không bị hack, và không sập khi đối tác bắn 10.000 event/phút.

Vì sao một BA cần quan tâm tới chuyện tưởng chừng rất "kỹ thuật" này? Bởi vì trong thực tế, chính BA là người viết spec cho cái webhook endpoint đó: định nghĩa nó nhận gì, trả về gì, xử lý lỗi ra sao, ai chịu trách nhiệm khi event bị mất. Nếu spec của bạn thiếu một dòng "verify signature" hay "xử lý duplicate", đội dev sẽ build ra một endpoint mong manh, và sự cố sản xuất sẽ quay lại tìm bạn. Nắm vững thiết kế webhook receiver giúp bạn viết spec đủ chặt, trao đổi ngang hàng với kỹ sư, và phòng ngừa cả một lớp lỗi đắt giá.

Khái niệm cốt lõi

Webhook receiver là gì

Webhook receiver là một HTTP endpoint do hệ thống của bạn cung cấp, để bên thứ ba (nhà cung cấp) gọi vào mỗi khi có sự kiện. Khác với việc bạn chủ động gọi API của họ (request đi ra), ở đây họ gọi vào bạn (request đi vào). Bạn đóng vai "server", còn nhà cung cấp đóng vai "client".

Một endpoint webhook điển hình có hình dạng như sau:

POST /webhooks/payment-gateway
Headers:
  Content-Type: application/json
  X-Signature: t=1719460800,v1=5257a869e7ec...
  X-Event-Id: evt_8f3a1c
Body:
{
  "event": "payment.succeeded",
  "data": {
    "order_id": "ORD-2026-00123",
    "amount": 459000,
    "currency": "VND",
    "transaction_id": "vnp_99281",
    "paid_at": "2026-06-27T10:15:00+07:00"
  }
}

Lưu ý method gần như luôn là POST, vì event mang theo dữ liệu trong body. Path nên có tiền tố rõ ràng theo nhà cung cấp (/webhooks/vnpay, /webhooks/momo) để dễ định tuyến và phân quyền.

Bốn yêu cầu sống còn của một receiver tốt

Khi viết spec, bạn cần đảm bảo receiver xử lý được bốn vấn đề sau. Đây là khung tư duy bạn nên thuộc lòng.

1. Xác thực nguồn gốc (verify signature). Endpoint webhook của bạn nằm công khai trên internet. Bất kỳ ai cũng có thể gửi một request giả mạo payment.succeeded để lừa hệ thống ghi nhận đơn đã trả tiền. Vì vậy, nhà cung cấp ký mỗi payload bằng một secret dùng chung (shared secret) và gắn chữ ký vào header (thường là HMAC-SHA256). Receiver phải tính lại chữ ký từ body nhận được và so khớp; không khớp thì từ chối ngay. Đây là dòng spec quan trọng bậc nhất mà BA hay quên.

2. Phản hồi nhanh và đúng (acknowledge). Hầu hết nhà cung cấp coi webhook là "đã giao" khi nhận được status 2xx trong một khoảng thời gian ngắn (Stripe ví dụ là vài giây; nhiều cổng VN đặt 5–30 giây). Nếu receiver xử lý logic nặng — gửi email, cập nhật kho, gọi API khác — trước khi trả về 200, request có thể timeout. Khi đó nhà cung cấp tưởng giao thất bại và gửi lại. Nguyên tắc vàng: nhận → verify → lưu nhanh vào hàng đợi → trả 200 ngay → xử lý nặng ở background.

3. Idempotency (chống xử lý trùng). Vì nhà cung cấp retry, cùng một event có thể đến hai, ba lần. Nếu mỗi lần đến bạn lại cộng tiền hoa hồng hoặc gửi một email, hậu quả khôn lường. Receiver phải nhận diện event đã xử lý (qua X-Event-Id hoặc transaction_id) và bỏ qua bản trùng. Khái niệm idempotency bạn đã gặp ở bài 25; ở đây nó được áp dụng cụ thể vào receiver.

4. Xử lý lỗi và retry phía bạn. Nếu khi xử lý background gặp lỗi (database chết, API hết hạn token), bạn cần một cơ chế để thử lại, và một "nghĩa địa" cho event không xử lý được sau nhiều lần — gọi là Dead Letter Queue (DLQ). Spec cần nói rõ: retry mấy lần, cách nhau bao lâu, event hỏng đi đâu, ai được cảnh báo.

Mô hình "nhận nhanh, xử lý chậm"

Hình dung luồng chuẩn theo hai pha:

  • Pha 1 (đồng bộ, phải nhanh): nhận request → verify signature → kiểm tra trùng → đẩy vào queue/bảng tạm → trả 200 OK.
  • Pha 2 (bất đồng bộ, có thể chậm): worker lấy event từ queue → chạy business logic (cập nhật đơn, gửi thông báo) → đánh dấu hoàn tất; lỗi thì retry; hết retry thì vào DLQ.
Việc tách hai pha là tinh thần cốt lõi của "async pattern" trong tên bài. Nó giúp endpoint luôn phản hồi nhanh dù backend đang bận, và tránh việc nhà cung cấp retry vô tội vạ.

Tình huống thực tế

Ví dụ 1 — Sàn TMĐT "ChợViệt" mất đồng bộ đơn hàng vì xử lý đồng bộ

ChợViệt tích hợp VNPay. Receiver ban đầu được dev viết kiểu "làm hết trong một request": nhận callback → verify → cập nhật đơn → trừ tồn kho → gọi API đối tác giao hàng GHN tạo vận đơn → gửi email → rồi mới trả 200. Trong giờ cao điểm sale 6/6, API của GHN chậm, mỗi request mất 12 giây. VNPay đặt timeout 15 giây và retry tối đa 3 lần. Kết quả: nhiều callback bị timeout, VNPay gửi lại, và vì receiver chưa có idempotency, một số đơn được tạo vận đơn GHN hai lần — phát sinh hai mã vận đơn cho cùng một gói.

Sau sự cố, BA viết lại spec theo mô hình hai pha: pha 1 chỉ verify + lưu event vào bảng webhook_events rồi trả 200 trong dưới 200ms; toàn bộ phần tạo vận đơn, email được đẩy sang worker. Đồng thời thêm ràng buộc UNIQUE (event_id) để chặn trùng. Bài học: BA phải tách rõ "việc cần làm ngay để xác nhận đã nhận" và "việc xử lý nghiệp vụ", đừng để timeout của đối tác quyết định độ ổn định của bạn.

Ví dụ 2 — Fintech "PayNhanh" bị giả mạo event vì thiếu verify signature

PayNhanh, một ví điện tử giả định ở TP.HCM, cung cấp webhook cho merchant để báo "nạp tiền thành công". Một merchant láu cá phát hiện endpoint nhận event không kiểm tra chữ ký, bèn tự gửi request wallet.topup.succeeded với số tiền 50.000.000đ vào sandbox đối tác và sau đó thử trên production. May mắn là hệ thống còn một lớp đối soát cuối ngày nên phát hiện lệch, nhưng cũng đủ gây một đêm trắng cho cả đội.

Khi rà soát, nhóm phát hiện nhà cung cấp gắn header X-PayNhanh-Signature nhưng spec gốc của merchant không yêu cầu verify, nên dev bỏ qua. BA sau đó bổ sung vào spec: bắt buộc tính lại HMAC-SHA256(secret, raw_body) và so khớp constant-time với header; loại bỏ event quá hạn (kiểm tra timestamp trong header, chênh quá 5 phút thì từ chối để chống replay). Bài học: chữ ký không phải tính năng "nice-to-have" — nó là cánh cửa chính. Một dòng thiếu trong spec có thể thành lỗ hổng tiền thật.

Ví dụ 3 — Startup SaaS "Lịch Hẹn" và Dead Letter Queue cứu một ngày

Lịch Hẹn (đặt lịch phòng khám) nhận webhook từ Zalo OA mỗi khi bệnh nhân nhắn xác nhận. Một hôm, database analytics phụ bị đầy ổ đĩa khiến worker xử lý event ném lỗi liên tục. Nhờ đã thiết kế retry với backoff (thử lại sau 1 phút, 5 phút, 30 phút) và DLQ, các event lỗi không bị mất mà rơi vào hàng đợi chết, kèm cảnh báo Telegram cho đội trực. Sau khi dọn ổ đĩa, đội cho "replay" toàn bộ event trong DLQ và mọi xác nhận lịch được xử lý đầy đủ, không bệnh nhân nào bị bỏ sót.

Điều thú vị là yêu cầu DLQ và "replay được" này nằm ngay trong spec do BA viết, sau khi BA hỏi đúng một câu trong buổi refinement: "Nếu xử lý event thất bại thì event đi đâu, và làm sao xử lý lại?". Bài học: chính câu hỏi nghiệp vụ đơn giản của BA — "mất event thì sao?" — lại dẫn tới thiết kế kỹ thuật cứu cả hệ thống.

Hướng dẫn từng bước

Đây là quy trình bạn có thể dùng để viết spec cho một webhook receiver, từ góc nhìn BA.

Bước 1 — Xác định nhà cung cấp và danh mục event. Liệt kê tất cả event bạn cần nhận và ý nghĩa nghiệp vụ của từng cái. Ví dụ với cổng thanh toán: payment.succeeded, payment.failed, refund.completed. Đọc tài liệu OpenAPI/Swagger của họ (kỹ năng bài 41) để biết payload thật.

Bước 2 — Định nghĩa hình dạng endpoint. Chốt method (POST), path theo nhà cung cấp (/webhooks/vnpay), Content-Type mong đợi, và các header xác thực bắt buộc. Ghi rõ ví dụ payload mẫu cho mỗi loại event để dev và QA cùng tham chiếu.

Bước 3 — Quy định xác thực. Nêu rõ phương thức ký (HMAC-SHA256, hay với một số cổng VN là so khớp secure_hash theo thứ tự tham số), tên header chứa chữ ký, secret lấy ở đâu, và quy tắc từ chối: sai chữ ký trả 401, thiếu header trả 400.

Bước 4 — Quy định phản hồi. Thành công trả 200 (hoặc 202 Accepted nếu muốn nhấn mạnh "đã nhận, sẽ xử lý sau"). Đặt mục tiêu thời gian phản hồi (ví dụ dưới 500ms). Lưu ý: chỉ trả lỗi 5xx khi bạn muốn nhà cung cấp retry; nếu event không hợp lệ về mặt nghiệp vụ, nên trả 200 và tự log để tránh retry vô ích.

Bước 5 — Quy định idempotency. Chỉ định khóa nhận diện duy nhất (event_id hoặc transaction_id), nơi lưu lịch sử event đã xử lý, và hành vi khi gặp trùng (bỏ qua, vẫn trả 200).

Bước 6 — Quy định xử lý bất đồng bộ và lỗi. Mô tả luồng hai pha, cơ chế queue, số lần retry, khoảng cách backoff, điều kiện vào DLQ, và kênh cảnh báo. Đừng quên yêu cầu "replay được từ DLQ".

Bước 7 — Quy định quan sát và đối soát. Yêu cầu log mọi event (đã đề cập kỹ ở bài 49) và một báo cáo đối soát định kỳ giữa event nhận được và trạng thái nghiệp vụ — lớp lưới an toàn cuối cùng.

Lỗi thường gặp & mẹo

Xử lý logic nặng trước khi trả 200. Đây là lỗi phổ biến nhất, dẫn tới timeout và retry tràn lan. Mẹo: trong spec, viết thẳng câu "verify → lưu vào queue → trả 200 trong dưới X ms; mọi xử lý nghiệp vụ chạy ở worker".

Quên idempotency. Nhiều người tin "đối tác chỉ gửi một lần". Sai. Retry là chuyện bình thường, thậm chí mạng chập chờn cũng gây trùng. Luôn có khóa duy nhất và ràng buộc UNIQUE ở tầng database — đó là lưới chặn cuối cùng đáng tin nhất.

Dùng sai status code khiến retry sai. Trả 4xx/5xx cho mọi lỗi là cái bẫy. Nếu chữ ký sai (giả mạo) thì 401không nên để retry. Nếu lỗi tạm thời phía bạn (DB chết) thì 5xx để nhà cung cấp retry. Nếu event hợp lệ nhưng nghiệp vụ bỏ qua thì 200. Phân biệt rõ ba nhóm này.

So khớp chữ ký không constant-time, hoặc verify trên body đã bị parse. Phải verify trên raw body nguyên gốc, vì chỉ cần JSON được serialize lại khác đi một dấu cách là chữ ký lệch. Mẹo dành cho BA: ghi chú "verify trên raw request body, trước khi parse JSON" để dev không vô tình làm sai.

Không có DLQ và không replay được. Khi đó event lỗi biến mất vĩnh viễn. Luôn yêu cầu nơi chứa event hỏng và khả năng xử lý lại.

Quên chống replay. Kẻ tấn công có thể bắt một event hợp lệ cũ và gửi lại. Mẹo: kiểm tra timestamp trong chữ ký, từ chối event quá cũ (ví dụ quá 5 phút).

Bài tập thực hành

  • Viết mini-spec. Chọn một cổng thanh toán VN (VNPay, MoMo hoặc ZaloPay). Viết spec cho endpoint POST /webhooks/<gateway> gồm: danh sách 3 event, payload mẫu, header xác thực, quy tắc verify, status code cho từng tình huống (hợp lệ, sai chữ ký, lỗi DB tạm thời, event nghiệp vụ bỏ qua).
  • Vẽ luồng hai pha. Dùng sơ đồ tuần tự (sequence diagram) mô tả: nhà cung cấp → receiver (verify, lưu queue, trả 200) → worker (xử lý, retry, DLQ). Đánh dấu mốc thời gian mục tiêu cho pha 1.
  • Thiết kế bảng webhook_events. Liệt kê các cột bạn cho là cần thiết để bảo đảm idempotency, truy vết và replay (gợi ý: id sự kiện, nhà cung cấp, loại event, payload thô, trạng thái xử lý, số lần retry, thời điểm nhận, thời điểm xử lý).
  • Tình huống tranh luận. Đối tác đề nghị: "Cứ trả 200 cho mọi request cho nhanh, lỗi gì hai bên tự đối soát cuối ngày." Hãy viết một đoạn phản biện ngắn nêu rủi ro và đề xuất phương án dung hòa.

Tóm tắt

Webhook receiver là endpoint hệ thống của bạn mở ra để đối tác đẩy event vào, và việc thiết kế nó tốt hay tệ ảnh hưởng trực tiếp tới tiền bạc, trải nghiệm khách hàng và an ninh. Bốn trụ cột cần nhớ: verify signature để chống giả mạo, phản hồi nhanh để tránh timeout và retry, idempotency để chống xử lý trùng, và xử lý lỗi với retry + DLQ để không mất event. Tinh thần xuyên suốt là mô hình "nhận nhanh, xử lý chậm" — pha một xác nhận tức thì, pha hai xử lý nghiệp vụ ở background.

Là BA, vai trò của bạn không phải code cái endpoint, mà là viết spec đủ chặt để dev build đúng và QA test đủ. Ba câu hỏi vàng bạn nên luôn đặt ra trong buổi refinement: "Làm sao biết event này thật sự đến từ đối tác?", "Nếu cùng một event đến hai lần thì sao?", và "Nếu xử lý event thất bại thì nó đi đâu và xử lý lại thế nào?". Trả lời tốt ba câu này, bạn đã loại bỏ phần lớn các sự cố webhook đắt giá trước khi chúng kịp xảy ra.