Tài liệu tích hợp qhPay
qhPay giúp dịch vụ của bạn nhận tiền chuyển khoản qua VietQR và tự động biết khi nào khách đã trả. Tiền đi thẳng vào tài khoản ngân hàng của bạn, qhPay chỉ xác nhận giao dịch.
Bắt đầu nhanh trong 5 bước
- Đăng ký tài khoản merchant.
- Vào Tài khoản nhận tiền, khai báo ngân hàng và số tài khoản của bạn.
- Vào API key, tạo một key Test.
- Vào Callback (IPN), nhập URL máy chủ của bạn sẽ nhận thông báo, rồi lấy khoá ký callback.
- Gọi
POST /v1/payments, chuyển khách tớicheckoutUrl, xử lý đơn khi nhận callbackpayment.paid. Dùng nút “Giả lập chuyển khoản” để thử.
Khi chạy thật: kết nối một nguồn biến động số dư, tạo key Live và thay vào máy chủ.
Cách qhPay hoạt động
QH7KX2M9PA) và mã VietQR chứa sẵn số tiền. Quản trị viên có thể đổi tiền tố mã và mẫu nội dung (vd. {SHOP} {CODE}) trong trang quản trị; luôn dùng trường transferContent mà API trả về thay vì tự ghép.Cài đặt máy chủ qhPay
qhPay gồm một ứng dụng Node.js (20 trở lên) và cơ sở dữ liệu PostgreSQL (14 trở lên). Chỉ dùng hai thư viện: pg và nodemailer.
Cách 1: Docker Compose (khuyến nghị)
cd qhpay
cp .env.example .env
# Điền vào .env: POSTGRES_PASSWORD, SESSION_SECRET, ENCRYPTION_KEY, PUBLIC_URL, SMTP_URL, ADMIN_EMAIL
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # dùng để tạo khoá ngẫu nhiên
docker compose up -d --build
docker compose logs -f app # xem log; migration chạy tự động khi khởi động
PostgreSQL chạy trong mạng nội bộ của Docker, không mở cổng ra ngoài. qhPay chỉ nghe trên 127.0.0.1:3000, hãy đặt Nginx có HTTPS phía trước (xem mục dưới).
Cách 2: Cài trực tiếp
# 1. Tạo database và user riêng cho qhPay
sudo -u postgres psql -c "CREATE USER qhpay WITH PASSWORD 'mat-khau-manh'"
sudo -u postgres psql -c "CREATE DATABASE qhpay OWNER qhpay"
# 2. Cài và cấu hình
npm ci --omit=dev
cp .env.example .env # đặt DATABASE_URL=postgres://qhpay:[email protected]:5432/qhpay
# 3. Chạy
npm run migrate # tạo bảng (cũng tự chạy khi khởi động)
npm start # http://localhost:3000
Các biến cấu hình
| Biến | Ý nghĩa |
|---|---|
NODE_ENV | production bật mọi kiểm tra bắt buộc: khoá bí mật đủ dài, PUBLIC_URL là https, có DATABASE_URL và SMTP_URL, chặn callback nội bộ. Thiếu là máy chủ từ chối khởi động. |
PUBLIC_URL | Địa chỉ công khai, dùng cho link thanh toán, link trong email và URL webhook. |
DATABASE_URL | Chuỗi kết nối PostgreSQL. DATABASE_SSL=true khi database ở máy khác (bắt buộc kiểm tra chứng chỉ; DATABASE_SSL_CA là file CA riêng nếu có). |
SESSION_SECRET | Khoá ký phiên đăng nhập, ≥ 32 ký tự. |
ENCRYPTION_KEY | Khoá mã hoá khoá bí mật và khoá 2FA của merchant. Sao lưu cẩn thận, mất là không giải mã được. |
SMTP_URL, MAIL_FROM | Máy chủ gửi email (xác thực tài khoản, đặt lại mật khẩu, cảnh báo bảo mật). Ví dụ smtps://user:[email protected]:465. Bỏ trống khi phát triển thì email in ra log. |
ADMIN_EMAIL | Tài khoản đăng ký bằng email này có quyền quản trị. |
PAYMENT_TTL_MINUTES | Thời gian sống của đơn (mặc định 15 phút). |
TRUST_PROXY | true khi chạy sau Nginx/Cloudflare để ghi đúng IP. |
ACB_SANDBOX_URL, ACB_PRODUCTION_URL | Địa chỉ API của ACB cho QR động. Sandbox mặc định https://sandbox.acb.com.vn/acb/open. Production do ACB cấp khi duyệt kết nối; để trống thì merchant chỉ bật được sandbox. Merchant không tự nhập địa chỉ được, tránh bị lợi dụng gọi vào mạng nội bộ. |
Chạy production
Chạy nền bằng systemd (khi không dùng Docker)
# /etc/systemd/system/qhpay.service
[Unit]
Description=qhPay
After=network.target postgresql.service
[Service]
User=qhpay
WorkingDirectory=/opt/qhpay
EnvironmentFile=/opt/qhpay/.env
ExecStart=/usr/bin/node src/server.js
Restart=always
NoNewPrivileges=true
ProtectSystem=strict
PrivateTmp=true
[Install]
WantedBy=multi-user.target
HTTPS với Nginx
server {
listen 443 ssl http2;
server_name pay.example.vn;
ssl_certificate /etc/letsencrypt/live/pay.example.vn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pay.example.vn/privkey.pem;
client_max_body_size 256k;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
}
}
server { listen 80; server_name pay.example.vn; return 301 https://$host$request_uri; }
Sao lưu PostgreSQL
# Sao lưu hằng ngày (cron), giữ 30 bản
docker compose exec -T db pg_dump -U qhpay -Fc qhpay > /backup/qhpay-$(date +%F).dump
find /backup -name 'qhpay-*.dump' -mtime +30 -delete
# Khôi phục
docker compose exec -T db pg_restore -U qhpay -d qhpay --clean < /backup/qhpay-2026-10-20.dump
Bản sao lưu chứa khoá đã mã hoá; cần ENCRYPTION_KEY để dùng lại. Lưu ENCRYPTION_KEY ở nơi khác với bản sao lưu.
Chạy nhiều máy chủ
Mọi trạng thái nằm trong PostgreSQL: hàng đợi callback dùng FOR UPDATE SKIP LOCKED, giới hạn tần suất lưu trong database, migration có khoá. Có thể chạy nhiều bản qhPay sau một load balancer mà không gửi trùng callback hay cộng trùng tiền.
Danh sách kiểm tra trước khi chạy thật
NODE_ENV=production,PUBLIC_URL=https://…,TRUST_PROXY=true, đã cấu hìnhSMTP_URL.SESSION_SECRET,ENCRYPTION_KEY, mật khẩu PostgreSQL ngẫu nhiên, không commit vào git; file.envquyền 600.- Chỉ mở cổng 443 ra Internet; PostgreSQL và cổng 3000 chỉ nghe nội bộ.
- Sao lưu database hằng ngày và thử khôi phục định kỳ.
- Tài khoản admin bật xác thực 2 lớp.
- Thuê đơn vị kiểm thử xâm nhập độc lập trước khi nhận tiền thật.
Nguồn biến động số dư
qhPay cần được báo khi có tiền vào tài khoản. Hiện có bốn cách, chọn cách phù hợp:
| Nguồn | Phù hợp khi | Xác thực |
|---|---|---|
| ACB QR động | Có tài khoản doanh nghiệp ACB, muốn mỗi đơn một số tài khoản và QR riêng, ACB chặn chuyển sai tiền | Basic Auth, rồi qhPay hỏi lại ACB |
| SePay | Muốn bắt đầu nhanh, hơn 30 ngân hàng, tài khoản cá nhân hoặc doanh nghiệp | API Key trong header |
| Casso | Đang dùng Casso cho kế toán | secure-token |
| Open API ngân hàng | Đã có hợp đồng với BIDV (openapi.bidv.com.vn), VietinBank (openapi.vietinbank.vn), MB… | HMAC-SHA256 + timestamp |
URL webhook của từng nguồn nằm trong Bảng điều khiển › Nguồn biến động. URL chứa một mã bí mật riêng, hãy giữ kín và đổi ngay nếu nghi lộ.
ACB QR động (kết nối trực tiếp)
Dùng sản phẩm “Giải pháp QR Động” của ACB. Khi tạo đơn, qhPay gọi ACB để lấy một tài khoản định danh và mã QR chỉ dùng cho đơn đó. ACB chặn chuyển sai số tiền hoặc chuyển nhiều lần, nên không có chuyển thiếu, chuyển thừa hay ghi sai nội dung.
- Đăng ký với ACB dịch vụ Tài khoản định danh cho tài khoản doanh nghiệp, và tạo ứng dụng trên ACB Developer Portal để có Client ID, Client Secret.
- ACB cấp Merchant ID, Terminal ID, tiền tố tài khoản định danh (ví dụ
HAB) và mã khách hàng (Owner Number). - Bảng điều khiển › Nguồn biến động › ACB QR động › Cấu hình, nhập các thông tin trên. Cần nhập lại mật khẩu (và mã 2FA) vì cấu hình này quyết định tiền về đâu.
- Gửi ACB đường dẫn nhận thông báo
https://tên-miền/webhooks/acb/{mã-bí-mật}cùng tên đăng nhập, mật khẩu Basic Auth qhPay tạo. qhPay nhận cả thông báo trạng thái lẫn danh sách giao dịch cuối ngày ở đường dẫn này (kể cả khi ACB nối thêm/v1/statushay/v1/qr-transactionphía sau). - Bấm Kiểm tra kết nối, rồi tạo đơn Test để thử với sandbox. Sandbox chỉ áp dụng cho đơn Test, production chỉ áp dụng cho đơn Live.
An toàn: tài liệu ACB không công bố cách tính checksum của thông báo, nên qhPay không tin nội dung thông báo. Mỗi thông báo chỉ khiến qhPay gọi API truy vấn của ACB bằng token của chính bạn để lấy trạng thái thật; thông báo giả không thể làm đơn thành paid. qhPay cũng tự đối soát định kỳ các đơn đang chờ, phòng khi thông báo bị mất. Khi huỷ đơn hoặc đơn hết hạn, qhPay huỷ luôn mã QR bên ACB.
Với đơn ACB, phản hồi API có "provider": "acb", qrString rỗng và qrImageUrl là ảnh PNG dạng data: do ACB cấp. Nếu tự hiển thị QR, hãy dùng ảnh này, kèm dòng “Quý khách vui lòng kiểm tra thông tin thanh toán trước khi thực hiện giao dịch” và số tiền in đậm, cỡ lớn theo yêu cầu của ACB. Trang thanh toán của qhPay đã làm sẵn.
SePay
- Đăng ký SePay, liên kết tài khoản ngân hàng nhận tiền.
- SePay › Tích hợp WebHooks › Thêm mới. URL: dán “URL webhook cho SePay”. Sự kiện: Có tiền vào.
- Kiểu chứng thực: API Key. Đặt một chuỗi ngẫu nhiên ≥ 32 ký tự, dán cùng chuỗi vào qhPay.
- Bấm “Gửi thử” trong SePay; giao dịch xuất hiện ở mục Biến động số dư của qhPay.
Casso
- Casso › Thiết lập › Webhook, dán “URL webhook cho Casso”.
- Đặt Key bảo mật là chuỗi ngẫu nhiên ≥ 32 ký tự, dán cùng chuỗi vào qhPay.
Open API ngân hàng / định dạng chung
Khi tích hợp trực tiếp với ngân hàng (ví dụ API “Thu hộ qua tài khoản định danh” của BIDV hoặc API thông báo biến động số dư của VietinBank), viết một bộ chuyển đổi nhỏ nhận thông báo từ ngân hàng rồi gửi về qhPay theo định dạng sau:
{
"transactions": [
{
"id": "FT26293XXXX", // mã giao dịch duy nhất phía ngân hàng (chống trùng)
"amount": 50000, // số tiền, VND
"type": "in", // in | out
"content": "QH7KX2M9PA chuyen tien",
"accountNumber": "0123456789",
"time": "2026-10-20T10:00:09+07:00",
"reference": "FT26293XXXX"
}
]
}
Header bắt buộc: X-QhPay-Timestamp (giây Unix) và X-QhPay-Signature = hex(HMAC-SHA256(khoá_ký, timestamp + "." + rawBody)). Lệch giờ quá 5 phút bị từ chối để chống phát lại.
const ts = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ transactions });
const sig = crypto.createHmac('sha256', process.env.QHPAY_SOURCE_SECRET).update(`${ts}.${body}`).digest('hex');
await fetch(QHPAY_GENERIC_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-QhPay-Timestamp': ts, 'X-QhPay-Signature': sig },
body,
});
Xác thực API
Mọi request tới /v1/* gửi API key trong header. Key qh_test_… chỉ thấy đơn Test, key qh_live_… chỉ thấy đơn Live.
Authorization: Bearer qh_live_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
Tạo đơn thanh toán
| Tham số | Kiểu | Mô tả |
|---|---|---|
orderId * | string | Mã đơn bên bạn, tối đa 64 ký tự (chữ, số, . _ : -). Gọi lại cùng orderId sẽ trả về đơn cũ nếu còn chờ, nên an toàn khi thử lại. |
amount * | integer | Số tiền VND, từ 1.000 đến 500.000.000. |
description | string | Mô tả hiển thị cho khách, tối đa 100 ký tự. |
returnUrl | string | Trang khách quay về sau khi thanh toán xong. |
metadata | object | Dữ liệu tuỳ ý (tối đa 2KB), được gửi kèm trong callback. |
bankAccountId | string | Tài khoản nhận cụ thể; bỏ trống để dùng tài khoản mặc định. |
curl -X POST https://pay.example.vn/v1/payments \
-H "Authorization: Bearer $QHPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"orderId":"GAME-10293","amount":50000,"description":"Nap 550 kim cuong","returnUrl":"https://game.example.vn/topup","metadata":{"player":"hiepsi01"}}'
const res = await fetch('https://pay.example.vn/v1/payments', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.QHPAY_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId: 'GAME-10293', amount: 50000, description: 'Nap 550 kim cuong', metadata: { player: 'hiepsi01' } }),
});
const payment = await res.json();
if (!res.ok) throw new Error(payment.error.message);
// Chuyển người chơi tới payment.checkoutUrl, hoặc tự hiển thị payment.qrString
<?php
$ch = curl_init('https://pay.example.vn/v1/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('QHPAY_API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['orderId' => 'GAME-10293', 'amount' => 50000, 'description' => 'Nap 550 kim cuong']),
]);
$payment = json_decode(curl_exec($ch), true);
header('Location: ' . $payment['checkoutUrl']);
import os, requests
r = requests.post('https://pay.example.vn/v1/payments',
headers={'Authorization': f"Bearer {os.environ['QHPAY_API_KEY']}"},
json={'orderId': 'GAME-10293', 'amount': 50000, 'description': 'Nap 550 kim cuong'}, timeout=10)
r.raise_for_status()
payment = r.json()
Phản hồi 201 Created
{
"id": "pay_Xk29aQ7mLp0e",
"mode": "live",
"orderId": "GAME-10293",
"amount": 50000,
"paidAmount": 0,
"currency": "VND",
"code": "QH7KX2M9PA",
"status": "pending",
"flags": [],
"bankAccount": { "bin": "970422", "bankCode": "MB", "bankName": "MB Bank", "accountNumber": "0123456789", "accountName": "CONG TY RONG LUA" },
"transferContent": "QH7KX2M9PA",
"qrString": "00020101021238570010A00000072701270006970422011001234567890208QRIBFTTA5303704540550000...",
"qrImageUrl": "https://img.vietqr.io/image/970422-0123456789-compact2.png?amount=50000&addInfo=QH7KX2M9PA",
"checkoutUrl": "https://pay.example.vn/pay/pay_Xk29aQ7mLp0e",
"returnUrl": null,
"metadata": { "player": "hiepsi01" },
"expiresAt": "2026-10-20T03:15:01.000Z",
"paidAt": null,
"createdAt": "2026-10-20T03:00:01.000Z"
}
Bạn có thể dùng trang checkoutUrl có sẵn, hoặc tự vẽ QR từ qrString (chuẩn EMVCo/NAPAS) trong giao diện của mình.
Tra cứu, huỷ đơn
Callback là kênh chính. Chỉ dùng tra cứu để đối soát định kỳ hoặc khi nghi ngờ mất callback, không gọi liên tục.
Callback & kiểm tra chữ ký
Khi đơn đổi trạng thái, qhPay gửi POST tới Callback URL của bạn:
POST /qhpay/callback
Content-Type: application/json
X-QhPay-Event: payment.paid
X-QhPay-Timestamp: 1792465209
X-QhPay-Signature: 5d0c7c1e... (hex HMAC-SHA256)
{ "id": "cb_...", "event": "payment.paid", "createdAt": "...", "data": { ...đơn thanh toán đầy đủ... } }
Sự kiện: payment.paid, payment.expired, payment.cancelled, và payment.reversed (chỉ với ACB QR động: ngân hàng huỷ giao dịch đã báo thành công, đơn có thêm cờ reversed).
Bắt buộc kiểm tra chữ ký
chữ_ký = hex(HMAC-SHA256(khoá_ký_callback, timestamp + "." + rawBody)). Dùng raw body nguyên bản (chưa parse JSON), so sánh bằng hàm an toàn thời gian, và từ chối timestamp lệch quá 5 phút.
const crypto = require('crypto');
function verifyQhPay(req, rawBody) {
const ts = req.headers['x-qhpay-timestamp'];
const sig = req.headers['x-qhpay-signature'] || '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto.createHmac('sha256', process.env.QHPAY_CALLBACK_SECRET).update(`${ts}.${rawBody}`).digest('hex');
return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
// Express: app.post('/qhpay/callback', express.raw({ type: 'application/json' }), handler)
async function handler(req, res) {
const raw = req.body.toString('utf8');
if (!verifyQhPay(req, raw)) return res.status(401).end();
const { event, data } = JSON.parse(raw);
if (event === 'payment.paid') {
// Idempotent: chỉ giao hàng nếu đơn chưa được xử lý
await db.orders.markPaidOnce(data.orderId, data.amount);
}
res.json({ ok: true });
}
<?php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_QHPAY_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_QHPAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('QHPAY_CALLBACK_SECRET'));
if (!$ts || abs(time() - (int)$ts) > 300 || !hash_equals($expected, $sig)) {
http_response_code(401); exit;
}
$payload = json_decode($raw, true);
if ($payload['event'] === 'payment.paid') {
// UPDATE orders SET status='paid' WHERE order_id=? AND status='pending'
// Chỉ cộng kim cương khi câu lệnh trên thực sự cập nhật 1 dòng
}
echo json_encode(['ok' => true]);
import hmac, hashlib, time, os, json
from flask import request, abort
@app.post('/qhpay/callback')
def qhpay_callback():
raw = request.get_data()
ts = request.headers.get('X-QhPay-Timestamp', '')
sig = request.headers.get('X-QhPay-Signature', '')
expected = hmac.new(os.environ['QHPAY_CALLBACK_SECRET'].encode(), f'{ts}.'.encode() + raw, hashlib.sha256).hexdigest()
if not ts or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(expected, sig):
abort(401)
payload = json.loads(raw)
if payload['event'] == 'payment.paid':
mark_paid_once(payload['data']['orderId'])
return {'ok': True}
Gửi lại và xử lý trùng
- Phản hồi HTTP 2xx trong 10 giây để xác nhận. Nếu không, qhPay gửi lại sau 10 giây, 30 giây, 2 phút, 10 phút, 30 phút và 1 giờ (tổng 7 lần).
- Callback có thể đến nhiều lần: xử lý idempotent (chỉ giao hàng khi đơn đang ở trạng thái chờ).
- Luôn đối chiếu
data.amountvàdata.orderIdvới đơn trong hệ thống của bạn. - Callback URL phải là địa chỉ công khai; production bắt buộc
https://, địa chỉ mạng nội bộ bị chặn.
Trạng thái & cờ
| Trạng thái | Ý nghĩa |
|---|---|
| pending | Chờ khách chuyển khoản. |
| paid | Đã nhận đủ tiền. Giao hàng khi nhận callback này. |
| expired | Quá thời gian chờ mà chưa đủ tiền. |
| cancelled | Bạn đã huỷ đơn qua API hoặc bảng điều khiển. |
Cờ (flags) | Ý nghĩa và cách xử lý |
|---|---|
underpaid | Khách chuyển thiếu, đơn vẫn chờ. Khách chuyển bù cùng nội dung sẽ được cộng dồn. |
overpaid | Khách chuyển thừa (paidAmount > amount). Cân nhắc hoàn phần thừa. |
late | Tiền về sau khi đơn hết hạn/huỷ. Đơn vẫn chuyển sang paid vì tiền đã vào tài khoản; bạn quyết định giao hàng hay hoàn tiền. |
extra_transfer | Khách chuyển thêm vào đơn đã thanh toán (thường là bấm chuyển hai lần). Cần hoàn tiền. |
reversed | ACB huỷ giao dịch (điều chỉnh lỗi) sau khi đã báo thành công. Tiền không còn ở tài khoản của bạn: thu hồi hàng đã giao nếu có thể. |
Ví dụ: nạp tiền vào game
Thư mục demo-game/ là một game mẫu tích hợp đầy đủ: chọn gói kim cương, thanh toán qua qhPay, nhận callback có chữ ký và cộng kim cương đúng một lần.
# Cửa sổ 1 (cần PostgreSQL đang chạy, xem mục Cài đặt)
npm start
# Đăng ký merchant trên http://localhost:3000, thêm tài khoản ngân hàng,
# tạo API key Test, đặt Callback URL = http://localhost:4000/qhpay/callback, xem khoá ký callback.
# Cửa sổ 2
QHPAY_API_KEY=qh_test_... QHPAY_CALLBACK_SECRET=whsec_... npm run demo
# Mở http://localhost:4000, nạp thử, rồi bấm "Giả lập chuyển khoản" trong bảng điều khiển qhPay.
Nguyên tắc quan trọng khi nạp game:
- Giá gói và số kim cương lấy ở máy chủ game, không tin giá trị client gửi lên.
- Chỉ cộng kim cương trong xử lý callback đã kiểm chữ ký, không cộng khi người chơi quay về
returnUrl. - Ghi nhận đơn đã cộng để callback lặp lại không cộng hai lần.
Kiểm thử
- Dùng key
qh_test_…: đơn Test không bao giờ khớp với tiền thật. - Giả lập chuyển khoản trong bảng điều khiển hoặc qua
POST /v1/payments/{id}/simulatevới{"amount": 30000}để thử chuyển thiếu. - Thử máy chủ của bạn trả lỗi 500 để xem qhPay gửi lại callback, rồi dùng nút “Gửi lại”.
npm testchạy 14 kịch bản đầu-cuối trên PostgreSQL thật: tạo đơn, webhook SePay/Casso/chung, 11 webhook đồng thời, chống phát lại, CSRF, mã hoá khoá, xác thực email, quên mật khẩu, 2FA. ĐặtTEST_DATABASE_URLtrỏ tới một database riêng có chữ “test” trong tên (dữ liệu của nó bị xoá mỗi lần chạy).
Bảo mật
qhPay bảo vệ gì
- API key chỉ lưu bản băm SHA-256, hiện đúng một lần khi tạo, thu hồi có hiệu lực ngay.
- Khoá bí mật (khoá ký callback, khoá SePay/Casso, khoá ký webhook) được mã hoá AES-256-GCM khi lưu. Xem lại phải nhập mật khẩu.
- Mật khẩu băm bằng scrypt có salt; tối thiểu 10 ký tự, có chữ và số. Quên mật khẩu qua link email dùng một lần, hết hạn sau 30 phút; phản hồi giống nhau dù email có tồn tại hay không.
- Xác thực 2 lớp (TOTP) kèm 10 mã dự phòng dùng một lần; một mã 6 số không dùng lại được; xem khoá bí mật, đổi mật khẩu, tắt 2FA đều phải nhập lại mật khẩu và mã 2FA.
- Xác thực email bắt buộc trước khi tạo API key Live. Email cảnh báo khi đổi/đặt lại mật khẩu, bật/tắt 2FA, tạo key Live.
- Toàn vẹn dữ liệu: ràng buộc UNIQUE trong PostgreSQL bảo đảm mỗi giao dịch ngân hàng chỉ tính một lần; khoá dòng khi khớp đơn nên webhook đồng thời không cộng sai tiền; callback được ghi cùng transaction với trạng thái đơn nên không bao giờ mất.
- Webhook vào bắt buộc có khoá xác thực; định dạng chung ký HMAC kèm timestamp chống phát lại; mỗi giao dịch ngân hàng chỉ ghi nhận một lần.
- Đối soát chặt: phải đúng mã đơn, đúng tài khoản nhận và đủ số tiền. Đơn Test không bao giờ khớp tiền thật, đơn Live không giả lập được.
- Callback ra ký HMAC-SHA256 kèm timestamp; chặn địa chỉ mạng nội bộ (chống SSRF) ngay ở bước phân giải DNS; không theo chuyển hướng.
- Phiên đăng nhập cookie HttpOnly, SameSite, Secure khi https; hết hạn sau 12 giờ; đổi mật khẩu hoặc “đăng xuất mọi nơi” vô hiệu tất cả phiên cũ.
- Trình duyệt: Content-Security-Policy chặn script lạ, chống clickjacking, kiểm tra Origin chống CSRF, escape mọi dữ liệu chống XSS, chống CSV injection khi xuất Excel.
- Giới hạn tần suất cho đăng nhập, đăng ký, API, webhook. Nhật ký bảo mật ghi đăng nhập sai, xem/đổi khoá, webhook giả mạo.
- Chỉ hai thư viện npm (
pg,nodemailer), đã kiểm tranpm auditkhông có lỗ hổng; thư viện vẽ QR được đóng gói sẵn, không tải script từ bên ngoài.
Việc của bạn
- Giữ API key và khoá ký trong biến môi trường hoặc kho bí mật, không commit vào git.
- Luôn kiểm tra chữ ký callback; không tin
returnUrlhay dữ liệu client gửi lên. - Dùng key riêng cho từng máy chủ để thu hồi độc lập; xem “Dùng lần cuối” để phát hiện key bị lạm dụng.
- Bật xác thực 2 lớp và cất mã dự phòng ở nơi an toàn.
- Theo dõi Nhật ký bảo mật và email cảnh báo; nghi ngờ lộ thì đổi mật khẩu, đăng xuất mọi nơi, thu hồi key, đổi khoá ký.
Phát hiện lỗ hổng? Gửi yêu cầu chủ đề Bảo mật tại trang hỗ trợ, đừng công khai trước khi được khắc phục.
Mã lỗi
Lỗi trả về dạng { "error": { "code": "...", "message": "..." } }.
| HTTP | code | Ý nghĩa |
|---|---|---|
| 400 | invalid_amount, invalid_order_id, invalid_return_url, metadata_too_large | Tham số sai. |
| 400 | no_bank_account | Chưa khai báo tài khoản nhận tiền. |
| 400 | live_payment | Không giả lập được đơn Live. |
| 401 | unauthorized | Thiếu hoặc sai API key. |
| 401 | invalid_signature | Webhook sai khoá/chữ ký hoặc quá hạn. |
| 403 | locked | Tài khoản merchant bị khoá. |
| 404 | not_found | Không tìm thấy đơn (hoặc đơn thuộc chế độ khác với key). |
| 409 | order_conflict | orderId đã dùng cho đơn khác số tiền hoặc đã kết thúc. |
| 409 | invalid_state | Không thể huỷ đơn không còn chờ. |
| 409 | order_in_progress | Đơn ACB cùng orderId đang được tạo, gọi lại sau giây lát. |
| 409, 502 | provider_error | ACB không tạo hoặc không huỷ được mã QR. Khi tạo đơn lỗi, đơn không được lưu nên gọi lại cùng orderId được. Khi huỷ bị từ chối, thường do khách vừa trả xong: qhPay đối soát ngay. |
| 429 | rate_limited | Gọi quá nhanh, thử lại sau. |
Xử lý sự cố
Khách đã chuyển nhưng đơn vẫn “Chờ thanh toán”
- Xem Biến động số dư: không có giao dịch nào → nguồn webhook chưa gửi tới (kiểm tra URL, khoá, nhật ký bên SePay/Casso).
- Có giao dịch “Chưa khớp” → khách ghi sai nội dung. Xem ghi chú, đối chiếu thủ công.
- Đơn có cờ “Chuyển thiếu” → khách cần chuyển bù.
Đơn đã “paid” nhưng hệ thống của tôi chưa giao hàng
- Xem Callback (IPN) › Nhật ký: lỗi
EPRIVATEADDRnghĩa là URL trỏ vào mạng nội bộ;ECONNREFUSED/ETIMEDOUTlà máy chủ của bạn không phản hồi; HTTP 401 thường do sai khoá ký. - Sửa xong bấm “Gửi lại”.
Webhook bị từ chối 401
Khoá trong SePay/Casso khác với khoá đã lưu ở qhPay, hoặc (với định dạng chung) đồng hồ máy chủ lệch quá 5 phút. Nhật ký bảo mật sẽ ghi “Webhook sai chữ ký bị chặn”.
Vẫn chưa được? Gửi yêu cầu hỗ trợ kèm mã đơn và thời gian.