# 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](https://hatang.dev/docs/guides/payments/bank-connections.md).
- A [webhook endpoint](https://hatang.dev/docs/guides/payments/webhooks.md) subscribed to `transaction.received`. This flow has no IPN, because there is no payment to attach an `ipn_url` to.

## 1. Tell Ha Tang what your order codes look like {#1-tell-hatang-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**

```bash
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" } } }'
```

**Node.js**

```ts
await hatang.payments.bankConnections.update("bcn_123", {
  config: { payment_code_pattern: { prefix: "DH", min_length: 4, max_length: 8, charset: "digits" } },
});
```

**Python**

```python
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()
```

**PHP**

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

1. The description is normalized: accents removed, `đ` becomes `D`, everything upper-cased, and every character that is not a letter or a digit dropped. `ck dh 2025-0919 cam on` becomes `CKDH20250919CAMON`.
2. Ha Tang looks for the prefix followed by `min_length` to `max_length` characters 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`.

> **Caution**
>
> 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**

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

**Node.js**

```ts
const { data: qr } = await hatang.payments.qr({ amount: 99_000, content: "DH20250919" });
// Render qr.payload with any QR library.
```

**Python**

```python
qr = requests.get("https://api.hatang.dev/payments/qr", headers=HEADERS,
                  params={"amount": 99000, "content": "DH20250919"}, timeout=10).json()
```

**PHP**

```php
$qr = json_decode($client->get('/payments/qr', ['query' => ['amount' => 99000, 'content' => 'DH20250919']])->getBody(), true);
```

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

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

1. Verify the webhook signature (see [Webhooks](https://hatang.dev/docs/guides/payments/webhooks.md)).
2. Find the order by `merchant_code`, and compare `amount` with what it should be.
3. Store `data.id`. The same transaction is never reported twice, but your endpoint may receive the same delivery again after a timeout.
4. Check `environment` and `direction`.

### 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**

```bash
curl "https://api.hatang.dev/payments/transactions?direction=in&match_status=unmatched&limit=50" \
  -H "Authorization: Bearer $HATANG_API_KEY"
```

**Node.js**

```ts
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! });
```

**Python**

```python
page = requests.get("https://api.hatang.dev/payments/transactions", headers=HEADERS,
                    params={"direction": "in", "match_status": "unmatched", "limit": 50}, timeout=10).json()
```

**PHP**

```php
$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](https://hatang.dev/docs/guides/payments/einvoices.md#invoices-without-a-payment) for a transfer with `transaction_id`.
- [Import a statement](https://hatang.dev/docs/guides/payments/bank-connections.md#statement-import) to recover transfers the bank missed.
- [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md) when you want a checkout page and expiry.

Source: https://hatang.dev/docs/guides/payments/transactions
