# 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](https://hatang.dev/docs/guides/payments/bank-connections.md) in the environment you use.
- An API key with `payments:write`.
- An HTTPS endpoint for the [IPN](https://hatang.dev/docs/guides/payments/payment-results.md), 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**

```bash
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": "b@example.com" },
    "metadata": { "order_id": "1001" }
  }'
```

**Node.js**

```ts
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",
    customer: { name: "Tran Thi B", email: "b@example.com" },
    metadata: { order_id: "1001" },
  },
  { idempotencyKey: "order-1001" },
);
if (error) throw new Error(`${error.name}: ${error.message}`);
```

**Python**

```python
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",
        "customer": {"name": "Tran Thi B", "email": "b@example.com"},
        "metadata": {"order_id": "1001"},
    },
    idempotency_key="order-1001",
)  # an error raises hatang.HatangError with .name and .message
```

**PHP**

```php
<?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',
            'customer' => ['name' => 'Tran Thi B', 'email' => 'b@example.com'],
            '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](#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](https://hatang.dev/docs/guides/payments/payment-results.md)). 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](https://hatang.dev/docs/guides/payments/einvoices.md#invoice-request) (buyer, line items, VAT). See [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md) |

### 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 {#response}

```json
{
  "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": "b@example.com" },
  "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](https://hatang.dev/docs/guides/payments/buyer-flow.md).

## 3. Learn the result

Wait for the IPN call or a webhook (see [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md)), then fulfil the order when `status` is `paid` or `overpaid` and `paid_amount` covers the order. Never fulfil from the browser's word.

## Statuses

```mermaid
stateDiagram-v2
    [*] --> pending
    pending --> paid: full amount
    pending --> partially_paid: less
    pending --> overpaid: more
    partially_paid --> paid: rest arrives with the same code
    partially_paid --> overpaid: more than the rest
    paid --> overpaid: another transfer with the same code
    pending --> expired: expires_at passes
    expired --> paid: late money
    pending --> canceled: POST /cancel
```

| `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_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 {#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 {#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.

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

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

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

**Node.js**

```ts
const { data, error } = await hatang.payments.cancel("pay_123");
```

**Python**

```python
hatang.payments.cancel("pay_123")
```

**PHP**

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

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

**Node.js**

```ts
await hatang.payments.markPaid("pay_123", { note: "Paid in cash at the counter" });
```

**Python**

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

**PHP**

```php
$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](https://hatang.dev/docs/guides/payments/buyer-flow.md): the checkout page, the QR and your own payment page.
- [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md): IPN and webhooks.
- [E-invoices](https://hatang.dev/docs/guides/payments/einvoices.md): add an `invoice` block.

Source: https://hatang.dev/docs/guides/payments/create-payment
