Receive transfers without orders
Use this flow when your system already has order codes and you only need to know when money for them arrives, for example a static QR code at a shop counter. You do not create a payment per order. Ha Tang reports every transfer into the account as transaction.received, with your order code if it found one in the description.
Before you start
- An active bank connection.
- A webhook endpoint subscribed to
transaction.received. This flow has no IPN, because there is no payment to attach anipn_urlto.
1. Tell Ha Tang what your order codes look like
Set payment_code_pattern on the bank account: a prefix and a length range. Ha Tang then looks for it in every transfer description.
- cURL
- Node.js
- Python
- PHP
curl -X PATCH https://api.hatang.dev/payments/bank-connections/bcn_123 \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "config": { "payment_code_pattern": { "prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits" } } }'
await hatang.payments.bankConnections.update("bcn_123", {
config: { payment_code_pattern: { prefix: "DH", min_length: 4, max_length: 8, charset: "digits" } },
});
requests.patch("https://api.hatang.dev/payments/bank-connections/bcn_123", headers=HEADERS, json={
"config": {"payment_code_pattern": {"prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits"}},
}, timeout=10).raise_for_status()
$client->patch('/payments/bank-connections/bcn_123', ['json' => [
'config' => ['payment_code_pattern' => ['prefix' => 'DH', 'min_length' => 4, 'max_length' => 8, 'charset' => 'digits']],
]]);
How the code is found
- The description is normalized: accents removed,
đbecomesD, everything upper-cased, and every character that is not a letter or a digit dropped.ck dh 2025-0919 cam onbecomesCKDH20250919CAMON. - Ha Tang looks for the prefix followed by
min_lengthtomax_lengthcharacters of the charset, and takes the first match, as long as possible.
With the pattern above, DH20250919 is found in ck dh 2025-0919 cam on.
Spaces are removed before the search, so with charset: "alnum" the words after your code run into it: DH1001 cam on becomes DH1001CAMON and the match is DH1001CAMO. Prefer digits, or make every code exactly max_length long.
2. Show a VietQR with your code
GET /payments/qr builds a VietQR for any amount and description. Nothing is stored.
- cURL
- Node.js
- Python
- PHP
# JSON with the EMVCo payload
curl "https://api.hatang.dev/payments/qr?amount=99000&content=DH20250919" \
-H "Authorization: Bearer $HATANG_API_KEY"
# An SVG image
curl "https://api.hatang.dev/payments/qr?amount=99000&content=DH20250919&format=svg" \
-H "Authorization: Bearer $HATANG_API_KEY" -o qr.svg
const { data: qr } = await hatang.payments.qr({ amount: 99_000, content: "DH20250919" });
// Render qr.payload with any QR library.
qr = requests.get("https://api.hatang.dev/payments/qr", headers=HEADERS,
params={"amount": 99000, "content": "DH20250919"}, timeout=10).json()
$qr = json_decode($client->get('/payments/qr', ['query' => ['amount' => 99000, 'content' => 'DH20250919']])->getBody(), true);
{
"object": "vietqr",
"payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA53037045405990005802VN62140810DH202509196304FCC9",
"connection_id": "bcn_01a0b90428fd7c499145a12c133abe41",
"bank": { "bin": "970422", "account_number": "910452658", "account_name": "NGUYEN VAN A" },
"amount": 99000,
"content": "DH20250919"
}
| Parameter | Meaning |
|---|---|
amount | VND. Leave it out for a QR where the payer types the amount (a counter sticker) |
content | The description to prefill, up to 50 characters |
connection_id | Which account; required only when several are linked |
format | json (default) or svg |
logo | With format=svg: 0 leaves the Ha Tang mark out of the image |
The QR only prefills the description. A payer who transfers by hand must type the code themselves, so print it next to the QR.
3. Receive transaction.received
{
"type": "transaction.received",
"created_at": "2026-09-19T09:34:12.946Z",
"data": {
"object": "transaction",
"id": "txn_01a0b90429dc79c7a5689c7a0c485a01",
"environment": "production",
"connection_id": "bcn_01a0b90428fd7c499145a12c133abe41",
"direction": "in",
"amount": 99000,
"currency": "VND",
"description": "DH20250919 chuyen tien",
"code": "DH20250919",
"merchant_code": "DH20250919",
"reference": "FT1789810452930810",
"counterparty": { "account": "0987654321", "name": "NGUYEN VAN KHACH", "bank": "970436" },
"booked_at": "2026-09-19T09:34:12.930Z",
"source": "push",
"payment_id": null,
"match_status": "unmatched",
"invoice_exempt": null,
"created_at": "2026-09-19T09:34:12.946Z"
}
}
| Field | Meaning |
|---|---|
merchant_code | Your order code as found in the description; null when none was found |
code | The code stored on the transaction: a Ha Tang payment code when the transfer paid a payment, otherwise your order code |
reference | The bank's reference number |
source | How Ha Tang learned about it: push (the bank notified it), reconcile (read from the account history), import (a statement) or simulator (development) |
payment_id, match_status | Set when the transfer paid one of your payments (matched); unmatched otherwise |
counterparty.bank | The payer's bank BIN, when the bank reports it |
Your handler should:
- Verify the webhook signature (see Webhooks).
- Find the order by
merchant_code, and compareamountwith what it should be. - Store
data.id. The same transaction is never reported twice, but your endpoint may receive the same delivery again after a timeout. - Check
environmentanddirection.
Edge cases
| Situation | What you get |
|---|---|
| The payer forgot the code | merchant_code: null. Match by amount and counterparty.name, or ask the payer |
| The payer sent the wrong amount | The amount as transferred; deciding is up to you |
| The payer paid twice | Two transactions with different id and reference |
| The bank pushed the same transfer twice | One transaction (same reference and amount on the account) |
| The transfer also carries a payment code | It settles that payment (payment.succeeded, etc.) and transaction.received has payment_id set. A transfer is never counted twice |
Filter what is reported
Per bank account, under transaction_events in the config or in the dashboard:
| Setting | Default | Effect |
|---|---|---|
direction | in | all also reports money going out |
require_code | false | true reports only transfers where a code was found or a payment matched |
min_amount | 0 | Transfers below this amount are not reported |
List and look up transactions
Every transfer is stored, whether or not it was reported.
- cURL
- Node.js
- Python
- PHP
curl "https://api.hatang.dev/payments/transactions?direction=in&match_status=unmatched&limit=50" \
-H "Authorization: Bearer $HATANG_API_KEY"
const { data: page } = await hatang.payments.transactions.list({ direction: "in", match_status: "unmatched", limit: 50 });
if (page.has_more) await hatang.payments.transactions.list({ after: page.next_cursor! });
page = requests.get("https://api.hatang.dev/payments/transactions", headers=HEADERS,
params={"direction": "in", "match_status": "unmatched", "limit": 50}, timeout=10).json()
$page = json_decode($client->get('/payments/transactions', ['query' => [
'direction' => 'in', 'match_status' => 'unmatched', 'limit' => 50,
]])->getBody(), true);
| Parameter | Meaning |
|---|---|
limit | 1 to 100, default 20 |
after | The next_cursor of the previous page |
connection_id | One bank account |
direction | in or out |
match_status | matched, unmatched or ignored |
payment_id | The transactions that settled this payment |
The answer is { "object": "list", "data": [...], "has_more": true, "next_cursor": "txn_..." }, newest first. GET /payments/transactions/{id} returns one transaction.
Next steps
- Issue an e-invoice for a transfer with
transaction_id. - Import a statement to recover transfers the bank missed.
- Create a payment when you want a checkout page and expiry.