# 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 {#choose-the-environment}

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

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

**Node.js**

```ts
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();
```

**Python**

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

```php
<?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
```

> **Tip**
>
> 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.

> **Note: Older development keys**
>
> 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](https://hatang.dev/docs/guides/payments/quickstart.md)). 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:

```json
{
  "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 {#simulate-a-transfer}

**cURL**

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

**Node.js**

```ts
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" });
```

**Python**

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

**PHP**

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

```json
{
  "object": "simulated_transfer",
  "reference": "SIM1758274452930A1B2C3",
  "amount": 50000,
  "direction": "in",
  "description": "CHUYEN TIEN HTBF7RDGVQ",
  "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41",
  "duplicate": false
}
```

<figure className="checkout-single">
  <img src="/img/thanh-toan/checkout-sandbox.png" alt="The checkout page in development, with a button that simulates the transfer" width="420" />
  <figcaption>In development, the checkout page has a button that simulates the transfer</figcaption>
</figure>

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

> **Danger**
>
> Your handlers must check `environment`. A `development` payment must never unlock a real order.

## Next steps

- [Link a bank account](https://hatang.dev/docs/guides/payments/bank-connections.md) in production.
- [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md).
- [Go live](https://hatang.dev/docs/guides/payments/go-live.md): the switch to production, step by step.

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