- Published on
BankOpenAPI: tự động xác nhận chuyển khoản bằng VietQR và webhook
- Authors
- Name
- Gen9X
- X
- @gen9xdotcom
Khách chuyển khoản 150.000đ với nội dung "ck tien". Bạn mở app ngân hàng, kéo lịch sử, dò số tiền, đoán xem đây là đơn nào, rồi vào trang admin bấm "Đã thanh toán". Mười đơn một ngày thì còn vui. Hai trăm đơn thì cần một người ngồi dò sao kê cả ngày, và sớm muộn người đó cũng bấm nhầm. VietQR lo được nửa đầu câu chuyện: khách chuyển đúng tài khoản, đúng số tiền. Còn nửa sau là câu hỏi "tiền về chưa?", và ngân hàng không trả lời câu đó cho server của bạn.
Bài trước mình đã bóc từng byte của một mã VietQR. Bài này kể về thứ nằm ở phía bên kia của mã QR, cũng là thứ mình đang làm: BankOpenAPI, một API tự động xác nhận chuyển khoản cho tài khoản ngân hàng Việt Nam. Nói gọn, bạn tạo một yêu cầu thanh toán qua API rồi đưa VietQR cho khách. Khi tiền về đúng mã, server của bạn nhận một webhook có chữ ký. Không phải dò sao kê, không phải bấm tay.
Mình sẽ đi qua cách nó chạy, vài quyết định thiết kế mình nghĩ khá lâu (mã thanh toán, webhook, chống mất và chống trùng), và cả những giới hạn hiện tại. Bài viết cho dev là chính, nhưng gần cuối có một phần cho người không code.
Vấn đề: QR nói tiền đi đâu, không nói tiền đã tới
Một mã VietQR là dữ liệu một chiều. Khách quét, app ngân hàng của khách điền sẵn thông tin, tiền đi qua NAPAS sang ngân hàng của bạn. Suốt chặng đường đó không có bước nào gọi về server của bạn.
Muốn biết tiền đã về, dev Việt thường đi một trong mấy đường:
- Đọc thông báo trên điện thoại. Một máy Android cắm sạc 24/7, app đọc push notification của app ngân hàng rồi gửi lên server. Cách này chạy được cho đến khi app ngân hàng đổi format thông báo, hoặc máy hết pin lúc 2 giờ sáng.
- Cho bot đăng nhập internet banking. Vướng OTP, vướng điều khoản sử dụng, và mỗi lần ngân hàng đổi giao diện là một lần sửa scraper.
- Ký Open API trực tiếp với ngân hàng. Đây là cách chuẩn chỉnh, nhưng mỗi ngân hàng một hợp đồng, một format webhook, một cách ký, và thường chỉ dành cho tài khoản doanh nghiệp. Làm cho một ngân hàng đã mệt, trong khi khách của bạn dùng năm ngân hàng khác nhau.
Mình muốn có một API và một định dạng webhook dùng chung cho mọi ngân hàng. Tiền phải đi thẳng vào tài khoản của mình chứ không qua ví trung gian nào, và hệ thống phải tự biết khoản tiền nào thuộc đơn nào. BankOpenAPI là câu trả lời của mình cho danh sách đó.
Toàn cảnh: tự động xác nhận chuyển khoản trong một sơ đồ
1. Server của bạn → POST /v1/payment-requests → BankOpenAPI
← code TT7K3M9Q + qr.payload + hostedUrl
2. Khách quét VietQR → app ngân hàng → NAPAS → tài khoản CỦA BẠN
3. Ngân hàng báo có → BankOpenAPI khớp mã TT7K3M9Q
→ webhook payment_request.paid → Server của bạn
Điểm quan trọng nằm ở bước 2: tiền không bao giờ đi qua BankOpenAPI. Khách chuyển thẳng vào tài khoản ngân hàng của bạn. BankOpenAPI chỉ nhận thông tin giao dịch (số tiền, nội dung, mã tham chiếu) và không có API nào để chuyển tiền ra. Với mình, đây là ranh giới thiết kế chứ không phải tính năng còn thiếu. Một dịch vụ không giữ tiền và không chuyển được tiền thì có ít chỗ để hỏng hơn hẳn, cả với bạn lẫn với mình.
Muốn liên kết một tài khoản, chủ tài khoản phải xác nhận bằng OTP của ngân hàng hoặc ngay trong app ngân hàng.
Năm phút đầu: tạo một yêu cầu thanh toán
Đầu tiên, đăng ký, tạo API key và liên kết một tài khoản ngân hàng trong app. Tài khoản cá nhân ở phần lớn ngân hàng tự liên kết được trong vài phút. Sau đó bạn gọi:
curl https://app.bankopenapi.com/api/v1/payment-requests \
-H "Authorization: Bearer $BANKOPENAPI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-DH1028" \
-d '{"amount": 150000, "orderId": "DH1028", "description": "Đơn hàng DH1028"}'
Header Idempotency-Key giúp khách bấm "Thanh toán" hai lần cũng chỉ tạo ra một yêu cầu. Mình nói kỹ hơn ở phần sau. Response trả về 201 (đã lược bớt vài trường):
{
"id": "pr_3fK9mQ2xLwA7nB5vR8tY1cZd",
"object": "payment_request",
"livemode": true,
"amount": 150000,
"amountReceived": 0,
"currency": "VND",
"status": "pending",
"code": "TT7K3M9Q",
"orderId": "DH1028",
"transfer": {
"bankBin": "970422",
"accountNumber": "0123456789",
"accountName": "NGUYEN VAN AN",
"amount": 150000,
"content": "TT7K3M9Q"
},
"qr": {
"type": "static",
"payload": "00020101021238540010A00000072701240006970422011001234567890208QRIBFTTA530370454061500005802VN5913NGUYEN VAN AN62120808TT7K3M9Q6304FE1C",
"expiresAt": null
},
"hostedUrl": "https://app.bankopenapi.com/thanh-toan/pr_3fK9mQ2xLwA7nB5vR8tY1cZd",
"expiresAt": "2026-11-06T03:20:11.000Z",
"createdAt": "2026-11-05T03:20:11.000Z"
}
Ba trường bạn sẽ dùng:
code: mã thanh toán gồm 8 ký tự, bắt đầu bằngTT. Khách phải ghi mã này trong nội dung chuyển khoản. VietQR đã điền sẵn mã nên khách chỉ cần quét.qr.payload: chuỗi VietQR dựng sẵn, bạn đưa thẳng vào thư viện vẽ QR (ví dụQRCode.toDataURL(pr.qr.payload)với góiqrcode). Cạnh đó,transfercó đủ ngân hàng, số tài khoản, tên, số tiền và nội dung để hiện cho khách nào muốn tự gõ.hostedUrl: nếu không muốn tự vẽ QR, bạn redirect khách sang đây. Trang thanh toán có sẵn QR, nút sao chép, và tự cập nhật trạng thái khi tiền về.
Theo mặc định, yêu cầu hết hạn sau 24 giờ. Bạn chỉnh được từ 5 phút tới 7 ngày qua expiresInMinutes. Số tiền là số nguyên VND, từ 1.000đ tới 500 triệu. Đủ tham số và mã lỗi có ở tài liệu API yêu cầu thanh toán.
qr.payload: người quen cũ
Ai đã đọc bài trước chắc nhận ra chuỗi này. Chạy readTLV lên nó:
| Tag | Giá trị | Ý nghĩa |
|---|---|---|
| 00 | 01 | Phiên bản |
| 01 | 12 | Mã có sẵn số tiền |
| 38 | 0010A00000072701240006970422011001234567890208QRIBFTTA | NAPAS, BIN 970422 (MB), tài khoản 0123456789 |
| 53 | 704 | VND |
| 54 | 150000 | Số tiền |
| 58 | VN | Quốc gia |
| 59 | NGUYEN VAN AN | Tên chủ tài khoản |
| 62 | 0808TT7K3M9Q | Sub-tag 08, tức nội dung chuyển khoản, chính là mã TT |
| 63 | FE1C | CRC-16, tính lại bằng hàm crc16 ở bài trước vẫn ra đúng |
Không có gì lạ. Tag 38 mang BIN và số tài khoản nhận, tag 54 là số tiền, còn mã TT7K3M9Q nằm ở tag 62, sub-tag 08, đúng chỗ của "nội dung chuyển khoản". Cả chuỗi dài 134 ký tự, toàn ASCII, nên bạn không phải lo chuyện chữ "đ" như bài trước.
Số tài khoản trong QR là tài khoản nhận báo có. Tuỳ ngân hàng, đó là số tài khoản gốc của bạn hoặc một virtual account (VA) trỏ về tài khoản gốc. Loại nào thì tiền cũng nằm trong tài khoản của bạn.
Mã TT: vì sao không dùng luôn mã đơn hàng?
Ai cũng hỏi câu này đầu tiên: đơn đã có DH1028 rồi, sao còn đẻ thêm TT7K3M9Q?
Vì nội dung chuyển khoản là trường dữ liệu bẩn bậc nhất trong cả hệ thống. Từ lúc khách gõ tới lúc báo có về server, nó có thể bị biến dạng theo nhiều kiểu:
- Ngân hàng thêm chữ vào. Tiền tố mã giao dịch, tên người chuyển, chữ "chuyen tien" tự sinh. Gõ
TT7K3M9Q, bạn có thể nhận vềNGUYEN VAN BINH chuyen tien TT7K3M9Q. - Đổi hoa thường, bỏ ký tự đặc biệt.
DH-1028có thể về thànhDH1028, hoặcdh 1028. - Khách tự gõ. Khi không quét QR mà chép tay,
Odễ thành0,Ivàldễ thành1.
Trong khi đó, mã đơn của bạn có thể chứa đủ thứ (orderId cho phép tới 64 ký tự, kể cả _ . : / # -). Phía BankOpenAPI cũng không bắt nó phải unique, vì cùng một đơn bạn có thể tạo yêu cầu mới khi yêu cầu cũ hết hạn. Nên mình tách ra một mã riêng, thiết kế để không hỏng qua tất cả những kiểu biến dạng trên. Còn nếu bạn đang phân vân giữa khớp theo mã và dùng virtual account cho từng khách, blog BankOpenAPI có bài xác nhận chuyển khoản: mã đơn hay virtual account so sánh hai cách.
Mã TT được thiết kế như sau:
TT+ 6 ký tự lấy từ bảng 31 ký tự23456789ABCDEFGHJKMNPQRSTUVWXYZ. Bảng này bỏ hẳn0 O 1 I L, nên khách gõ tay cũng khó nhầm. 31⁶ cho khoảng 887 triệu tổ hợp.- Khớp mềm. Hệ thống viết hoa toàn bộ nội dung, bỏ mọi ký tự không phải chữ hoặc số, rồi tìm
TTcộng 6 ký tự hợp lệ ở bất kỳ vị trí nào.
| Nội dung khách chuyển | Khớp? |
|---|---|
TT7K3M9Q | Có |
tt 7k3m9q | Có, không phân biệt hoa thường, khoảng trắng |
NGUYEN VAN BINH chuyen tien TT7K3M9Q | Có, mã nằm ở đâu cũng được |
TT7K-3M9Q | Có, dấu gạch và dấu chấm bị bỏ qua |
TT7K3M9 | Không, thiếu một ký tự |
DH1028 | Không, chỉ có mã đơn |
Một khoản tiền chỉ được khớp khi đủ ba điều kiện: nội dung có mã, tiền về đúng tài khoản nhận của yêu cầu đó, và yêu cầu vẫn còn mở. Mình còn giữ chặt một nguyên tắc: số tiền dùng để khớp luôn là số tiền thật của giao dịch, không phải số in trong QR. Một số app ngân hàng cho khách sửa số tiền, thậm chí sửa cả nội dung, sau khi quét. Tin vào QR là tự bắn vào chân. Toàn bộ luật khớp, kèm code dựng VietQR từ transfer bằng Node, PHP và Python, có ở tài liệu VietQR và mã thanh toán.
Chuyển thiếu, chuyển thừa, chuyển muộn
Thực tế không bao giờ gọn như demo. Đây là cách hệ thống xử lý từng trường hợp:
| Tình huống | status | Webhook |
|---|---|---|
| Chuyển đủ | paid | payment_request.paid |
| Chuyển thừa | overpaid | payment_request.paid, phần thừa bạn tự hoàn cho khách |
| Chuyển thiếu | underpaid | payment_request.underpaid, mỗi lần nhận thêm mà vẫn thiếu |
| Chuyển thiếu rồi chuyển thêm cho đủ | paid | payment_request.paid, các lần chuyển được cộng dồn |
| Hết hạn rồi khách mới chuyển | vẫn khớp như bình thường | như các dòng trên |
| Nội dung không có mã | không khớp | transaction.received với paymentRequestId: null |
| Bạn huỷ, hoặc hết hạn mà không ai trả | canceled / expired | không gửi gì |
Hai dòng đáng nói thêm. Khi khách chuyển thiếu, transfer.amount và QR của yêu cầu đó tự đổi thành phần còn thiếu, nên bạn chỉ cần cho khách quét lại. Còn "hết hạn vẫn khớp" là cố ý: khách đã chuyển tiền thật thì việc của hệ thống là ghi nhận, không phải từ chối chỉ vì quá giờ.
Có một trường hợp đặc biệt: VietinBank không gửi nội dung chuyển khoản sang BankOpenAPI, nên không khớp được theo mã. Với tài khoản VietinBank, bạn nghe sự kiện transaction.received rồi tự khớp theo số tiền, hoặc khớp tay trong app. Cách sửa tận gốc là QR động riêng cho từng đơn: tiền về mang theo định danh của chính mã QR đó, không cần đến nội dung. Mình đang làm phần này ở những ngân hàng hỗ trợ, nên đừng ngạc nhiên nếu một ngày qr.type của bạn chuyển từ static sang dynamic. Mã TT vẫn nằm trong nội dung như cũ.
Webhook: phần mình tốn thời gian nhất
Gửi một HTTP POST thì dễ. Gửi sao cho bên nhận tin được, không mất, không trùng, không bị giả mạo thì lại là cả một chuyên đề. Mình không tự chế định dạng mà theo Standard Webhooks, một spec mở cho webhook. Lợi ích rất thực tế: Node, PHP, Python, Go đều có sẵn thư viện verify, bạn không phải tin vào code ký của mình.
Mỗi request mang ba header:
webhook-id: evt_2Gc7Vn4Kp9Wq3Xr8Ty5Bm1Hd ← giữ nguyên qua mọi lần gửi lại
webhook-timestamp: 1793849092 ← Unix giây, mới cho mỗi lần thử
webhook-signature: v1,<base64> ← HMAC-SHA256
Và body có dạng (đã lược bớt):
{
"id": "evt_2Gc7Vn4Kp9Wq3Xr8Ty5Bm1Hd",
"type": "payment_request.paid",
"livemode": true,
"timestamp": "2026-11-05T03:24:52.000Z",
"data": {
"id": "pr_3fK9mQ2xLwA7nB5vR8tY1cZd",
"object": "payment_request",
"amount": 150000,
"amountReceived": 150000,
"status": "paid",
"code": "TT7K3M9Q",
"orderId": "DH1028",
"paidAt": "2026-11-05T03:24:52.000Z"
}
}
Ngoài payment_request.paid và payment_request.underpaid, bạn còn đăng ký được transaction.received (mọi khoản tiền vào tài khoản đã liên kết, kể cả không thuộc đơn nào), bank_account.linked và bank_account.removed. Mẫu đầy đủ của từng sự kiện có ở danh sách sự kiện webhook, còn cách verify, lịch retry và xoay secret có ở tài liệu webhook báo có.
Verify với SDK
Với JavaScript/TypeScript thì dùng gói bankopenapi trên npm. Một route Next.js nhận webhook trông như sau:
// app/api/webhooks/bankopenapi/route.ts
import { verifyWebhook, WebhookVerificationError } from 'bankopenapi'
export async function POST(req: Request) {
const body = await req.text() // body thô: verify trước, parse sau
let event
try {
event = await verifyWebhook(body, req.headers, process.env.BANKOPENAPI_WEBHOOK_SECRET!)
} catch (err) {
if (err instanceof WebhookVerificationError)
return new Response('bad signature', { status: 400 })
throw err
}
if (event.type === 'payment_request.paid') {
// event.data có kiểu PaymentRequest, TypeScript tự suy ra từ event.type
await markOrderPaid(event.id, event.data.id)
}
return new Response(null, { status: 200 })
}
Đây là ba lỗi mình thấy dev hay mắc nhất khi nhận webhook:
- Không verify trên body thô. Gọi
req.json()rồiJSON.stringifylại là chữ ký lệch ngay, vì thứ tự key, khoảng trắng hay cách escape Unicode có thể khác đi. Với Express, hãy dùngexpress.rawcho riêng route này và đặt nó trướcexpress.json(). - Trả 2xx quá chậm. Quá 10 giây là tính thất bại và webhook bị gửi lại. Việc nặng như gửi email hay gọi API vận chuyển thì đẩy vào queue.
- Không chống trùng theo
webhook-id. Bị gửi lại là chuyện bình thường của webhook, không phải lỗi.
Không dùng SDK cũng chẳng sao
Spec này đủ đơn giản để tự viết. Nội dung được ký là webhook-id, dấu chấm, webhook-timestamp, dấu chấm, rồi đến body thô. Khoá ký là phần sau whsec_ của signing secret, decode từ base64:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody: string, headers: Headers, secret: string) {
const id = headers.get('webhook-id')
const ts = headers.get('webhook-timestamp')
const sigHeader = headers.get('webhook-signature')
if (!id || !ts || !sigHeader) throw new Error('thiếu header')
// Lệch quá 5 phút thì từ chối, chống replay
const age = Math.abs(Date.now() / 1000 - Number(ts))
if (!Number.isFinite(age) || age > 300) throw new Error('timestamp không hợp lệ')
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest()
// Header có thể chứa nhiều chữ ký cách nhau bởi dấu cách (lúc đang xoay secret)
const ok = sigHeader.split(' ').some((part) => {
const [version, sig] = part.split(',')
const got = Buffer.from(sig ?? '', 'base64')
return version === 'v1' && got.length === expected.length && timingSafeEqual(got, expected)
})
if (!ok) throw new Error('chữ ký sai')
return JSON.parse(rawBody)
}
Chuyện nhiều chữ ký trong một header xảy ra khi bạn xoay secret. Trong khoảng chuyển tiếp (1 giờ, 24 giờ hoặc 7 ngày, tuỳ bạn chọn), mỗi webhook được ký bằng cả secret cũ lẫn secret mới. Nhờ vậy bạn deploy secret mới lúc nào cũng được mà không rơi webhook nào.
Chống trùng bằng chính database
Mình khuyên lưu webhook-id vào một bảng có khoá chính, trong cùng transaction với việc cập nhật đơn:
BEGIN;
-- 0 dòng được chèn = sự kiện này đã xử lý rồi: ROLLBACK và vẫn trả 200
INSERT INTO webhook_events (id) VALUES ('evt_2Gc7Vn4Kp9Wq3Xr8Ty5Bm1Hd')
ON CONFLICT (id) DO NOTHING;
-- Điều kiện status <> 'paid' giúp chạy lại bao nhiêu lần cũng không sai
UPDATE orders
SET status = 'paid', paid_at = now()
WHERE payment_request_id = 'pr_3fK9mQ2xLwA7nB5vR8tY1cZd'
AND status <> 'paid';
COMMIT;
Lúc tạo yêu cầu thanh toán, hãy lưu id dạng pr_… vào đơn rồi khớp theo nó thay vì orderId, vì như đã nói, orderId không unique ở phía BankOpenAPI. Nếu muốn đi sâu hơn phần này, blog BankOpenAPI có bài nhận webhook báo có không mất, không trùng.
Thứ tự webhook cũng không được đảm bảo. Một sự kiện bị retry có thể đến sau sự kiện xảy ra sau nó. Hãy quyết định dựa trên data.status và timestamp, và nếu chưa chắc thì gọi GET /v1/payment-requests/:id để lấy trạng thái hiện tại.
Gửi lại, và khi nào thì dừng
Nếu bên nhận trả mã khác 2xx, timeout, lỗi kết nối, hoặc trả redirect 3xx (redirect không được theo, để webhook không bị dẫn đi nơi khác), hệ thống sẽ gửi lại theo lịch:
| Lần thử | Cách lần trước |
|---|---|
| 1 | gửi ngay |
| 2 | 5 giây |
| 3 | 5 phút |
| 4 | 30 phút |
| 5 | 2 giờ |
| 6 | 5 giờ |
| 7 | 10 giờ |
| 8 | 10 giờ |
Tổng cộng khoảng 27 tiếng, đủ để bạn ngủ dậy, thấy server chết và sửa xong. Một vài mã trả về có ý nghĩa riêng:
429hoặc503kèmRetry-After: lần sau đợi ít nhất chừng đó, tối đa 12 giờ.410 Gone: bảo BankOpenAPI tắt endpoint này.
Endpoint thất bại liên tục 5 ngày thì tự tắt và bạn nhận email. Sự kiện được giữ 30 ngày, bạn gửi lại được từ app hoặc qua POST /v1/events/:id/resend. URL webhook bắt buộc là HTTPS công khai. Localhost và IP nội bộ bị chặn, nên không ai mượn webhook để gọi vào mạng nội bộ được.
Khi ngân hàng quên báo
Đường báo có từ ngân hàng sang BankOpenAPI cũng là một đường mạng, và đôi khi cũng rơi. Vì vậy, ngoài việc nghe báo có, hệ thống còn đối chiếu với lịch sử giao dịch của ngân hàng mỗi 5 phút, nhìn lại 6 giờ gần nhất, và mỗi ngày, nhìn lại 3 ngày. Giao dịch nào lọt lưới sẽ được nhặt lại. Mỗi giao dịch được nhận diện theo mã tham chiếu của ngân hàng, nên một khoản tiền không bị tính hai lần dù đến từ cả hai đường.
Những thứ "nhàm chán" nhưng mình rất để tâm
Một API thanh toán sống hay chết ở những chi tiết mà demo không bao giờ cho thấy. Ai từng dùng API của các cổng thanh toán quốc tế sẽ thấy quen với những lựa chọn dưới đây:
- ID có tiền tố:
pr_cho yêu cầu thanh toán,txn_cho giao dịch,ba_cho tài khoản ngân hàng,evt_cho sự kiện,we_cho webhook endpoint. Nhìn log là biết ID của cái gì. Lỡ dán ID giao dịch vào chỗ cần ID yêu cầu thanh toán thì bạn nhận lỗi 404 rõ ràng, chứ không sai âm thầm. - Tiền là số nguyên VND, thời gian là ISO 8601 UTC. Không float, không
"150.000". Bài trước mình đã kể chuyện format tiền theo locale làm hỏng QR, và mình không muốn lặp lại chuyện đó ở API. Idempotency-Key: gửi lại cùng key trong 24 giờ thì nhận lại đúng response cũ, kèm headerIdempotent-Replayed: true. Cùng key mà body khác thì nhận422 IDEMPOTENCY_KEY_REUSED. Mạng chập chờn thì cứ retry, không lo sinh ra yêu cầu thanh toán thứ hai.- Lỗi có
codecho máy đọc (INVALID_AMOUNT,NO_BANK_ACCOUNT…) vàmessagetiếng Việt cho người đọc. Mọi response đều có headerRequest-ID, nên khi báo lỗi cho mình bạn chỉ cần gửi đúng chuỗi đó. - Phân trang bằng cursor (
limit,starting_after) thay vì offset. Có dữ liệu mới chèn vào lúc bạn đang duyệt thì các trang cũng không bị lệch. - API key có dạng
boa_test_…hoặcboa_live_…, 6 ký tự cuối là checksum. Nhờ tiền tố, công cụ quét secret dễ nhận ra key lỡ bị commit lên Git. Checksum giúp loại sớm key gõ sai hoặc key bịa. Phía mình chỉ lưu hash SHA-256 của key. Mỗi key có thể có quyền đầy đủ hoặc chỉ đọc, có hạn dùng và allowlist IP. Khi bạn xoay key, key cũ vẫn chạy thêm một khoảng chuyển tiếp. - Không bật CORS, và đây là cố ý. API key không có chỗ ở trình duyệt, nên SDK cũng báo lỗi nếu bị gọi từ phía client.
- Rate limit 100 request mỗi phút cho mỗi key. Vượt thì nhận
429kèmRetry-After, key không bị khoá.
Sandbox: chạy cả luồng mà không cần ngân hàng thật
Mình rất ngại cảnh muốn thử một cổng thanh toán mà phải chuyển tiền thật cho chính mình. Vì vậy key boa_test_… mở ra một Sandbox đầy đủ. Tài khoản ngân hàng thử được liên kết ngay. "Chuyển khoản" chỉ cần một lệnh. Webhook vẫn được gửi thật tới endpoint của bạn, với livemode: false. Không cần nạp tiền, không tốn phí.
import BankOpenAPI from 'bankopenapi'
const boa = new BankOpenAPI({ apiKey: process.env.BANKOPENAPI_TEST_KEY })
await boa.sandbox.createBankAccount() // tài khoản thử, liên kết xong ngay
const pr = await boa.paymentRequests.create({ amount: 150000, orderId: 'DH1028' })
await boa.sandbox.payPaymentRequest(pr.id) // "khách chuyển khoản", webhook paid được gửi đi
await boa.sandbox.createTransaction({ amount: 50000, content: 'chuyen tien' }) // tiền vào không thuộc đơn nào
Nếu server đang chạy ở localhost, bạn mở một tunnel (ví dụ cloudflared tunnel --url http://localhost:3000) rồi đăng ký URL đó làm webhook endpoint.
Gói bankopenapi (mã nguồn trên GitHub, tài liệu SDK JavaScript) không phụ thuộc gói nào khác. Nó chạy trên Node.js 20+, Next.js (kể cả Edge), Cloudflare Workers, Bun và Deno. Thư viện tự retry khi gặp lỗi mạng, 429 hoặc 5xx, và tự gắn Idempotency-Key cho mỗi lần tạo yêu cầu. Với ngôn ngữ khác, bạn gọi REST trực tiếp. Tài liệu có sẵn ví dụ curl, Node, PHP và Python cho từng endpoint.
Dùng thẳng từ AI agent
Nếu bạn đang vibe code với Claude Code hay Cursor, BankOpenAPI có sẵn MCP server và một skill. Thêm MCP bằng một lệnh:
claude mcp add --transport http bankopenapi https://app.bankopenapi.com/api/mcp \
--header "Authorization: Bearer $BANKOPENAPI_API_KEY"
Agent có 7 tool:
- xem tài khoản và số dư
- tạo, xem, liệt kê và huỷ yêu cầu thanh toán
- liệt kê giao dịch
Bạn có thể bảo nó "Tạo yêu cầu thanh toán 250.000đ cho đơn DH2041 rồi gửi tôi link thanh toán" và nhận về một hostedUrl.
Skill là một file SKILL.md dạy agent tích hợp cho đúng: key chỉ dùng ở backend, verify webhook trên body thô, chống trùng theo ID sự kiện. Nhờ vậy code agent viết ra không phải sửa lại mấy lỗi kinh điển ở trên:
mkdir -p .claude/skills/bankopenapi
curl -fsSL https://bankopenapi.com/skills/bankopenapi/SKILL.md -o .claude/skills/bankopenapi/SKILL.md
Cấu hình cho Cursor và file .mcp.json dùng chung cho cả project có ở hướng dẫn dùng BankOpenAPI với AI agent.
Lời khuyên: cho agent một key riêng, bắt đầu bằng key boa_test_, và nếu agent chỉ cần tra cứu thì dùng key chỉ đọc. Cả MCP lẫn skill đều không có cách nào chuyển tiền ra. Nhưng với key live, tạo hay huỷ một yêu cầu thanh toán là thao tác thật trên dữ liệu thật.
Không code vẫn dùng được
Không phải ai cần biết tiền về cũng là dev. Chủ shop hay kế toán không cần viết dòng code nào vẫn có:
- Báo có đa kênh. Tiền về là có tin báo vào Telegram, Zalo, Slack, Discord, Lark, Pancake Work, WhatsApp, Google Sheets, Lark Base, email hoặc thông báo trình duyệt. Cả nhóm bán hàng cùng thấy, không ai phải chụp màn hình app ngân hàng gửi vào nhóm nữa. Trong tin báo, số tài khoản được che, chỉ hiện 4 số cuối. Riêng Telegram có bài hướng dẫn báo có qua Telegram cho cả nhóm.
- Link thanh toán không cần code. Số tiền có thể cố định hoặc để khách tự nhập. Bạn dán link vào bio, tin nhắn hay README đều được.
- Đối soát hằng ngày. Bạn xem được đơn nào đã trả, đơn nào chưa, khoản tiền nào về mà không khớp đơn nào. Những khoản lệch thì khớp tay, rồi xuất CSV cho phần mềm kế toán.
Ngân hàng hỗ trợ
Hiện có 14 ngân hàng: Vietcombank, VietinBank, BIDV, MB Bank, ACB, OCB, VPBank, Sacombank, VIB, TPBank, PGBank, Co-opBank, MSB và HDBank.
Ở đa số ngân hàng, tài khoản cá nhân tự liên kết được ngay trong app. Bạn xác nhận bằng OTP hoặc trong app ngân hàng, thường chỉ mất vài phút. Tài khoản hộ kinh doanh và doanh nghiệp thì tuỳ ngân hàng: có nơi tự liên kết được, có nơi phải gửi yêu cầu vì cần đăng ký với ngân hàng trước. Chi tiết theo từng ngân hàng và từng loại tài khoản có ở danh sách ngân hàng hỗ trợ liên kết.
Giá: dùng bao nhiêu trả bấy nhiêu
Không có phí cài đặt, không có gói tháng:
- Nạp lần đầu từ 5.000đ để kích hoạt. Tiền nạp trở thành số dư.
- 100 giao dịch báo có đầu tiên mỗi tháng miễn phí, dùng chung cho mọi tài khoản đã liên kết.
- Từ giao dịch thứ 101, mỗi giao dịch 199đ, trừ vào số dư.
- Mỗi tài khoản ngân hàng tối đa 99.000đ một tháng. Chạm mốc đó thì các giao dịch còn lại trong tháng của tài khoản ấy được miễn phí.
Phí tính theo giao dịch chứ không theo kênh. Một khoản tiền về được báo cùng lúc qua webhook, Telegram và email thì vẫn chỉ tính một lần. Sandbox miễn phí và không cần nạp tiền.
Vài con số cho dễ hình dung:
| Quy mô | Phí tháng |
|---|---|
| 1 tài khoản, 150 giao dịch | 50 × 199đ = 9.950đ |
| 10 tài khoản, mỗi tài khoản 20 giao dịch | 100 × 199đ = 19.900đ |
| 1 tài khoản, 1.000 giao dịch | 179.100đ, chạm trần nên còn 99.000đ |
Hiện đang có ưu đãi cho người mới: nạp lần đầu từ 50.000đ được tặng thêm 50% số dư, áp dụng đến hết 31/12/2026 (chi tiết). Ví dụ và câu hỏi thường gặp về phí có ở bảng giá BankOpenAPI.
Những điều nên biết trước khi dùng
Mình thà nói trước còn hơn để bạn tự phát hiện lúc 2 giờ sáng:
- Hết số dư thì webhook tạm dừng. Giao dịch vẫn được ghi nhận và khớp đơn, nhưng webhook và tin báo không được gửi. Nạp tiền xong, bạn bấm "Gửi bù báo có" trong app để gửi lại các sự kiện trong 30 ngày, vì hệ thống không tự gửi bù. Trong lúc đó,
GET /v1/payment-requests/:idvẫn trả đúng trạng thái. Nếu chạy production, hãy để dư một chút trong số dư cho yên tâm. - VietinBank không gửi nội dung chuyển khoản, như đã nói ở trên.
- SDK chính thức mới có cho JavaScript/TypeScript. Ngôn ngữ khác thì gọi REST, và verify webhook bằng thư viện Standard Webhooks của ngôn ngữ đó.
- Base URL hiện là
https://app.bankopenapi.com/api/v1và sẽ chuyển sangapi.bankopenapi.com, có email báo trước. Hãy để nó trong biến môi trường ngay từ đầu. - Chưa có plugin cho các nền tảng bán hàng như WooCommerce hay Haravan. Hiện bạn nối qua API và webhook theo hướng dẫn tích hợp.
- Tiền nạp không được hoàn lại, và hiện chưa xuất hoá đơn cho phí dịch vụ. Doanh nghiệp cần hoá đơn đầu vào thì lưu ý.
- Chỉ nhận tiền, không chuyển tiền ra. Không có API chi hộ hay chuyển khoản đi. Như đã nói ở trên, đây là chủ đích.
Tóm lại
BankOpenAPI giải một bài toán cụ thể: biết chính xác khi nào tiền chuyển khoản về và khoản tiền đó thuộc đơn nào, mà không phải đụng vào tiền của bạn. Bên dưới là ba ý tưởng không có gì cao siêu:
- một mã thanh toán đủ bền để không hỏng khi đi qua nội dung chuyển khoản
- VietQR dựng sẵn để khách không phải tự gõ mã đó
- một luồng webhook theo chuẩn mở, có chữ ký, có retry và có đối soát, để không mất cũng không trùng
Nếu muốn thử, đây là đường ngắn nhất:
- Đăng ký và tạo một key
boa_test_…. - Chạy đoạn sandbox ở trên, trỏ webhook về server của bạn. Từng bước có ở hướng dẫn bắt đầu nhanh.
- Khi đã ổn, liên kết tài khoản thật, tạo một yêu cầu 1.000đ và tự quét trả cho chính mình.
Tài liệu đầy đủ ở tài liệu API BankOpenAPI. Nếu có câu hỏi hay muốn bàn chuyện tích hợp, bạn vào nhóm Telegram hỗ trợ hoặc gửi mail tới teams@bankopenapi.com. Còn nếu bạn thấy chỗ nào trong thiết kế mà bạn đã làm khác và hay hơn, mình rất muốn nghe. Nhắn mình qua các kênh ở trang About nhé.