Skip to main content

How the payer pays

Once you have created a payment, the payer can scan its VietQR, use the hosted checkout page, or transfer by hand. This page shows each option, the public endpoints for building your own payment page, and what happens when the payer makes a mistake.

Scan the VietQR​

Any banking app in Vietnam scans it (NAPAS 247: instant, 24/7). The QR fills in your bank, the account number, the amount and the payment code; the payer only confirms.

A sample VietQR with the hatang.dev mark
Sample VietQR: 910452658 · 150,000 ₫ · HTBF7RDGVQ
  • qr.image_url: an SVG image you can put straight in an <img> tag. It has the Ha Tang mark in the middle and is drawn at error correction level H, so the mark does not hurt scanning. Add ?logo=0 for a plain code, for example to print it under your own branding.
  • qr.payload: the EMVCo string. Render it with your own QR library for a custom design.

Use the hosted checkout page​

The Ha Tang checkout page

Redirect the payer to checkout_url. The page shows the QR, the bank details with copy buttons, and a countdown to expires_at. It updates live when the money arrives (and shows the invoice number once there is one), then sends the payer to your return_url. It needs no login: the payment id is the only credential. In development it also has a button that simulates the transfer.

note

Coming back to return_url only means the payer left the checkout page. Confirm the payment on your server before you fulfil the order (see Confirm before you fulfil).

Transfer by hand​

Show bank.name, bank.account_number, bank.account_name, the amount and the code, and ask the payer to put the code in the transfer description. This works from any bank, including internet banking on a computer. Show the code prominently: without it, the transfer cannot be matched to the payment.

Build your own payment page​

These endpoints need no API key, so you can call them from the browser:

EndpointReturns
GET /pay/{id}What the checkout page shows, and only what the payer needs
GET /pay/{id}/qr.svgThe VietQR image (?logo=0 for a plain one)
GET /pay/{id}/eventsServer-sent events, one each time the status changes

GET /pay/{id} returns:

{
"object": "checkout",
"id": "pay_01a0b904297873e8b3524a26fdca0ab5",
"environment": "production",
"status": "pending",
"amount": 150000,
"paid_amount": 0,
"currency": "VND",
"code": "HTBF7RDGVQ",
"description": "Order #1001",
"merchant": { "name": "Example Shop" },
"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.com/orders/1001",
"expires_at": "2026-09-19T10:34:12.856Z",
"paid_at": null,
"invoice": null
}

bank.simulated is true for a made-up development account. invoice, when the order asked for one, holds only requested, status, number, series, lookup_code and pdf_url.

Listen for status changes:

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(); // display only
if (status === "partially_paid") showRemaining(paid_amount);
});

The stream sends the current status first, then one status event per change, and a keep-alive comment every 15 seconds. It closes after 15 minutes; open it again if the payer is still on the page. If the stream is refused (each payment and each server cap live connections), fall back to polling GET /pay/{id} every few seconds.

LimitValue
Requests to /pay/* per client address600 per minute (429 rate_limit_exceeded beyond, with Retry-After)
Payments module switched off403 product_disabled
Never fulfil from the browser

Anyone can open a page that says "paid". Confirm on your server with the IPN, a webhook or GET /payments/{id}.

When the payer makes a mistake​

SituationResult
Wrong amountThe payment becomes partially_paid or overpaid. A second transfer with the same code adds up
Forgot the codeThe transfer arrives as transaction.received with payment_id: null. Check it, then mark the payment paid
Paid after expires_atThe money is recorded and the payment becomes paid
Paid twiceThe payment becomes overpaid; refund the difference from your bank
Paid into the wrong accountHa Tang only sees linked accounts; the payment stays unpaid

Next steps​

View this page as Markdown