Chuyển tới nội dung chính

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.

Mã VietQR mẫu có logo hatang.dev
VietQR mẫu: 910452658 · 150.000 ₫ · HTBF7RDGVQ
  • 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​

Trang checkout Hạ Tầng

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.

ghi chú

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:

EndpointTrả 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}/eventsServer-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ạnGiá trị
Request tới /pay/* mỗi địa chỉ client600 mỗi phút (vượt quá nhận 429 rate_limit_exceeded, kèm Retry-After)
Module Thanh toán bị tắt403 product_disabled
Không giao hàng theo trình duyệt

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ốngKết quả
Sai số tiềnPayment 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_atTiền vẫn được ghi nhận và payment chuyển sang paid
Trả hai lầnPayment 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ảnHạ 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​

Xem trang này dạng Markdown