# How the payer pays

Once you have [created a payment](https://hatang.dev/docs/guides/payments/create-payment.md), 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.

<figure className="qr-demo">
  <img src="/img/thanh-toan/vietqr-hatang.svg" alt="A sample VietQR with the hatang.dev mark" width="240" />
  <figcaption>Sample VietQR: 910452658 · 150,000 ₫ · HTBF7RDGVQ</figcaption>
</figure>

- `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

<figure className="checkout-single">
  <img src="/img/thanh-toan/checkout.png" alt="The Ha Tang checkout page" width="420" />
</figure>

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](https://hatang.dev/docs/guides/payments/sdk-javascript.md#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:

| Endpoint | Returns |
|---|---|
| `GET /pay/{id}` | What the checkout page shows, and only what the payer needs |
| `GET /pay/{id}/qr.svg` | The VietQR image (`?logo=0` for a plain one) |
| `GET /pay/{id}/events` | Server-sent events, one each time the status changes |

`GET /pay/{id}` returns:

```json
{
  "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:

```js
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.

| Limit | Value |
|---|---|
| Requests to `/pay/*` per client address | 600 per minute (`429 rate_limit_exceeded` beyond, with `Retry-After`) |
| Payments module switched off | `403 product_disabled` |

> **Danger: 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

| Situation | Result |
|---|---|
| Wrong amount | The payment becomes `partially_paid` or `overpaid`. A second transfer with the same code adds up |
| Forgot the code | The transfer arrives as `transaction.received` with `payment_id: null`. Check it, then [mark the payment paid](https://hatang.dev/docs/guides/payments/create-payment.md) |
| Paid after `expires_at` | The money is recorded and the payment becomes `paid` |
| Paid twice | The payment becomes `overpaid`; refund the difference from your bank |
| Paid into the wrong account | Ha Tang only sees linked accounts; the payment stays unpaid |

## Next steps

- [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md) on your server.
- [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md): statuses and edge cases.
- [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md): what the checkout page shows about the invoice.

Source: https://hatang.dev/docs/guides/payments/buyer-flow
