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

  1. Đăng ký tài khoản merchant.
  2. 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.
  3. Vào API key, tạo một key Test.
  4. 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.
  5. Gọi POST /v1/payments, chuyển khách tới checkoutUrl, xử lý đơn khi nhận callback payment.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

Bước 1 Máy chủ của bạn gọi API tạo đơn. qhPay sinh mã nội dung riêng (vd. 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.
Bước 2 Khách quét QR bằng app ngân hàng, tiền vào tài khoản của bạn.
Bước 3 Nguồn biến động số dư (SePay, Casso, Open API ngân hàng) báo cho qhPay qua webhook.
Bước 4 qhPay tìm mã trong nội dung, kiểm tra số tiền và tài khoản nhận, đánh dấu đơn đã thanh toán.
Bước 5 qhPay gửi callback có chữ ký tới máy chủ của bạn. Bạn kiểm tra chữ ký rồi giao hàng (cộng kim cương, kích hoạt khoá học…).
Vì qhPay không giữ tiền hộ, mô hình này không phải là dịch vụ trung gian thanh toán. Nếu bạn mở rộng sang thu hộ và chi trả lại cho người khác, hãy tham khảo luật sư về giấy phép của Ngân hàng Nhà nước (Nghị định 52/2024/NĐ-CP).

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_ENVproduction 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_URLChuỗ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_SECRETKhoá ký phiên đăng nhập, ≥ 32 ký tự.
ENCRYPTION_KEYKhoá 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_FROMMá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_EMAILTài khoản đăng ký bằng email này có quyền quản trị.
PAYMENT_TTL_MINUTESThời gian sống của đơn (mặc định 15 phút).
TRUST_PROXYtrue 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ình SMTP_URL.
  • SESSION_SECRET, ENCRYPTION_KEY, mật khẩu PostgreSQL ngẫu nhiên, không commit vào git; file .env quyề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ồnPhù hợp khiXác thực
ACB QR độngCó 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ềnBasic Auth, rồi qhPay hỏi lại ACB
SePayMuốn bắt đầu nhanh, hơn 30 ngân hàng, tài khoản cá nhân hoặc doanh nghiệpAPI Key trong header
CassoĐang dùng Casso cho kế toánsecure-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.

  1. Đă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.
  2. 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).
  3. 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.
  4. 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/status hay /v1/qr-transaction phía sau).
  5. 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

  1. Đăng ký SePay, liên kết tài khoản ngân hàng nhận tiền.
  2. 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.
  3. 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.
  4. 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

  1. Casso › Thiết lập › Webhook, dán “URL webhook cho Casso”.
  2. Đặ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:

POST/webhooks/generic/{mã-bí-mật-của-bạn}
{
  "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
Chỉ gọi API từ máy chủ. Không đặt API key trong app di động, game client hay JavaScript trên trình duyệt. Mỗi key bị giới hạn 120 request/phút.

Tạo đơn thanh toán

POST/v1/payments
Tham sốKiểuMô tả
orderId *stringMã đơ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 *integerSố tiền VND, từ 1.000 đến 500.000.000.
descriptionstringMô tả hiển thị cho khách, tối đa 100 ký tự.
returnUrlstringTrang khách quay về sau khi thanh toán xong.
metadataobjectDữ liệu tuỳ ý (tối đa 2KB), được gửi kèm trong callback.
bankAccountIdstringTà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

GET/v1/payments/{id}
GET/v1/payments?orderId=GAME-10293
POST/v1/payments/{id}/cancel
POST/v1/payments/{id}/simulate Chỉ Test

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.amount và data.orderId vớ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
pendingChờ khách chuyển khoản.
paidĐã nhận đủ tiền. Giao hàng khi nhận callback này.
expiredQuá thời gian chờ mà chưa đủ tiền.
cancelledBạn đã huỷ đơn qua API hoặc bảng điều khiển.
Cờ (flags)Ý nghĩa và cách xử lý
underpaidKhá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.
overpaidKhách chuyển thừa (paidAmount > amount). Cân nhắc hoàn phần thừa.
lateTiề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_transferKhá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.
reversedACB 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}/simulate vớ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 test chạ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. Đặt TEST_DATABASE_URL trỏ 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 tra npm audit khô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 returnUrl hay 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": "..." } }.

HTTPcodeÝ nghĩa
400invalid_amount, invalid_order_id, invalid_return_url, metadata_too_largeTham số sai.
400no_bank_accountChưa khai báo tài khoản nhận tiền.
400live_paymentKhông giả lập được đơn Live.
401unauthorizedThiếu hoặc sai API key.
401invalid_signatureWebhook sai khoá/chữ ký hoặc quá hạn.
403lockedTài khoản merchant bị khoá.
404not_foundKhông tìm thấy đơn (hoặc đơn thuộc chế độ khác với key).
409order_conflictorderId đã dùng cho đơn khác số tiền hoặc đã kết thúc.
409invalid_stateKhông thể huỷ đơn không còn chờ.
409order_in_progressĐơn ACB cùng orderId đang được tạo, gọi lại sau giây lát.
409, 502provider_errorACB 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.
429rate_limitedGọi quá nhanh, thử lại sau.

Xử lý sự cố

Khách đã chuyển nhưng đơn vẫn “Chờ thanh toán”

  1. 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).
  2. Có giao dịch “Chưa khớp” → khách ghi sai nội dung. Xem ghi chú, đối chiếu thủ công.
  3. Đơ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

  1. Xem Callback (IPN) › Nhật ký: lỗi EPRIVATEADDR nghĩa là URL trỏ vào mạng nội bộ; ECONNREFUSED/ETIMEDOUT là máy chủ của bạn không phản hồi; HTTP 401 thường do sai khoá ký.
  2. 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.