# Quickstart

Take your first payment in the `development` environment: link the development bank, create a payment, pretend the payer transferred the money, and receive the signed notification on your server. You do not need a real bank account.

## Before you start

- A Ha Tang account with the **Payments** module switched on (Business settings → Modules). While it is off, every Payments request answers `403 product_disabled`.
- A server that can receive an HTTPS `POST` from the internet, for step 6. On your laptop, use a tunnel such as ngrok or Cloudflare Tunnel.

Every request on this page sends `Hatang-Environment: development`. Without that header, requests go to production. See [Environments and sandbox](https://hatang.dev/docs/guides/payments/environments.md).

## Step 1: Create an API key

In the dashboard, open **API Keys** and create a key with the `payments:read` and `payments:write` scopes. Keep it on your server.

```bash
export HATANG_API_KEY=ht_123...
```

For Node.js, install the SDK:

```bash
npm install @hatang/sdk
```

```ts title="lib/hatang.ts"
import { Hatang } from "@hatang/sdk";

export const hatang = new Hatang(process.env.HATANG_API_KEY, {
  baseUrl: "https://api.hatang.dev",
  environment: "development", // sent as the Hatang-Environment header
});
```

## Step 2: Link the development bank

Start the link, then confirm it with the code `000000`. The development bank always uses that code.

**cURL**

```bash
curl https://api.hatang.dev/payments/bank-connections \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Hatang-Environment: development" \
  -H "Content-Type: application/json" \
  -d '{ "provider": "simulator", "label": "Test account" }'

curl https://api.hatang.dev/payments/bank-connections/bcn_123/confirm \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Hatang-Environment: development" \
  -H "Content-Type: application/json" \
  -d '{ "otp": "000000" }'
```

**Node.js**

```ts
import { hatang } from "./lib/hatang";

const { data: conn, error } = await hatang.payments.bankConnections.create({ provider: "simulator", label: "Test account" });
if (error) throw new Error(`${error.name}: ${error.message}`);
await hatang.payments.bankConnections.confirm(conn.id, "000000");
```

**Python**

```python
import os, requests

API = "https://api.hatang.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}", "Hatang-Environment": "development"}

conn = requests.post(f"{API}/payments/bank-connections", headers=HEADERS,
                     json={"provider": "simulator", "label": "Test account"}, timeout=10).json()
requests.post(f"{API}/payments/bank-connections/{conn['id']}/confirm", headers=HEADERS,
              json={"otp": "000000"}, timeout=10).raise_for_status()
```

**PHP**

```php
<?php
$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.hatang.dev',
    'headers' => [
        'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'),
        'Hatang-Environment' => 'development',
    ],
]);
$conn = json_decode($client->post('/payments/bank-connections', [
    'json' => ['provider' => 'simulator', 'label' => 'Test account'],
])->getBody(), true);
$client->post("/payments/bank-connections/{$conn['id']}/confirm", ['json' => ['otp' => '000000']]);
```

The first call answers `201` with the connection in `pending_otp` and a `challenge`; the confirmation turns it `active`:

```json
{
  "object": "bank_connection",
  "id": "bcn_01a0b90428fd7c499145a12c133abe41",
  "environment": "development",
  "provider": "simulator",
  "bank": { "bin": "970422", "name": "MBBank" },
  "account_number": "SIM4821937506",
  "account_name": "TAI KHOAN GIA LAP",
  "account_type": "personal",
  "status": "active",
  "label": "Test account",
  "link_mode": "otp",
  "activated_at": "2026-09-19T09:30:02.114Z",
  "created_at": "2026-09-19T09:29:51.402Z"
}
```

You can do the same in the dashboard: **Payments settings → Bank accounts → Add bank account**.

## Step 3: Create a payment

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 "Hatang-Environment: development" \
  -H "Idempotency-Key: order-1001" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 150000,
    "description": "Order #1001",
    "ipn_url": "https://shop.example.com/hatang/ipn",
    "metadata": { "order_id": "1001" }
  }'
```

**Node.js**

```ts
// createOrder uses the order id as the Idempotency-Key and stores it in metadata.order_id.
const { data: payment, error } = await hatang.payments.createOrder({
  order_id: "1001",
  amount: 150_000,
  description: "Order #1001",
  ipn_url: "https://shop.example.com/hatang/ipn",
});
if (error) throw new Error(`${error.name}: ${error.message}`);
console.log(payment.checkout_url);
```

**Python**

```python
from hatang import Hatang  # pip install hatang

hatang = Hatang(environment="development")  # sent as the Hatang-Environment header
payment = hatang.payments.create({
    "amount": 150000,
    "description": "Order #1001",
    "ipn_url": "https://shop.example.com/hatang/ipn",
    "metadata": {"order_id": "1001"},
}, idempotency_key="order-1001")
print(payment["checkout_url"])
```

**PHP**

```php
$payment = json_decode($client->post('/payments', [
    'headers' => ['Idempotency-Key' => 'order-1001'],
    'json' => [
        'amount' => 150000,
        'description' => 'Order #1001',
        'ipn_url' => 'https://shop.example.com/hatang/ipn',
        'metadata' => ['order_id' => '1001'],
    ],
])->getBody(), true);
echo $payment['checkout_url'];
```

The answer is `201` with the payment in `pending`, its `code`, a `qr` and a `checkout_url`. The full object is described in [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md#response).

## Step 4: Open the checkout page

Open `checkout_url` in a browser. It shows the VietQR, the bank details and a countdown. In development it also has a button that simulates the transfer.

## Step 5: Simulate the transfer

**cURL**

```bash
curl https://api.hatang.dev/payments/simulate/transfer \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Hatang-Environment: development" \
  -H "Content-Type: application/json" \
  -d '{ "payment_id": "pay_123" }'
```

**Node.js**

```ts
await hatang.payments.simulateTransfer({ payment_id: payment.id });
```

**Python**

```python
requests.post(f"{API}/payments/simulate/transfer", headers=HEADERS,
              json={"payment_id": payment["id"]}, timeout=10).raise_for_status()
```

**PHP**

```php
$client->post('/payments/simulate/transfer', ['json' => ['payment_id' => $payment['id']]]);
```

The call answers `202`. Within a few seconds the payment becomes `paid`, the checkout page switches to its paid state, and your `ipn_url` receives a `payment.succeeded` call.

## Step 6: Handle the IPN

Your endpoint must check the `Hatang-Signature` header against the raw body before it trusts anything, then answer `2xx` within 10 seconds. The secret key is in **Payments settings → Integration keys** (development keys start with `sk_test_`).

```ts title="app/api/hatang/ipn/route.ts (Next.js)"
import { constructIpnEvent, HatangSignatureError } from "@hatang/sdk";

export async function POST(req: Request) {
  const raw = await req.text(); // the raw body, before any JSON parsing
  try {
    const ipn = await constructIpnEvent(raw, req.headers, process.env.HATANG_SECRET_KEY!);
    if (ipn.data.status === "paid" || ipn.data.status === "overpaid") {
      await markOrderPaid(ipn.data.metadata.order_id as string, ipn.data.paid_amount);
    }
    return new Response(null, { status: 200 });
  } catch (err) {
    if (err instanceof HatangSignatureError) return new Response(null, { status: 401 });
    throw err; // a 5xx makes Hatang retry later
  }
}
```

[Verify IPN signatures](https://hatang.dev/docs/guides/payments/verify-ipn.md) has the same handler in PHP, Python and C#.

## Step 7: Check what was delivered

Every IPN call and its result is listed for the payment, newest first:

```bash
curl https://api.hatang.dev/payments/pay_123/ipn-deliveries \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Hatang-Environment: development"
```

```json
{
  "object": "list",
  "data": [
    {
      "object": "ipn_delivery",
      "id": "ipn_01a0b90429d87cf89e37fc2f66040ec5",
      "payment_id": "pay_01a0b904297873e8b3524a26fdca0ab5",
      "event_type": "payment.succeeded",
      "url": "https://shop.example.com/hatang/ipn",
      "status": "success",
      "attempt": 1,
      "response_status": 200,
      "response_ms": 84,
      "last_error": null,
      "next_attempt_at": null,
      "delivered_at": "2026-09-19T09:34:13.041Z",
      "created_at": "2026-09-19T09:34:12.952Z"
    }
  ]
}
```

The payment page in the dashboard shows the same list.

## What can go wrong

| Error | Why | Fix |
|---|---|---|
| `422 no_bank_connection` | No active bank account in this environment | Do step 2, or check that you sent `Hatang-Environment: development` |
| `422 validation_error` with "Several bank accounts are linked; pass connection_id" | More than one active account | Send `connection_id` when you create the payment |
| `422 validation_error` on `ipn_url` | The URL is internal (`localhost`, `10.x`, `192.168.x`…) or not `https` | Use a public HTTPS URL, such as a tunnel |
| `401 restricted_api_key` | The key lacks a Payments scope | Create a key with `payments:read` and `payments:write` |
| `403 environment_mismatch` | You simulated a transfer outside development | Send `Hatang-Environment: development` |
| No IPN arrives | Your endpoint did not answer `2xx` | Look at `last_error` in step 7; Ha Tang retries for about a day |

## Next steps

- [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md): every field, status and edge case.
- [Receive payment results](https://hatang.dev/docs/guides/payments/payment-results.md): IPN, webhooks and polling compared.
- [JavaScript SDK](https://hatang.dev/docs/guides/payments/sdk-javascript.md): the whole flow in a few calls.
- [Go live](https://hatang.dev/docs/guides/payments/go-live.md) when you are ready for real money.

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