Skip to main content

Create a payment

A payment is one order's request for money. It carries a transfer code, a VietQR and a hosted checkout page, and Ha Tang moves it to paid when a transfer with that code arrives. This page covers creating payments, their statuses, the payment code, and the calls to cancel or settle a payment by hand.

Before you start​

  • An active bank connection in the environment you use.
  • An API key with payments:write.
  • An HTTPS endpoint for the IPN, if you want calls for this payment.

1. Create the payment​

Create it when the customer checks out. Send an Idempotency-Key (your order id works well) so that a retried request returns the same payment instead of a second one.

curl https://api.hatang.dev/payments \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{
"amount": 150000,
"description": "Order #1001",
"expires_in": 3600,
"return_url": "https://shop.example.com/orders/1001",
"ipn_url": "https://shop.example.com/hatang/ipn",
"customer": { "name": "Tran Thi B", "email": "[email protected]" },
"metadata": { "order_id": "1001" }
}'
FieldMeaning
amountRequired. VND, VAT included: what the payer transfers. A whole number from 1 to 100,000,000,000
descriptionShown to the payer on the checkout page, up to 200 characters. It is not the transfer description
codeYour own transfer code instead of a generated one; see Your own code
connection_idWhich bank account receives the money. Required only when several accounts are active
expires_inSeconds until the payment expires: 1 to 2,592,000 (30 days). Default 86,400 (24 hours)
return_urlWhere the checkout page sends the payer after paying
ipn_urlYour endpoint for this payment's IPN calls (see Receive payment results). Public https only
customername, email, phone. Yours; returned as given
metadataYour own key-value data. Keys up to 40 characters; values are strings (up to 500 characters), numbers, booleans or null. Returned as given
invoiceIssue an e-invoice once paid: the invoice request (buyer, line items, VAT). See E-invoices

Idempotency​

The same Idempotency-Key with the same body returns the payment created the first time, with 201. The same key with a different body answers 400 invalid_idempotent_request. Keys are up to 256 characters and are scoped to your business and environment.

Response​

{
"object": "payment",
"id": "pay_01a0b904297873e8b3524a26fdca0ab5",
"environment": "production",
"status": "pending",
"amount": 150000,
"paid_amount": 0,
"currency": "VND",
"code": "HTBF7RDGVQ",
"description": "Order #1001",
"connection_id": "bcn_01a0b90428fd7c499145a12c133abe41",
"bank": { "bin": "970422", "name": "MB Bank", "account_number": "910452658", "account_name": "NGUYEN VAN A" },
"qr": {
"payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA530370454061500005802VN62140810HTBF7RDGVQ63044DEC",
"image_url": "https://api.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5/qr.svg"
},
"checkout_url": "https://app.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5",
"return_url": "https://shop.example.com/orders/1001",
"ipn_url": "https://shop.example.com/hatang/ipn",
"customer": { "name": "Tran Thi B", "email": "[email protected]" },
"metadata": { "order_id": "1001" },
"invoice": null,
"expires_at": "2026-09-19T10:34:12.856Z",
"paid_at": null,
"canceled_at": null,
"created_at": "2026-09-19T09:34:12.856Z"
}

Store id with your order. The payment.created webhook fires (there is no IPN for it).

Errors​

ErrorWhenWhat to do
422 validation_errorA field is missing or wrong; the message names it (amount: Number must be greater than or equal to 1)Fix the request
422 validation_error on ipn_urlThe URL points at an internal address, or is not https in productionUse a public HTTPS URL
422 validation_error "Several bank accounts are linked; pass connection_id"More than one active accountSend connection_id
422 no_bank_connectionNo active account, or connection_id is not activeLink or re-link the account
409 code_takenYour code is already used by another payment into this accountUse another code
400 invalid_idempotent_requestThe key was used with a different bodyUse a new key for a new order
429 rate_limit_exceededMore than 20 Payments requests per second for your businessRetry after the Retry-After header

2. Let the payer pay​

Redirect to checkout_url, or show the qr and bank details yourself. See How the payer pays.

3. Learn the result​

Wait for the IPN call or a webhook (see Receive payment results), then fulfil the order when status is paid or overpaid and paid_amount covers the order. Never fulfil from the browser's word.

Statuses​

statusMeaningEvent
pendingWaiting for the transferpayment.created
paidThe full amount arrived; paid_at is setpayment.succeeded
partially_paidLess than the amount arrived. Another transfer with the same code adds uppayment.partially_paid
overpaidMore than the amount arrivedpayment.overpaid
expiredNothing arrived before expires_atpayment.expired
canceledYou canceled it while it was pendingpayment.canceled

payment.succeeded, payment.partially_paid and payment.overpaid carry the matching bank transaction alongside the payment.

Edge cases​

  • Late money. A transfer that arrives after expires_at is still recorded: the payment moves from expired to paid (or partially_paid, overpaid) and you are notified. Decide yourself whether to fulfil a late order.
  • Partial payments do not expire. Only a pending payment becomes expired. A partially_paid payment stays partially_paid until the rest arrives or you settle it by hand.
  • Paying twice. A second transfer with the code of a paid payment makes it overpaid, and paid_amount is the sum. Refunds happen outside Ha Tang.
  • Canceled payments are no longer matched. A transfer with their code arrives as an unmatched transaction.received.
  • Expiry timing. Expiry runs every 30 seconds, so payment.expired arrives shortly after expires_at.

The payment code​

code is a prefix (HT by default, changeable per bank account with config.code_prefix) plus 8 characters without look-alikes: no 0, O, 1 or I. The payer must put it in the transfer description. Anything around it is fine (CK HTBF7RDGVQ thanh toan), and case, spaces, punctuation and accents are ignored. The VietQR already carries it, so a payer who scans does not type it.

Your own code​

Send code and that becomes the transfer description instead: your order number, so your bank statement and your orders read the same.

{ "amount": 275000, "code": "DH1002" }
  • Letters and digits only, 6 to 20 of them; it is upper-cased.
  • It must be unused on that bank account, or the call answers 409 code_taken. Retrying with the same Idempotency-Key returns the existing payment instead.
  • Longer is safer. The code is found by looking for it inside the description, so a short common code (ORDER1) can match a transfer that merely happens to contain it. Codes with your own prefix (DH1002) are specific enough.
  • When one code is part of another (DH1002 and DH10024), the longer one wins the transfer that carries it.

Retrieve and list payments​

curl https://api.hatang.dev/payments/pay_123 -H "Authorization: Bearer $HATANG_API_KEY"
curl "https://api.hatang.dev/payments?status=paid,overpaid&limit=50" -H "Authorization: Bearer $HATANG_API_KEY"
ParameterMeaning
limit1 to 100, default 20
afterThe next_cursor of the previous page
statusOne or more statuses, comma-separated
connection_idOne bank account

The list answers { "object": "list", "data": [...], "has_more": false, "next_cursor": null }, newest first.

Cancel a payment​

curl -X POST https://api.hatang.dev/payments/pay_123/cancel \
-H "Authorization: Bearer $HATANG_API_KEY"

Only a pending payment can be canceled. Any other status answers 409 invalid_state ("A paid payment cannot be canceled").

Record money received another way​

When money arrived outside the bank feed (cash, or a transfer without the code), record it by hand. The dashboard has the same action: Mark paid on the payment page.

curl https://api.hatang.dev/payments/pay_123/mark-paid \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "note": "Paid in cash at the counter" }'
FieldMeaning
amountThe amount received. Defaults to what is still owed (amount - paid_amount)
noteUp to 500 characters, kept in the event

It works on pending, partially_paid and expired payments (otherwise 409 invalid_state). The payment moves to paid, partially_paid or overpaid exactly as if a transfer had arrived, and the webhook event's data carries manual: { by, note, amount } so you can tell it apart.

Next steps​

View this page as Markdown