Environments and sandbox
Payments is the only Ha Tang product with environments. Every business has a development and a production environment, and their payments data is completely separate: bank accounts, payments, transactions, e-invoice providers, invoices and integration keys. API keys and webhook endpoints are shared by both. This page explains how to pick an environment and how to test the whole flow without real money.
What differs
development | production | |
|---|---|---|
| Bank | The development bank (simulator): confirmation code 000000, transfers simulated with an API call | Your real bank account |
| E-invoices | The provider's sandbox (Mắt Bão demo); nothing reaches the tax office | Real invoices under your tax code |
| Simulated transfers | POST /payments/simulate/transfer, plus a button on the checkout page and on the payment page in the dashboard | Not available (403 environment_mismatch) |
| Integration keys | ak_test_… / sk_test_… | ak_… / sk_… |
| Chosen with | Hatang-Environment: development | No header (the default), or Hatang-Environment: production |
Choose the environment per request
Send the Hatang-Environment header on each request:
| Header | Result |
|---|---|
Hatang-Environment: development | The request reads and writes development data |
Hatang-Environment: production or no header | Production |
| Any other value | 422 validation_error: "Hatang-Environment must be development or production" |
Every response repeats the environment it used in its own Hatang-Environment header, and every payments object (payment, transaction, bank connection, invoice) has an environment field.
- cURL
- Node.js
- Python
- PHP
curl https://api.hatang.dev/payments \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Hatang-Environment: development"
import { Hatang } from "@hatang/sdk";
// The option is sent as the Hatang-Environment header on every request.
// Without it, the SDK reads HATANG_ENVIRONMENT from the process environment.
const hatang = new Hatang(process.env.HATANG_API_KEY, {
baseUrl: "https://api.hatang.dev",
environment: "development",
});
const { data } = await hatang.payments.list();
import os
from hatang import Hatang
# environment= sends the Hatang-Environment header on every call
dev = Hatang(os.environ["HATANG_API_KEY"], environment="development")
payments = dev.payments.list() # development payments only
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://api.hatang.dev']);
$res = $client->get('/payments', ['headers' => [
'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'),
'Hatang-Environment' => 'development',
]]);
echo $res->getHeaderLine('Hatang-Environment'); // development
Read the environment from configuration (HATANG_ENVIRONMENT=development on your staging server, nothing in production) rather than hard-coding it, so the same code runs in both.
A key that starts with ht_test_ is tied to development: without the header it uses development, and asking it for production answers 403 environment_mismatch.
An id from one environment is unknown in the other: fetching a development payment with production selected answers 404 not_found.
In the dashboard
The bar at the top of every Payments page shows the current environment and switches it. Production is shown in red, and switching to it asks for confirmation. Other products have no switch.
The development bank
Link it with { "provider": "simulator" } and confirm with the code 000000 (see Quickstart). By default its QR codes name MB Bank and its account number is made up (it starts with SIM), so no real bank resolves it.
To see a real VietQR for your own bank, pass bank_code and your account number:
{
"provider": "simulator",
"bank_code": "BIDV",
"account_number": "96247001234",
"account_name": "NGUYEN VAN A"
}
bank_code takes the usual short codes: MB (the default), BIDV, ACB, VCB, ICB, TCB, VPB, TPB, STB and the other VietQR banks. With your real account number, a payer's banking app really does transfer money to you. No bank is integrated in development, though, so Ha Tang does not see that money arrive: the payment only moves when you simulate the transfer or mark it paid.
Simulate a transfer
- cURL
- Node.js
- Python
- PHP
# Pay a payment in full
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" }'
# A partial 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", "amount": 50000 }'
await hatang.payments.simulateTransfer({ payment_id: "pay_123" }); // in full
await hatang.payments.simulateTransfer({ payment_id: "pay_123", amount: 50_000 }); // partial
// A transfer with no order, for the transfers-only flow:
await hatang.payments.simulateTransfer({ connection_id: "bcn_123", amount: 99_000, content: "DH20250919 chuyen tien" });
HEADERS = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}", "Hatang-Environment": "development"}
requests.post("https://api.hatang.dev/payments/simulate/transfer", headers=HEADERS,
json={"payment_id": "pay_123", "amount": 50000}, timeout=10).raise_for_status()
$client->post('/payments/simulate/transfer', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'),
'Hatang-Environment' => 'development',
],
'json' => ['payment_id' => 'pay_123', 'amount' => 50000],
]);
| Field | Meaning |
|---|---|
payment_id | The payment to pay. Fills in the bank account, the amount still owed and the code |
connection_id | The bank account, for a transfer with no payment. Give payment_id or connection_id |
amount | Another amount, to test partial payments and overpayments. Defaults to what is still owed |
content | The transfer description as the bank would report it. Defaults to CHUYEN TIEN <code> |
direction | in (default) or out |
reference | The bank reference number. Reuse one to simulate the bank pushing the same transfer twice |
counterparty_name | The payer's name. Defaults to NGUYEN VAN KHACH |
The call answers 202:
{
"object": "simulated_transfer",
"reference": "SIM1758274452930A1B2C3",
"amount": 50000,
"direction": "in",
"description": "CHUYEN TIEN HTBF7RDGVQ",
"connection_id": "bcn_01a0b90428fd7c499145a12c133abe41",
"duplicate": false
}

A simulated transfer goes through exactly the same processing as a real bank's: matching, events, IPN and webhooks. Your handlers receive what they will receive in production.
Errors
| Error | When |
|---|---|
403 environment_mismatch | The request is in production |
422 validation_error | Neither payment_id nor connection_id, or the account is not a development bank account |
409 invalid_state | The bank account is not active |
422 missing_required_field | No amount and nothing is owed on the payment |
Test scenarios
| Scenario | How |
|---|---|
| Paid in full | Simulate with payment_id only |
| Partial, then completed | Simulate amount below the total, then simulate again without amount |
| Overpaid | Simulate amount above the total |
| Expired | Create with expires_in: 60, wait a couple of minutes, check for payment.expired |
| Money after expiry | Simulate on the expired payment: it becomes paid |
| Payer forgot the code | Simulate with connection_id and a content without the code |
| Duplicate bank push | Simulate twice with the same reference and amount: the second answers duplicate: true and changes nothing |
| Your endpoint is down | Point ipn_url at a URL that answers 500, then read GET /payments/{id}/ipn-deliveries |
Your handlers must check environment. A development payment must never unlock a real order.
Next steps
- Link a bank account in production.
- Create a payment.
- Go live: the switch to production, step by step.