Cách người mua thanh toán
Sau khi bạn tạo payment, người mua có thể quét VietQR, dùng trang checkout có sẵn, hoặc chuyển khoản thủ công. Trang này mô tả từng cách, các endpoint công khai để tự xây trang thanh toán, và điều gì xảy ra khi người mua chuyển sai.
Quét VietQR
Mọi app ngân hàng tại Việt Nam đều quét được (NAPAS 247: tức thì, 24/7). QR điền sẵn ngân hàng, số tài khoản, số tiền và mã thanh toán; người mua chỉ cần xác nhận.
qr.image_url: ảnh SVG, đặt thẳng vào thẻ<img>. Ảnh có logo hatang.dev ở giữa và được vẽ ở mức sửa lỗi H, nên logo không ảnh hưởng việc quét. Thêm?logo=0để lấy mã trơn, ví dụ khi in kèm thương hiệu của bạn.qr.payload: chuỗi EMVCo. Render bằng thư viện QR của bạn để tùy biến giao diện.
Dùng trang checkout có sẵn

Chuyển người mua tới checkout_url. Trang hiển thị QR, thông tin ngân hàng kèm nút sao chép, và đồng hồ đếm ngược tới expires_at. Trang cập nhật trực tiếp khi tiền về (kèm số hóa đơn khi đã có), rồi chuyển người mua về return_url. Không cần đăng nhập: payment id là thông tin duy nhất. Ở development, trang có thêm nút giả lập chuyển khoản.
Người mua quay lại return_url chỉ có nghĩa là họ đã rời trang checkout. Hãy xác nhận thanh toán ở server trước khi giao hàng (xem Xác nhận trước khi giao hàng).
Chuyển khoản thủ công
Hiển thị bank.name, bank.account_number, bank.account_name, số tiền và code, rồi nhắc người mua ghi mã vào nội dung chuyển khoản. Cách này dùng được từ mọi ngân hàng, kể cả internet banking trên máy tính. Hãy hiển thị mã thật nổi bật: thiếu mã, giao dịch không khớp được với payment.
Tự xây trang thanh toán
Các endpoint này không cần API key, nên gọi được từ trình duyệt:
| Endpoint | Trả về |
|---|---|
GET /pay/{id} | Những gì trang checkout hiển thị, và chỉ những gì người mua cần |
GET /pay/{id}/qr.svg | Ảnh VietQR (?logo=0 để lấy mã trơn) |
GET /pay/{id}/events | Server-sent events, một sự kiện mỗi khi trạng thái đổi |
GET /pay/{id} trả về:
{
"object": "checkout",
"id": "pay_01a0b904297873e8b3524a26fdca0ab5",
"environment": "production",
"status": "pending",
"amount": 150000,
"paid_amount": 0,
"currency": "VND",
"code": "HTBF7RDGVQ",
"description": "Đơn hàng #1001",
"merchant": { "name": "Cửa hàng mẫu" },
"bank": { "bin": "970422", "name": "MB Bank", "account_number": "910452658", "account_name": "NGUYEN VAN A", "simulated": false },
"qr": {
"payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA530370454061500005802VN62140810HTBF7RDGVQ63044DEC",
"image_url": "https://api.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5/qr.svg"
},
"return_url": "https://shop.example.vn/orders/1001",
"expires_at": "2026-09-19T10:34:12.856Z",
"paid_at": null,
"invoice": null
}
bank.simulated là true với tài khoản development tự sinh. invoice, khi đơn có yêu cầu hóa đơn, chỉ gồm requested, status, number, series, lookup_code và pdf_url.
Lắng nghe thay đổi trạng thái:
const es = new EventSource(`https://api.hatang.dev/pay/${paymentId}/events`);
es.addEventListener("status", (e) => {
const { status, paid_amount } = JSON.parse(e.data);
if (status === "paid" || status === "overpaid") showThankYou(); // chỉ để hiển thị
if (status === "partially_paid") showRemaining(paid_amount);
});
Stream gửi trạng thái hiện tại trước, sau đó một sự kiện status cho mỗi lần thay đổi, và một dòng keep-alive mỗi 15 giây. Stream đóng sau 15 phút; mở lại nếu người mua vẫn ở trên trang. Nếu stream bị từ chối (mỗi payment và mỗi server có giới hạn số kết nối trực tiếp), hãy chuyển sang gọi GET /pay/{id} vài giây một lần.
| Giới hạn | Giá trị |
|---|---|
Request tới /pay/* mỗi địa chỉ client | 600 mỗi phút (vượt quá nhận 429 rate_limit_exceeded, kèm Retry-After) |
| Module Thanh toán bị tắt | 403 product_disabled |
Ai cũng có thể mở một trang ghi "đã thanh toán". Hãy xác nhận ở server bằng IPN, webhook hoặc GET /payments/{id}.
Khi người mua chuyển sai
| Tình huống | Kết quả |
|---|---|
| Sai số tiền | Payment thành partially_paid hoặc overpaid. Giao dịch tiếp theo cùng mã sẽ cộng dồn |
| Quên ghi mã | Giao dịch đến dưới dạng transaction.received với payment_id: null. Kiểm tra rồi đánh dấu payment đã thanh toán |
Trả sau expires_at | Tiền vẫn được ghi nhận và payment chuyển sang paid |
| Trả hai lần | Payment thành overpaid; hoàn phần chênh lệch từ ngân hàng của bạn |
| Chuyển nhầm tài khoản | Hạ Tầng chỉ thấy tài khoản đã liên kết; payment vẫn chưa thanh toán |
Bước tiếp theo
- Nhận kết quả trên server.
- Tạo thanh toán: trạng thái và tình huống đặc biệt.
- Hóa đơn điện tử: trang checkout hiển thị gì về hóa đơn.