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
POSTfrom 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.
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.
export HATANG_API_KEY=ht_123...
For Node.js, install the SDK:
npm install @hatang/sdk
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
- Node.js
- Python
- PHP
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" }'
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");
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
$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:
{
"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
- Node.js
- Python
- PHP
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" }
}'
// 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);
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"])
$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.
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
- Node.js
- Python
- PHP
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" }'
await hatang.payments.simulateTransfer({ payment_id: payment.id });
requests.post(f"{API}/payments/simulate/transfer", headers=HEADERS,
json={"payment_id": payment["id"]}, timeout=10).raise_for_status()
$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_).
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 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:
curl https://api.hatang.dev/payments/pay_123/ipn-deliveries \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Hatang-Environment: development"
{
"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: every field, status and edge case.
- Receive payment results: IPN, webhooks and polling compared.
- JavaScript SDK: the whole flow in a few calls.
- Go live when you are ready for real money.