Bắt đầu nhanh
Bốn bước để hệ thống của bạn tự biết đơn nào đã được chuyển khoản: tạo API key, liên kết tài khoản ngân hàng, tạo yêu cầu thanh toán, nhận webhook. Chưa có tài khoản thì đăng ký trước. Sandbox dùng ngay, không cần nạp tiền; liên kết tài khoản ngân hàng thật cần nạp lần đầu từ 5.000 ₫.
1. Tạo API key
Vào trang API key trong app, bấm "Tạo API key", đặt tên (ví dụ "Web bán hàng (production)"). Khóa có dạng boa_live_ + 36 ký tự và chỉ hiện đúng một lần: sao chép ngay vào biến môi trường của hệ thống.
export BANKOPENAPI_API_KEY="boa_live_..."Gọi thử endpoint tài khoản để kiểm tra khóa:
curl https://app.bankopenapi.com/api/v1/account \
-H "Authorization: Bearer $BANKOPENAPI_API_KEY"const res = await fetch("https://app.bankopenapi.com/api/v1/account", {
headers: {
Authorization: `Bearer ${process.env.BANKOPENAPI_API_KEY}`,
},
});
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/account');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('BANKOPENAPI_API_KEY'),
],
]);
$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.get(
"https://app.bankopenapi.com/api/v1/account",
headers={"Authorization": f"Bearer {os.environ['BANKOPENAPI_API_KEY']}"},
timeout=15,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
print(data){
"object": "account",
"id": "Xq7oT2mB9kLr4VnC8pWzA1sD3fGhJ6uE",
"email": "dev@example.com",
"name": "Nguyễn Văn An",
"createdAt": "2026-11-04T08:15:30.000Z"
}Ví dụ curl viết cho bash, zsh (macOS, Linux, WSL). Khóa chỉ dùng từ backend: đừng đặt trong code chạy trên trình duyệt hay app di động. Xem Xác thực.
2. Liên kết tài khoản ngân hàng
Vào trang Tài khoản ngân hàng, chọn ngân hàng, nhập thông tin tài khoản và OTP ngân hàng gửi về số điện thoại của chủ tài khoản. Liên kết xong, mọi khoản tiền vào tài khoản này đều được BankOpenAPI ghi nhận.
Bước này làm trong app vì chủ tài khoản phải xác nhận bằng OTP; muốn thử trước thì bật Sandbox và thêm tài khoản thử. Chưa có tài khoản nào thì request tạo yêu cầu thanh toán trả lỗi 422 NO_BANK_ACCOUNT. Danh sách ngân hàng ở trang Ngân hàng hỗ trợ.
3. Tạo yêu cầu thanh toán
Khi khách đặt đơn, hệ thống của bạn tạo một yêu cầu thanh toán với số tiền và mã đơn của bạn. Không gửi bankAccountId thì BankOpenAPI dùng tài khoản liên kết gần nhất.
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){
"id": "pr_3fK9mQ2xLwA7nB5vR8tY1cZd",
"object": "payment_request",
"livemode": true,
"amount": 150000,
"amountReceived": 0,
"currency": "VND",
"status": "pending",
"code": "TT7K3M9Q",
"orderId": "DH1028",
"description": "Đơn hàng DH1028",
"bankAccount": {
"id": "ba_8Hn2PqR5sT7vW9xY3zA4bC6d",
"bank": {
"bin": "970422",
"name": "MB Bank"
},
"accountNumber": "0123456789",
"label": "MB Bank •••• 6789"
},
"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",
"paymentLinkId": null,
"expiresAt": "2026-11-06T03:20:11.000Z",
"paidAt": null,
"canceledAt": null,
"createdAt": "2026-11-05T03:20:11.000Z"
}Lưu id vào đơn hàng. Trường transfer là mọi thứ người trả cần: chuyển transfer.amount tới tài khoản transfer.accountNumber (ngân hàng có mã BIN transfer.bankBin), ghi transfer.content trong nội dung. Dựng mã VietQR từ các trường này để app ngân hàng của người trả điền sẵn tất cả: xem VietQR và nội dung chuyển khoản.
Chưa muốn tự dựng trang thanh toán? Gửi hostedUrl cho người trả: trang do BankOpenAPI dựng sẵn, có mã VietQR, thông tin chuyển khoản và tự báo khi nhận tiền.
4. Nhận webhook
Vào trang Webhook, bấm "Thêm endpoint", nhập URL https:// của hệ thống và chọn sự kiện payment_request.paid. Sao chép signing secret (dạng whsec_...) vào biến môi trường BANKOPENAPI_WEBHOOK_SECRET.
Endpoint của bạn verify chữ ký bằng thư viện standardwebhooks (có cho Node.js, PHP, Python và nhiều ngôn ngữ khác), chống xử lý trùng theo event.id, rồi trả 200:
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 "", 200Khi đơn nhận đủ tiền, endpoint nhận body như sau:
{
"id": "evt_2Gc7Vn4Kp9Wq3Xr8Ty5Bm1Hd",
"type": "payment_request.paid",
"livemode": true,
"timestamp": "2026-11-05T03:24:52.000Z",
"data": {
"id": "pr_3fK9mQ2xLwA7nB5vR8tY1cZd",
"object": "payment_request",
"livemode": true,
"amount": 150000,
"amountReceived": 150000,
"currency": "VND",
"status": "paid",
"code": "TT7K3M9Q",
"orderId": "DH1028",
"description": "Đơn hàng DH1028",
"bankAccount": {
"id": "ba_8Hn2PqR5sT7vW9xY3zA4bC6d",
"bank": {
"bin": "970422",
"name": "MB Bank"
},
"accountNumber": "0123456789",
"label": "MB Bank •••• 6789"
},
"transfer": {
"bankBin": "970422",
"accountNumber": "0123456789",
"accountName": "NGUYEN VAN AN",
"amount": 0,
"content": "TT7K3M9Q"
},
"qr": null,
"hostedUrl": "https://app.bankopenapi.com/thanh-toan/pr_3fK9mQ2xLwA7nB5vR8tY1cZd",
"paymentLinkId": null,
"expiresAt": "2026-11-06T03:20:11.000Z",
"paidAt": "2026-11-05T03:24:52.000Z",
"canceledAt": null,
"createdAt": "2026-11-05T03:20:11.000Z"
}
}Bấm "Gửi thử" trên trang Webhook để nhận sự kiện webhook.test. Đang code trên máy, dùng một tunnel để có URL https công khai, ví dụ cloudflared tunnel --url http://localhost:3000.
Thử thật từ đầu đến cuối: tạo yêu cầu 1.000 VND, quét mã VietQR và tự chuyển khoản. Vài giây sau endpoint nhận transaction.received và payment_request.paid.
Bước tiếp theo
- Webhook: lịch retry, thứ tự sự kiện, cách chống xử lý trùng bằng database.
- API yêu cầu thanh toán: các trạng thái, chuyển thiếu, chuyển thừa, hủy.
- Lỗi và mã lỗi: request nào nên retry, request nào không.
- Dùng với AI agent: cài skill để Claude Code, Cursor viết phần tích hợp này cho bạn.