Nhận webhook báo có không mất, không trùng
Năm nguyên tắc khi viết endpoint nhận webhook báo có: xác thực chữ ký, trả 200 thật nhanh, chống trùng bằng ID giao dịch, chịu được retry và ghi log để đối soát. Kèm đoạn code mẫu bằng Node.js.
Nội dung bài viết
Khi có tiền chuyển vào tài khoản, ngân hàng ghi nhận giao dịch và hệ thống báo có gửi một HTTP request tới server của bạn. Request đó là webhook báo có. Nhờ nó, app của bạn biết khách đã trả tiền mà không cần ai mở app ngân hàng ra xem.
Viết endpoint nhận webhook thì nhanh, nhưng viết cho đúng thì cần để ý vài chỗ. Bài này đi qua năm nguyên tắc giúp bạn không bỏ sót giao dịch và không xác nhận một đơn hai lần.
Webhook báo có hoạt động thế nào
Luồng chung gồm ba bước:
- Khách chuyển khoản vào tài khoản ngân hàng hoặc virtual account của bạn.
- Hệ thống báo có nhận biến động số dư từ ngân hàng, chuẩn hóa về một định dạng chung.
- Hệ thống gửi
POSTtới URL bạn đăng ký, body là JSON mô tả giao dịch.
Một payload thường có ID giao dịch, số tiền, số tài khoản nhận, nội dung chuyển khoản và thời điểm giao dịch. Ví dụ:
{
"event": "transaction.credit",
"id": "txn_01JC9X4M7R",
"bank": "VCB",
"account": "••••6789",
"amount": 1250000,
"description": "DH1024 thanh toan",
"created_at": "2026-11-04T14:30:12+07:00"
}Đây chỉ là ví dụ minh họa. Tên trường và cách ký chính xác nằm trong tài liệu API của nhà cung cấp bạn dùng.
1. Xác thực chữ ký trước khi tin dữ liệu
URL webhook là URL công khai, ai biết cũng gửi request tới được. Nếu bạn tin mọi request, kẻ xấu có thể giả một giao dịch để "thanh toán" đơn hàng.
Cách phổ biến là ký HMAC: bên gửi dùng một secret chung để ký body, gửi chữ ký kèm theo trong header. Bên nhận tự tính lại chữ ký từ body gốc và so sánh. Lưu ý hai chỗ hay sai:
- Tính chữ ký trên body gốc dạng bytes, không phải trên object đã parse rồi stringify lại. Chỉ cần khác một dấu cách là chữ ký lệch.
- So sánh bằng hàm so sánh thời gian hằng như
crypto.timingSafeEqual, không dùng===.
2. Trả 200 thật nhanh, xử lý sau
Bên gửi webhook thường chỉ chờ phản hồi trong vài giây. Quá thời gian đó, request bị coi là lỗi và sẽ được gửi lại. Nếu endpoint của bạn vừa nhận vừa cập nhật đơn, gửi email, gọi API khác... thì rất dễ quá giờ.
Cách làm an toàn: xác thực chữ ký, lưu giao dịch vào database hoặc đẩy vào queue, rồi trả 200 ngay. Việc nặng để một worker chạy sau.
3. Chống trùng bằng ID giao dịch
Cùng một giao dịch có thể tới endpoint của bạn nhiều lần: bên gửi retry vì timeout, mạng chập chờn, hoặc bạn bấm gửi lại thủ công. Vì vậy endpoint phải idempotent: nhận một giao dịch hai lần cũng chỉ cộng tiền một lần.
Cách đơn giản nhất là đặt unique index trên cột ID giao dịch. Lần ghi thứ hai bị database từ chối, bạn bắt lỗi đó và vẫn trả 200, vì giao dịch đã được xử lý rồi.
CREATE TABLE bank_transactions (
id TEXT PRIMARY KEY, -- ID giao dịch trong webhook
amount INTEGER NOT NULL,
description TEXT,
order_id TEXT, -- đơn đã khớp, NULL nếu chưa khớp
received_at TEXT NOT NULL
);4. Chịu được retry và thứ tự lộn xộn
Đừng giả định webhook tới đúng thứ tự giao dịch. Giao dịch 14:31 có thể tới trước giao dịch 14:30 nếu lần gửi đầu của giao dịch 14:30 bị lỗi. Logic khớp đơn nên dựa vào nội dung từng giao dịch, không dựa vào thứ tự nhận.
Ngược lại, khi server của bạn lỗi hoặc đang deploy, cứ trả mã lỗi 5xx. Hệ thống báo có sẽ gửi lại sau, đó là việc của cơ chế retry.
5. Ghi log đủ để đối soát
Lưu lại body gốc, thời điểm nhận và kết quả xử lý của từng webhook. Cuối ngày, khi số tiền trong sổ đơn hàng lệch với sao kê, bạn sẽ cần đúng những dòng log này để tìm ra giao dịch nào chưa khớp.
Ví dụ endpoint bằng Node.js
Đoạn code dưới dùng Hono trên Node.js. Tên header X-Signature, biến WEBHOOK_SECRET và các hàm db, queue, isUniqueViolation là phần của app bạn, chỉ để minh họa.
import { Hono } from "hono";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = new Hono();
app.post("/webhooks/bank", async (c) => {
const raw = await c.req.text(); // body gốc, chưa parse
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!).update(raw).digest("hex");
const received = c.req.header("X-Signature") ?? "";
const ok =
received.length === expected.length &&
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return c.text("invalid signature", 401);
const tx = JSON.parse(raw);
try {
await db.insertTransaction(tx); // unique theo tx.id
} catch (err) {
if (!isUniqueViolation(err)) throw err; // lỗi khác: trả 5xx để được gửi lại
}
await queue.send({ transactionId: tx.id }); // khớp đơn, gửi thông báo ở worker
return c.text("ok");
});Kiểm tra trước khi chạy thật
- Gửi thử một webhook với chữ ký sai, kiểm tra endpoint trả
401. - Gửi cùng một webhook hai lần, kiểm tra đơn chỉ được xác nhận một lần.
- Tắt server một lúc rồi bật lại, kiểm tra các giao dịch trong lúc tắt vẫn về đủ nhờ retry.
- Thử với số tiền lẻ, nội dung có dấu và nội dung trống.
Tóm lại
Một endpoint nhận webhook tốt chỉ làm bốn việc: xác thực, ghi lại, trả 200, rồi để phần còn lại chạy nền. Bốn việc này giúp bạn không mất giao dịch khi server có sự cố và không cộng tiền hai lần khi webhook bị gửi lại.
BankOpenAPI gửi webhook báo có theo một chuẩn chung cho mọi ngân hàng bạn liên kết, có ký HMAC và tự gửi lại khi server của bạn chưa sẵn sàng. Định dạng chính thức nằm trong tài liệu API.