Tích hợp
Tích hợp qua API và webhook
Hệ thống của bạn tạo yêu cầu thanh toán cho mỗi đơn, hiện VietQR cho khách và nhận webhook khi tiền về đủ. Thử trước ở sandbox, gọi API bằng thư viện chính thức hoặc HTTP thuần, hoặc để AI agent làm qua MCP.
1Hệ thống của bạn tạo yêu cầu thanh toán
POST /v1/payment-requests
{ "amount": 150000, "orderId": "DH1028" }2Nhận mã chuyển khoản, hiện VietQR cho khách
201 Created
{ "code": "TT7K3M9Q", "transfer": { "accountNumber": "0123456789" } }3Khách chuyển khoản, hệ thống nhận webhook
POST /webhooks/bankopenapi200 OK
{ "type": "payment_request.paid", "data": { "orderId": "DH1028", "status": "paid" } }
Bắt đầu
4 bước tới webhook đầu tiên
Làm thử ở sandbox bằng API key thử, chạy đúng rồi đổi sang key thật. Code giữ nguyên, chỉ đổi key.
- 1
Tạo API key
Trong app, mục API key: key
Xác thựcboa_test_…cho sandbox,boa_live_…cho dữ liệu thật. Tải file.envngay khi tạo. - 2
Liên kết tài khoản ngân hàng
Liên kết trong app, chủ tài khoản xác nhận bằng OTP ngân hàng gửi. Ở sandbox chỉ cần bấm Thêm tài khoản thử.
Ngân hàng hỗ trợ - 3
Tạo yêu cầu thanh toán
API yêu cầu thanh toánPOST /v1/payment-requestsvới số tiền và mã đơn. Nhận về mã chuyển khoản, thông tin dựng VietQR và link trang thanh toán. - 4
Nhận webhook
Khai báo endpoint, chọn
Nhận và verify webhookpayment_request.paid, verify chữ ký rồi cập nhật đơn. Bấm Gửi thử để kiểm tra code.
Tự động xác nhận thanh toán
Tiền về tự khớp đơn, không soát tay
Mỗi yêu cầu thanh toán có một mã riêng dạng TT + 6 ký tự. VietQR điền sẵn mã vào nội dung chuyển khoản; tiền về, BankOpenAPI đọc mã, cộng vào đúng đơn và gửi webhook.
- Đọc mã không phân biệt hoa thường, bỏ qua dấu cách và ký tự đặc biệt: khách gõ “tt 7k3m9q” vẫn khớp.
- Ngân hàng hỗ trợ QR động (MB, OCB, BIDV, VietinBank, Vietcombank, VPBank): mỗi đơn một mã QR riêng, khớp chính xác hơn mã trong nội dung.
- Chuyển nhiều lần được cộng dồn. Thiếu thì báo
payment_request.underpaid; đủ hoặc thừa thì báopayment_request.paid, phần thừa bạn tự hoàn cho khách. - Hạn thanh toán mặc định 24 giờ, đặt từ 5 phút tới 7 ngày. Tiền về sau hạn vẫn được khớp, không lạc đơn.
- Có sẵn trang thanh toán (
hostedUrl): hiện VietQR, số tài khoản, nội dung, tự chuyển sang Đã nhận khi tiền về.
- 10:20Tạo yêu cầu
pendingMã chuyển khoản TT7K3M9Q - 10:24Nhận 100.000 ₫
underpaidWebhook payment_request.underpaid100.000 ₫ - 10:31Nhận thêm 50.000 ₫
paidWebhook payment_request.paid150.000 ₫
Code mẫu
Code mẫu chép là chạy
curl, Node.js, PHP và Python cho từng bước. Chọn ngôn ngữ một lần, mọi khối code trên trang đổi theo.
curl -X POST https://app.bankopenapi.com/api/v1/payment-requests \
-H "Authorization: Bearer $BANKOPENAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 150000,
"orderId": "DH1028",
"description": "Đơn hàng DH1028"
}'const res = await fetch("https://app.bankopenapi.com/api/v1/payment-requests", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BANKOPENAPI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 150000,
orderId: "DH1028",
description: "Đơn hàng DH1028",
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
console.log(data);<?php
$ch = curl_init('https://app.bankopenapi.com/api/v1/payment-requests');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('BANKOPENAPI_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 150000,
'orderId' => 'DH1028',
'description' => 'Đơn hàng DH1028',
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($data['error']['code'] . ': ' . $data['error']['message']);
}
print_r($data);import os
import requests
res = requests.post(
"https://app.bankopenapi.com/api/v1/payment-requests",
headers={"Authorization": f"Bearer {os.environ['BANKOPENAPI_API_KEY']}"},
json={
"amount": 150000,
"orderId": "DH1028",
"description": "Đơn hàng DH1028",
},
timeout=15,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
print(data)import QRCode from "qrcode"; // npm install qrcode
// Chuỗi VietQR (chuẩn EMVCo của NAPAS) từ trường transfer của yêu cầu thanh toán
const tlv = (id, value) => id + String(value.length).padStart(2, "0") + value;
function crc16(s) {
let crc = 0xffff;
for (let i = 0; i < s.length; i++) {
crc ^= s.charCodeAt(i) << 8;
for (let j = 0; j < 8; j++) crc = (crc & 0x8000 ? (crc << 1) ^ 0x1021 : crc << 1) & 0xffff;
}
return crc.toString(16).toUpperCase().padStart(4, "0");
}
export function vietqr({ bankBin, accountNumber, amount, content }) {
const merchant = tlv("00", "A000000727") + tlv("01", tlv("00", bankBin) + tlv("01", accountNumber)) + tlv("02", "QRIBFTTA");
const payload =
tlv("00", "01") +
tlv("01", amount > 0 ? "12" : "11") +
tlv("38", merchant) +
tlv("53", "704") +
(amount > 0 ? tlv("54", String(amount)) : "") +
tlv("58", "VN") +
(content ? tlv("62", tlv("08", content)) : "") +
"6304";
return payload + crc16(payload);
}
// Vẽ ảnh QR (data URL) để đặt vào thẻ <img>
const dataUrl = await QRCode.toDataURL(vietqr(paymentRequest.transfer), { width: 320, margin: 2 });
// <img src={dataUrl} alt="Mã VietQR"><?php
// Chuỗi VietQR (chuẩn EMVCo của NAPAS) từ trường transfer của yêu cầu thanh toán
function tlv(string $id, string $value): string
{
return $id . str_pad((string) strlen($value), 2, '0', STR_PAD_LEFT) . $value;
}
function crc16(string $s): string
{
$crc = 0xFFFF;
for ($i = 0; $i < strlen($s); $i++) {
$crc ^= ord($s[$i]) << 8;
for ($j = 0; $j < 8; $j++) {
$crc = ($crc & 0x8000 ? ($crc << 1) ^ 0x1021 : $crc << 1) & 0xFFFF;
}
}
return sprintf('%04X', $crc);
}
function vietqr(array $t): string
{
$merchant = tlv('00', 'A000000727') . tlv('01', tlv('00', $t['bankBin']) . tlv('01', $t['accountNumber'])) . tlv('02', 'QRIBFTTA');
$payload = tlv('00', '01')
. tlv('01', $t['amount'] > 0 ? '12' : '11')
. tlv('38', $merchant)
. tlv('53', '704')
. ($t['amount'] > 0 ? tlv('54', (string) $t['amount']) : '')
. tlv('58', 'VN')
. ($t['content'] ? tlv('62', tlv('08', $t['content'])) : '')
. '6304';
return $payload . crc16($payload);
}
$qr = vietqr($paymentRequest['transfer']);
// Vẽ ảnh bằng thư viện QR bất kỳ, vd. composer require chillerlan/php-qrcodeimport qrcode # pip install "qrcode[pil]"
# Chuỗi VietQR (chuẩn EMVCo của NAPAS) từ trường transfer của yêu cầu thanh toán
def tlv(id: str, value: str) -> str:
return f"{id}{len(value):02d}{value}"
def crc16(s: str) -> str:
crc = 0xFFFF
for ch in s.encode("ascii"):
crc ^= ch << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021 if crc & 0x8000 else crc << 1) & 0xFFFF
return f"{crc:04X}"
def vietqr(t: dict) -> str:
merchant = tlv("00", "A000000727") + tlv("01", tlv("00", t["bankBin"]) + tlv("01", t["accountNumber"])) + tlv("02", "QRIBFTTA")
payload = (
tlv("00", "01")
+ tlv("01", "12" if t["amount"] > 0 else "11")
+ tlv("38", merchant)
+ tlv("53", "704")
+ (tlv("54", str(t["amount"])) if t["amount"] > 0 else "")
+ tlv("58", "VN")
+ (tlv("62", tlv("08", t["content"])) if t["content"] else "")
+ "6304"
)
return payload + crc16(payload)
# Vẽ ảnh QR
qrcode.make(vietqr(payment_request["transfer"])).save("vietqr.png")import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
// Signing secret của endpoint (dạng "whsec_..."), lấy ở trang Webhook trong app
const wh = new Webhook(process.env.BANKOPENAPI_WEBHOOK_SECRET);
// express.raw giữ nguyên body thô: verify phải chạy trên đúng chuỗi BankOpenAPI đã ký
app.post("/webhooks/bankopenapi", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers);
} catch {
return res.status(400).send("Invalid signature");
}
// Chống xử lý trùng: một sự kiện giữ nguyên event.id qua mọi lần retry
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
if (event.type === "payment_request.paid") {
await markOrderPaid(event.data.orderId, event.data);
}
await markProcessed(event.id);
res.sendStatus(200);
});
app.listen(3000);<?php
// composer require standard-webhooks/standard-webhooks (PHP 8+)
require __DIR__ . '/vendor/autoload.php';
use StandardWebhooks\Webhook;
use StandardWebhooks\Exception\WebhookVerificationException;
$payload = file_get_contents('php://input');
// Thư viện đọc header bằng tên chữ thường: webhook-id, webhook-timestamp, webhook-signature
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
try {
$wh = new Webhook(getenv('BANKOPENAPI_WEBHOOK_SECRET'));
$event = $wh->verify($payload, $headers);
} catch (WebhookVerificationException $e) {
http_response_code(400);
exit('Invalid signature');
}
// Chống xử lý trùng: một sự kiện giữ nguyên id qua mọi lần retry
if (alreadyProcessed($event['id'])) {
http_response_code(200);
exit;
}
if ($event['type'] === 'payment_request.paid') {
markOrderPaid($event['data']['orderId'], $event['data']);
}
markProcessed($event['id']);
http_response_code(200);import os
from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
# Signing secret của endpoint (dạng "whsec_..."), lấy ở trang Webhook trong app
wh = Webhook(os.environ["BANKOPENAPI_WEBHOOK_SECRET"])
@app.post("/webhooks/bankopenapi")
def bankopenapi_webhook():
try:
# get_data() trả body thô (bytes), đúng chuỗi BankOpenAPI đã ký
event = wh.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
return "Invalid signature", 400
# Chống xử lý trùng: một sự kiện giữ nguyên id qua mọi lần retry
if already_processed(event["id"]):
return "", 200
if event["type"] == "payment_request.paid":
mark_order_paid(event["data"]["orderId"], event["data"])
mark_processed(event["id"])
return "", 200Sandbox
Giả lập tiền vào, không tốn phí
Thử trọn luồng từ tạo đơn tới webhook mà không chuyển đồng nào. Đổi key thử sang key thật là chạy với tiền thật.
- Key
boa_test_chỉ thấy dữ liệu thử; app có công tắc Sandbox để xem cùng dữ liệu đó. - Giả lập thêm tài khoản ngân hàng, tiền vào với số tiền và nội dung tùy ý, hoặc thanh toán một yêu cầu.
- Chạy đúng luồng thật: khớp đơn, lưu sự kiện, gửi webhook với
livemode: false. - Không tính phí, không gửi tin lên kênh chat. Trong app có nút Giả lập tiền vào, không cần viết code.
- Tài liệu sandbox
curl -X POST https://app.bankopenapi.com/api/v1/test/transactions \
-H "Authorization: Bearer $BANKOPENAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 150000,
"content": "NGUYEN VAN BINH chuyen tien TT7K3M9Q"
}'const res = await fetch("https://app.bankopenapi.com/api/v1/test/transactions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BANKOPENAPI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 150000,
content: "NGUYEN VAN BINH chuyen tien TT7K3M9Q",
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
console.log(data);<?php
$ch = curl_init('https://app.bankopenapi.com/api/v1/test/transactions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('BANKOPENAPI_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 150000,
'content' => 'NGUYEN VAN BINH chuyen tien TT7K3M9Q',
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($data['error']['code'] . ': ' . $data['error']['message']);
}
print_r($data);import os
import requests
res = requests.post(
"https://app.bankopenapi.com/api/v1/test/transactions",
headers={"Authorization": f"Bearer {os.environ['BANKOPENAPI_API_KEY']}"},
json={
"amount": 150000,
"content": "NGUYEN VAN BINH chuyen tien TT7K3M9Q",
},
timeout=15,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
print(data)Thiết kế API
API viết cho dev, không bắt đoán
Thư viện chính thức
Gọi API, verify webhook và xử lý lỗi đã viết sẵn. Không muốn thêm phụ thuộc thì gọi REST bằng HTTP client nào cũng được.
API key an toàn
Key có tiền tố và checksum, dễ nhận ra khi lỡ lộ. Quyền đầy đủ hoặc chỉ đọc, có hạn dùng, đổi key có thời gian chuyển tiếp.
Gọi lại an toàn
Gửi header Idempotency-Key: gọi lại cùng request trong 24 giờ chỉ tạo một yêu cầu, phản hồi cũ được trả lại.
Lỗi dễ xử lý
JSON có mã lỗi cố định để rẽ nhánh, thông báo tiếng Việt. Mỗi phản hồi có Request-ID để tra log.
Quy ước nhất quán
Phân trang cursor (limit, starting_after), ID có tiền tố pr_, txn_, evt_, tiền là số nguyên VND, thời gian ISO UTC.
Giới hạn rõ ràng
100 request mỗi phút cho mỗi key. Vượt thì nhận 429 kèm Retry-After, không bị khóa key.
Chỉ gọi từ backend
API không mở CORS, nên key không lọt ra trình duyệt của khách. Frontend gọi backend của bạn, backend gọi BankOpenAPI.
Nhật ký truy cập
Xem lịch sử gọi API và IP dùng key gần nhất. Email báo khi tạo, đổi key và 7 ngày trước khi key hết hạn.
API Reference
Endpoint có sẵn
Mọi endpoint nhận và trả JSON, xác thực bằng header Authorization: Bearer. Tham số, phản hồi và mã lỗi của từng endpoint nằm trong tài liệu.
Yêu cầu thanh toán
- POST
/v1/payment-requestsTạo yêu cầu cho một đơn - GET
/v1/payment-requestsLiệt kê, lọc theo trạng thái, mã đơn - GET
/v1/payment-requests/{id}Xem kèm các giao dịch đã khớp - POST
/v1/payment-requests/{id}/cancelHủy khi chưa nhận tiền
Giao dịch
- GET
/v1/transactionsLọc theo tài khoản, loại, thời gian - GET
/v1/transactions/{id}Xem một giao dịch
Tài khoản ngân hàng
- GET
/v1/bank-accountsTài khoản đã liên kết - GET
/v1/bank-accounts/{id}Xem một tài khoản
Webhook endpoint
- POST
/v1/webhook-endpointsTạo endpoint (GET để liệt kê) - PATCH
/v1/webhook-endpoints/{id}Sửa endpoint (DELETE để xóa) - POST
/v1/webhook-endpoints/{id}/rotate-secretĐổi signing secret - GET
/v1/webhook-endpoints/{id}/deliveriesLịch sử gửi, gửi lại lần lỗi
Sự kiện
- GET
/v1/eventsSự kiện 30 ngày gần nhất, lọc theo loại - POST
/v1/events/{id}/resendGửi lại một sự kiện tới endpoint
Tài khoản và sandbox
- GET
/v1/accountTài khoản sở hữu key, dùng để thử key - POST
/v1/test/transactionsGiả lập tiền vào (key thử) - POST
/v1/test/payment-requests/{id}/payGiả lập thanh toán
Dùng với AI agent
Để AI agent gọi API thay bạn
Kết nối Claude Code, Cursor hay agent khác qua MCP bằng chính API key. Cài skill BankOpenAPI để agent viết code tích hợp theo đúng tài liệu.
7 tool MCP
create_payment_requestTạo yêu cầu thanh toánget_payment_requestXem trạng thái, số tiền đã nhậnlist_payment_requestsLiệt kê, lọc theo trạng thái, mã đơncancel_payment_requestHủy yêu cầu chưa nhận tiềnlist_transactionsLiệt kê giao dịchget_balanceSố dư, lượt miễn phí còn lạiget_accountTài khoản sở hữu key
Key thử: agent chỉ làm việc trên dữ liệu sandbox. Key chỉ đọc: agent chỉ thấy các tool đọc.
Hướng dẫn dùng với AI agentclaude mcp add --transport http bankopenapi \
https://app.bankopenapi.com/api/mcp \
--header "Authorization: Bearer $BANKOPENAPI_API_KEY"mkdir -p .claude/skills/bankopenapi
curl -fsSL https://bankopenapi.com/skills/bankopenapi/SKILL.md \
-o .claude/skills/bankopenapi/SKILL.mdTrong app
Công cụ cho dev trong app
Giả lập trong app
Nút Giả lập tiền vào ở trang Giao dịch, Giả lập thanh toán ở từng yêu cầu. Thử luồng webhook mà không chuyển tiền thật.
Lịch sử webhook
Từng lần gửi: mã HTTP, thời gian phản hồi, đoạn đầu body. Tab Sự kiện xem payload và gửi lại tới endpoint bất kỳ.
Link thanh toán không cần code
Tạo link cố định số tiền hoặc để khách tự nhập, gắn vào trang bán hàng, README hay FUNDING.yml.
Xuất CSV giao dịch
Lọc theo tài khoản và khoảng thời gian rồi tải về, đưa vào phần mềm kế toán.
Đối soát và khớp tay
Danh sách tiền về chưa khớp đơn và đơn chưa có tiền. Khớp tay một giao dịch vào đúng đơn khi khách ghi sai nội dung.
Danh sách Bắt đầu
Trang Tổng quan dẫn từng bước: tạo key, liên kết tài khoản, thêm webhook, thử giao dịch đầu tiên.
Câu hỏi về tích hợp
Chưa thấy câu bạn cần? Hỏi chúng tôi qua trang Liên hệ.
Có thư viện chính thức không?
Có. Thư viện chính thức gói sẵn việc gọi API, verify webhook và xử lý lỗi. Không muốn thêm phụ thuộc thì gọi REST trực tiếp: tài liệu có ví dụ curl, Node.js, PHP, Python cho mọi endpoint.
Ngôn ngữ, framework nào dùng được?
Mọi ngôn ngữ gọi được HTTPS và đọc JSON: PHP, Node.js, Python, Go, Java, .NET… Webhook dùng chuẩn Standard Webhooks, có thư viện verify cho hầu hết ngôn ngữ phổ biến.
Gọi API từ frontend hay app mobile được không?
Không. API key chỉ dùng ở backend và API không mở CORS. Frontend hoặc app mobile gọi backend của bạn, backend gọi BankOpenAPI.
Có API liên kết tài khoản ngân hàng không?
Liên kết làm trong app, vì chủ tài khoản phải xác nhận bằng OTP ngân hàng gửi. API đọc được danh sách tài khoản đã liên kết, và webhook có sự kiện bank_account.linked, bank_account.removed.
Khách chuyển khoản sai nội dung thì sao?
Giao dịch vẫn được ghi và gửi sự kiện transaction.received, chỉ không tự khớp đơn. Bạn khớp tay ở trang Đối soát trong app, hoặc tự khớp trong code theo số tiền và thời điểm.
Giới hạn gọi API là bao nhiêu?
100 request mỗi phút cho mỗi API key (MCP dùng chung giới hạn này). Vượt thì nhận 429 kèm Retry-After. Giả lập ở sandbox giới hạn thêm 10 lần mỗi phút.
AI agent có đụng vào tiền thật không?
Chỉ khi bạn đưa key thật. Dùng key thử thì agent chỉ thấy dữ liệu sandbox; key chỉ đọc thì agent không tạo hay hủy được yêu cầu nào. BankOpenAPI không có API chuyển tiền đi.
Bắt đầu với BankOpenAPI
Đăng ký tài khoản, liên kết tài khoản ngân hàng và nhận webhook báo có đầu tiên. Cần tư vấn thì nhắn chúng tôi.