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
- Node.js
- Python
- PHP
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" }
}'
const { data: payment, error } = await hatang.payments.create(
{
amount: 150_000,
description: "Order #1001",
expires_in: 3600,
return_url: "https://shop.example.com/orders/1001",
ipn_url: "https://shop.example.com/hatang/ipn",
metadata: { order_id: "1001" },
},
{ idempotencyKey: "order-1001" },
);
if (error) throw new Error(`${error.name}: ${error.message}`);
payment = hatang.payments.create(
{
"amount": 150000,
"description": "Order #1001",
"expires_in": 3600,
"return_url": "https://shop.example.com/orders/1001",
"ipn_url": "https://shop.example.com/hatang/ipn",
"metadata": {"order_id": "1001"},
},
idempotency_key="order-1001",
) # an error raises hatang.HatangError with .name and .message
<?php
use GuzzleHttp\Exception\ClientException;
try {
$res = $client->post('/payments', [
'headers' => ['Idempotency-Key' => 'order-1001'],
'json' => [
'amount' => 150000,
'description' => 'Order #1001',
'expires_in' => 3600,
'return_url' => 'https://shop.example.com/orders/1001',
'ipn_url' => 'https://shop.example.com/hatang/ipn',
'metadata' => ['order_id' => '1001'],
],
]);
$payment = json_decode($res->getBody(), true);
} catch (ClientException $e) {
$err = json_decode($e->getResponse()->getBody(), true);
throw new RuntimeException("{$err['name']}: {$err['message']}");
}
| Field | Meaning |
|---|---|
amount | Required. VND, VAT included: what the payer transfers. A whole number from 1 to 100,000,000,000 |
description | Shown to the payer on the checkout page, up to 200 characters. It is not the transfer description |
code | Your own transfer code instead of a generated one; see Your own code |
connection_id | Which bank account receives the money. Required only when several accounts are active |
expires_in | Seconds until the payment expires: 1 to 2,592,000 (30 days). Default 86,400 (24 hours) |
return_url | Where the checkout page sends the payer after paying |
ipn_url | Your endpoint for this payment's IPN calls (see Receive payment results). Public https only |
customer | name, email, phone. Yours; returned as given |
metadata | Your own key-value data. Keys up to 40 characters; values are strings (up to 500 characters), numbers, booleans or null. Returned as given |
invoice | Issue 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",
"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
| Error | When | What to do |
|---|---|---|
422 validation_error | A 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_url | The URL points at an internal address, or is not https in production | Use a public HTTPS URL |
422 validation_error "Several bank accounts are linked; pass connection_id" | More than one active account | Send connection_id |
422 no_bank_connection | No active account, or connection_id is not active | Link or re-link the account |
409 code_taken | Your code is already used by another payment into this account | Use another code |
400 invalid_idempotent_request | The key was used with a different body | Use a new key for a new order |
429 rate_limit_exceeded | More than 20 Payments requests per second for your business | Retry 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
status | Meaning | Event |
|---|---|---|
pending | Waiting for the transfer | payment.created |
paid | The full amount arrived; paid_at is set | payment.succeeded |
partially_paid | Less than the amount arrived. Another transfer with the same code adds up | payment.partially_paid |
overpaid | More than the amount arrived | payment.overpaid |
expired | Nothing arrived before expires_at | payment.expired |
canceled | You canceled it while it was pending | payment.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_atis still recorded: the payment moves fromexpiredtopaid(orpartially_paid,overpaid) and you are notified. Decide yourself whether to fulfil a late order. - Partial payments do not expire. Only a
pendingpayment becomesexpired. Apartially_paidpayment stayspartially_paiduntil the rest arrives or you settle it by hand. - Paying twice. A second transfer with the code of a
paidpayment makes itoverpaid, andpaid_amountis 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.expiredarrives shortly afterexpires_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 sameIdempotency-Keyreturns 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 (
DH1002andDH10024), 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"
| Parameter | Meaning |
|---|---|
limit | 1 to 100, default 20 |
after | The next_cursor of the previous page |
status | One or more statuses, comma-separated |
connection_id | One bank account |
The list answers { "object": "list", "data": [...], "has_more": false, "next_cursor": null }, newest first.
Cancel a payment
- cURL
- Node.js
- Python
- PHP
curl -X POST https://api.hatang.dev/payments/pay_123/cancel \
-H "Authorization: Bearer $HATANG_API_KEY"
const { data, error } = await hatang.payments.cancel("pay_123");
hatang.payments.cancel("pay_123")
$client->post('/payments/pay_123/cancel');
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
- Node.js
- Python
- PHP
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" }'
await hatang.payments.markPaid("pay_123", { note: "Paid in cash at the counter" });
requests.post("https://api.hatang.dev/payments/pay_123/mark-paid", headers=HEADERS,
json={"note": "Paid in cash at the counter"}, timeout=10).raise_for_status()
$client->post('/payments/pay_123/mark-paid', ['json' => ['note' => 'Paid in cash at the counter']]);
| Field | Meaning |
|---|---|
amount | The amount received. Defaults to what is still owed (amount - paid_amount) |
note | Up 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
- How the payer pays: the checkout page, the QR and your own payment page.
- Receive payment results: IPN and webhooks.
- E-invoices: add an
invoiceblock.