Skip to main content

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.

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
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
});

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

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

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

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

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_).

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 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​

ErrorWhyFix
422 no_bank_connectionNo active bank account in this environmentDo 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 accountSend connection_id when you create the payment
422 validation_error on ipn_urlThe URL is internal (localhost, 10.x, 192.168.x…) or not httpsUse a public HTTPS URL, such as a tunnel
401 restricted_api_keyThe key lacks a Payments scopeCreate a key with payments:read and payments:write
403 environment_mismatchYou simulated a transfer outside developmentSend Hatang-Environment: development
No IPN arrivesYour endpoint did not answer 2xxLook at last_error in step 7; Ha Tang retries for about a day

Next steps​

View this page as Markdown