# Bank connections

A bank connection is the link between Ha Tang and the bank account your customers pay into. Ha Tang only reads the account's incoming transfers; it never moves money. This page shows how to link each bank, configure matching, unlink an account, and import a statement when the bank missed a transfer.

## Before you start

- An API key with `payments:write`, or access to **Payments settings → Bank accounts** in the dashboard.
- For a real bank: the account number, the holder's name exactly as the bank has it, and (for MB Bank) the national ID or tax code the account was opened with.
- Pick the environment. Real banks exist only in `production`; the development bank only in `development`. Asking for the wrong one answers `403 environment_mismatch`.

## Endpoints

| Method | Endpoint | Does |
|---|---|---|
| `GET` | `/payments/bank-connections` | List the linked accounts (unlinked ones are left out) |
| `POST` | `/payments/bank-connections` | Start linking |
| `GET` | `/payments/bank-connections/{id}` | Retrieve one |
| `PATCH` | `/payments/bank-connections/{id}` | Change the label or the matching settings |
| `POST` | `/payments/bank-connections/{id}/confirm` | Confirm linking with the bank's code |
| `POST` | `/payments/bank-connections/{id}/unlink` | Start unlinking |
| `POST` | `/payments/bank-connections/{id}/unlink/confirm` | Confirm unlinking with the bank's code |
| `POST` | `/payments/statements` | Import a statement (CSV) |
| `GET` | `/payments/statements/{id}` | Statement import progress |

All of them need `payments:read` (reads) or `payments:write` (changes).

## How each bank links

| Bank | `provider` | `link_mode` | What you do |
|---|---|---|---|
| MB Bank | `mbbank` | `otp` | Send the account details; MB texts a code to the phone registered for the account; confirm with it |
| BIDV | `bidv` | `bank_registered` | Register BIDV's balance notification service at BIDV for the account, then send the account number. The connection activates when BIDV sends its first message for it |
| ACB | `acb` | `redirect` | Send the account number, then open `challenge.authorize_url` so the holder consents on ACB's page. They come back to the dashboard and the account is active |
| Pay2S | `pay2s` | `bank_registered` | Link the account on Pay2S, create a Hook there pointing at `webhook_url`, then send the account number, `bank_code` and the Hook's token. The connection activates with the first push |
| Development bank | `simulator` | `otp` | Confirm with the code `000000` |

## Link an MB Bank account {#link-an-account}

```mermaid
sequenceDiagram
    participant S as You
    participant H as Ha Tang
    participant B as Bank
    S->>H: POST /payments/bank-connections
    H->>B: Check the account against the holder's ID
    B-->>S: Code by SMS to the account holder
    H-->>S: status pending_otp + challenge
    S->>H: POST /payments/bank-connections/{id}/confirm
    H->>B: Confirm
    H-->>S: status active + webhook bank_connection.activated
```

### 1. Start linking

**cURL**

```bash
curl https://api.hatang.dev/payments/bank-connections \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "mbbank",
    "account_number": "910452658",
    "account_name": "NGUYEN VAN A",
    "account_type": "personal",
    "holder_id": "001099012345",
    "label": "Shop account"
  }'
```

**Node.js**

```ts
const { data: conn, error } = await hatang.payments.bankConnections.create({
  provider: "mbbank",
  account_number: "910452658",
  account_name: "NGUYEN VAN A",
  account_type: "personal",
  holder_id: "001099012345",
  label: "Shop account",
});
if (error) throw new Error(`${error.name}: ${error.message}`);
```

**Python**

```python
conn = requests.post("https://api.hatang.dev/payments/bank-connections", headers=HEADERS, json={
    "provider": "mbbank",
    "account_number": "910452658",
    "account_name": "NGUYEN VAN A",
    "account_type": "personal",
    "holder_id": "001099012345",
    "label": "Shop account",
}, timeout=30).json()
```

**PHP**

```php
$conn = json_decode($client->post('/payments/bank-connections', ['json' => [
    'provider' => 'mbbank',
    'account_number' => '910452658',
    'account_name' => 'NGUYEN VAN A',
    'account_type' => 'personal',
    'holder_id' => '001099012345',
    'label' => 'Shop account',
]])->getBody(), true);
```

| Field | Description |
|---|---|
| `provider` | **Required.** `mbbank`, `bidv`, `acb` or `pay2s` in production; `simulator` in development |
| `account_number` | Required for real banks. Digits (Pay2S also allows capital letters), 4 to 30 characters. The development bank makes one up when you leave it out |
| `account_name` | The holder's name exactly as the bank has it. Required for MB Bank |
| `account_type` | `personal` (default) or `business` |
| `holder_id` | National ID (personal) or tax code (business) the account was opened with. Required for MB Bank; stored encrypted |
| `bank_code` | For a provider that serves several banks (Pay2S, the development bank): which bank the account is at, e.g. `BIDV`, `MB`, `VCB`. It decides the bank the VietQR names. Refused for the other providers |
| `webhook_token` | Pay2S only, and required there: the token of the Hook you created on Pay2S |
| `label` | Your name for the account, up to 100 characters |
| `config` | Matching settings; see [Matching settings](#config) |

The answer is `201` with the connection in `pending_otp` and a `challenge`:

```json
{
  "object": "bank_connection",
  "id": "bcn_01a0b90428fd7c499145a12c133abe41",
  "environment": "production",
  "provider": "mbbank",
  "bank": { "bin": "970422", "name": "MB Bank" },
  "account_number": "910452658",
  "account_name": "NGUYEN VAN A",
  "account_type": "personal",
  "status": "pending_otp",
  "label": "Shop account",
  "config": {
    "code_prefix": "HT",
    "payment_code_pattern": null,
    "transaction_events": { "direction": "in", "require_code": false, "min_amount": 0 }
  },
  "capabilities": { "pushNotifications": true, "history": false, "accountInfo": false, "virtualAccounts": false, "refunds": false },
  "link_mode": "otp",
  "webhook_url": null,
  "consent": null,
  "last_transaction_at": null,
  "activated_at": null,
  "revoked_at": null,
  "created_at": "2026-09-19T09:29:51.402Z",
  "challenge": { "id": "lch_01a0b90428e17d2c8b5f0a4e3c2d1b0a", "expires_at": "2026-09-19T09:34:51.402Z", "delivery": "sms_otp" }
}
```

`challenge.delivery` tells you what happens next: `sms_otp` (a code is on its way), `redirect` (open `challenge.authorize_url`) or `bank_registration` (wait for the bank's first message).

### 2. Confirm with the code

**cURL**

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

**Node.js**

```ts
const { data, error } = await hatang.payments.bankConnections.confirm(conn.id, "123456");
```

**Python**

```python
requests.post(f"https://api.hatang.dev/payments/bank-connections/{conn['id']}/confirm",
              headers=HEADERS, json={"otp": "123456"}, timeout=30).raise_for_status()
```

**PHP**

```php
$client->post("/payments/bank-connections/{$conn['id']}/confirm", ['json' => ['otp' => '123456']]);
```

`otp` is 4 to 8 digits. On success the connection is `active` and the `bank_connection.activated` webhook fires. A wrong code counts as an attempt; after 5 wrong attempts the code is dead and you start again.

## Link a BIDV, ACB or Pay2S account

- **BIDV.** Register the balance notification service for the account at BIDV, then `POST` with `provider: "bidv"` and `account_number`. The connection stays `pending_otp` (shown as "Waiting for the bank" in the dashboard) until BIDV sends its first message for the account, which activates it. Calling `confirm` answers `409 invalid_state`.
- **ACB.** `POST` with `provider: "acb"` and `account_number`, then send the account holder to `challenge.authorize_url` within a few minutes. After they consent on ACB's page, ACB sends them back to **Payments settings → Bank accounts** and the account is `active`. The consent covers reading the account's history, lasts up to 180 days and appears in the connection's `consent` field. ACB does not push transfers; Ha Tang reads the history every 10 minutes.
- **Pay2S.** Link the account on Pay2S first. Read `webhook_url` from any Pay2S connection (or from the dashboard form), create a Hook on Pay2S pointing at it, and copy the Hook's token. Then `POST` with `provider: "pay2s"`, `bank_code`, `account_number` (as Pay2S writes it) and `webhook_token`. The connection activates when Pay2S pushes its first transaction for the account. Pay2S reads accounts at `VCB`, `ICB`, `TCB`, `BIDV`, `ACB`, `MB` and `TPB`.

## Statuses

| `status` | Meaning |
|---|---|
| `pending_otp` | Waiting: for the code, for the holder's consent, or for the bank's first message |
| `active` | Receiving transfers |
| `needs_reauth` | The bank wants the account linked again |
| `revoked` | Unlinked |
| `error` | The bank reported a problem with the link |

## Matching settings (`config`) {#config}

Change them with `PATCH`, or in the dashboard under **Payments settings → Bank accounts → Matching and notifications**. Only the fields you send change.

**cURL**

```bash
curl -X PATCH https://api.hatang.dev/payments/bank-connections/bcn_123 \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Main shop",
    "config": {
      "code_prefix": "SHOP",
      "payment_code_pattern": { "prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits" },
      "transaction_events": { "direction": "in", "require_code": true, "min_amount": 10000 }
    }
  }'
```

**Node.js**

```ts
await hatang.payments.bankConnections.update("bcn_123", {
  label: "Main shop",
  config: {
    code_prefix: "SHOP",
    payment_code_pattern: { prefix: "DH", min_length: 4, max_length: 8, charset: "digits" },
    transaction_events: { direction: "in", require_code: true, min_amount: 10_000 },
  },
});
```

**Python**

```python
requests.patch("https://api.hatang.dev/payments/bank-connections/bcn_123", headers=HEADERS, json={
    "label": "Main shop",
    "config": {
        "code_prefix": "SHOP",
        "payment_code_pattern": {"prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits"},
        "transaction_events": {"direction": "in", "require_code": True, "min_amount": 10000},
    },
}, timeout=10).raise_for_status()
```

**PHP**

```php
$client->patch('/payments/bank-connections/bcn_123', ['json' => [
    'label' => 'Main shop',
    'config' => [
        'code_prefix' => 'SHOP',
        'payment_code_pattern' => ['prefix' => 'DH', 'min_length' => 4, 'max_length' => 8, 'charset' => 'digits'],
        'transaction_events' => ['direction' => 'in', 'require_code' => true, 'min_amount' => 10000],
    ],
]]);
```

| Field | Default | Description |
|---|---|---|
| `code_prefix` | `HT` | Prefix of generated payment codes, 2 to 5 capital letters (orders flow) |
| `payment_code_pattern` | `null` | Your own order code format, looked for in every transfer (transfers-only flow). `null` turns it off |
| `payment_code_pattern.prefix` | – | 1 to 10 capital letters or digits |
| `payment_code_pattern.min_length` / `max_length` | `3` / `10` | Length of the part after the prefix, 1 to 30 |
| `payment_code_pattern.charset` | `digits` | `digits` or `alnum` (letters and digits) |
| `transaction_events.direction` | `in` | `in`, or `all` to report money going out too |
| `transaction_events.require_code` | `false` | Only report transfers where a code was found or a payment matched |
| `transaction_events.min_amount` | `0` | Do not report transfers below this amount (VND) |

`transaction_events` only filters `transaction.received`. Payments are always matched and reported.

## Unlink an account

**cURL**

```bash
curl -X POST https://api.hatang.dev/payments/bank-connections/bcn_123/unlink \
  -H "Authorization: Bearer $HATANG_API_KEY"

# Only when the answer has a challenge:
curl https://api.hatang.dev/payments/bank-connections/bcn_123/unlink/confirm \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "otp": "123456" }'
```

**Node.js**

```ts
const { data } = await hatang.payments.bankConnections.unlink("bcn_123");
if (data?.status !== "revoked") await hatang.payments.bankConnections.confirmUnlink("bcn_123", "123456");
```

**Python**

```python
conn = requests.post("https://api.hatang.dev/payments/bank-connections/bcn_123/unlink",
                     headers=HEADERS, timeout=30).json()
if conn.get("challenge"):
    requests.post("https://api.hatang.dev/payments/bank-connections/bcn_123/unlink/confirm",
                  headers=HEADERS, json={"otp": "123456"}, timeout=30).raise_for_status()
```

**PHP**

```php
$conn = json_decode($client->post('/payments/bank-connections/bcn_123/unlink')->getBody(), true);
if (!empty($conn['challenge'])) {
    $client->post('/payments/bank-connections/bcn_123/unlink/confirm', ['json' => ['otp' => '123456']]);
}
```

MB Bank confirms unlinking with a code, like linking. The other banks, and an account still waiting to be linked, are unlinked at once: the answer is already `revoked` and `challenge` is `null`. Unlinking an ACB account also withdraws the consent at ACB. To stop BIDV's or Pay2S's pushes, turn the service off on their side too. Unlinking fires `bank_connection.revoked`.

## Import a statement {#statement-import}

Statement import recovers transfers the bank never pushed, for any bank. Download the statement from your bank, convert it to this CSV, and upload it in the dashboard (**Payments settings → Bank accounts → Import a bank statement**) or through the API.

```csv
reference,booked_at,direction,amount,description,counterparty_account,counterparty_name
FT1789810452930810,2026-09-19T09:34:12Z,in,99000,DH20250919 chuyen tien,0987654321,NGUYEN VAN KHACH
```

**cURL**

```bash
curl https://api.hatang.dev/payments/statements \
  -H "Authorization: Bearer $HATANG_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg csv "$(cat statement.csv)" \
        '{ connection_id: "bcn_123", filename: "statement-september.csv", content: $csv }')"
```

**Node.js**

```ts
import { readFile } from "node:fs/promises";

const { data: job } = await hatang.payments.statements.import({
  connection_id: "bcn_123",
  filename: "statement-september.csv",
  content: await readFile("statement.csv", "utf8"),
});
const { data: progress } = await hatang.payments.statements.get(job.id);
```

**Python**

```python
with open("statement.csv", encoding="utf-8") as f:
    job = requests.post("https://api.hatang.dev/payments/statements", headers=HEADERS, json={
        "connection_id": "bcn_123",
        "filename": "statement-september.csv",
        "content": f.read(),
    }, timeout=30).json()
```

**PHP**

```php
$job = json_decode($client->post('/payments/statements', ['json' => [
    'connection_id' => 'bcn_123',
    'filename' => 'statement-september.csv',
    'content' => file_get_contents('statement.csv'),
]])->getBody(), true);
```

The call answers `202` and the rows are processed in the background. Follow them with `GET /payments/statements/{id}`:

```json
{
  "object": "statement_import",
  "id": "stm_01a0b9042a1c7f0e9d8c7b6a5f4e3d2c",
  "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41",
  "filename": "statement-september.csv",
  "status": "done",
  "rows": 1,
  "processed_rows": 1,
  "error": null,
  "created_at": "2026-09-20T02:10:00.000Z",
  "completed_at": "2026-09-20T02:10:01.250Z"
}
```

| Rule | Detail |
|---|---|
| Columns | `reference`, `booked_at`, `direction`, `amount`, `description` are required; `counterparty_account` and `counterparty_name` are optional. The header row names them, in any order |
| `direction` | `in`, `out`, `c`, `d`, `credit` or `debit` |
| `amount` | A positive whole number of VND; thousands separators (`99.000`, `99,000`) are accepted |
| `booked_at` | An ISO 8601 date and time. A value that cannot be read becomes the import time |
| Size | `content` up to 5,000,000 characters |
| Duplicates | A transfer Ha Tang already has (same reference and amount on the account) is skipped |
| `status` | `queued`, `processing`, `done` or `failed` (with `error`) |

Imported transfers go through the same matching as pushed ones: a transfer that carries a payment code settles that payment and fires the usual events.

A missing column or a bad row is refused up front with `422 validation_error`, for example `Statement is missing the "amount" column` or `Statement row 3 is invalid`. Nothing is stored in that case.

## Errors

| Error | When |
|---|---|
| `403 environment_mismatch` | A real bank in development, or the development bank in production |
| `422 missing_required_field` | MB Bank without `account_number`, `account_name` and `holder_id`; BIDV or Pay2S without `account_number`; Pay2S without `webhook_token` |
| `422 validation_error` | Unknown `provider`, a malformed account number, `bank_code` for a bank that does not take one, or a `bank_code` Pay2S does not read |
| `422 bank_link_failed` | The bank refused: unknown account, name or ID that does not match, account linked to another business, or the holder declined consent |
| `422 invalid_otp` | Wrong code |
| `410 otp_expired` | The code expired; start linking again |
| `409 invalid_state` | The account is already linked, already being linked, already unlinked, or does not confirm with a code |
| `503 provider_unavailable` | The bank did not answer; try again later |

## Next steps

- [Receive transfers without orders](https://hatang.dev/docs/guides/payments/transactions.md) with `payment_code_pattern`.
- [Create a payment](https://hatang.dev/docs/guides/payments/create-payment.md) into the linked account.
- [Webhooks](https://hatang.dev/docs/guides/payments/webhooks.md): `bank_connection.*` events.

Source: https://hatang.dev/docs/guides/payments/bank-connections
