Skip to main content

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​

MethodEndpointDoes
GET/payments/bank-connectionsList the linked accounts (unlinked ones are left out)
POST/payments/bank-connectionsStart 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}/confirmConfirm linking with the bank's code
POST/payments/bank-connections/{id}/unlinkStart unlinking
POST/payments/bank-connections/{id}/unlink/confirmConfirm unlinking with the bank's code
POST/payments/statementsImport a statement (CSV)
GET/payments/statements/{id}Statement import progress

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

Bankproviderlink_modeWhat you do
MB BankmbbankotpSend the account details; MB texts a code to the phone registered for the account; confirm with it
BIDVbidvbank_registeredRegister 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
ACBacbredirectSend 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
Pay2Spay2sbank_registeredLink 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 banksimulatorotpConfirm with the code 000000

1. Start linking​

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"
}'
FieldDescription
providerRequired. mbbank, bidv, acb or pay2s in production; simulator in development
account_numberRequired 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_nameThe holder's name exactly as the bank has it. Required for MB Bank
account_typepersonal (default) or business
holder_idNational ID (personal) or tax code (business) the account was opened with. Required for MB Bank; stored encrypted
bank_codeFor 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_tokenPay2S only, and required there: the token of the Hook you created on Pay2S
labelYour name for the account, up to 100 characters
configMatching settings; see Matching settings

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

{
"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 https://api.hatang.dev/payments/bank-connections/bcn_123/confirm \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "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.

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

statusMeaning
pending_otpWaiting: for the code, for the holder's consent, or for the bank's first message
activeReceiving transfers
needs_reauthThe bank wants the account linked again
revokedUnlinked
errorThe bank reported a problem with the link

Matching settings (config)​

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

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 }
}
}'
FieldDefaultDescription
code_prefixHTPrefix of generated payment codes, 2 to 5 capital letters (orders flow)
payment_code_patternnullYour 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_length3 / 10Length of the part after the prefix, 1 to 30
payment_code_pattern.charsetdigitsdigits or alnum (letters and digits)
transaction_events.directioninin, or all to report money going out too
transaction_events.require_codefalseOnly report transfers where a code was found or a payment matched
transaction_events.min_amount0Do not report transfers below this amount (VND)

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

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

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

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

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

{
"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"
}
RuleDetail
Columnsreference, booked_at, direction, amount, description are required; counterparty_account and counterparty_name are optional. The header row names them, in any order
directionin, out, c, d, credit or debit
amountA positive whole number of VND; thousands separators (99.000, 99,000) are accepted
booked_atAn ISO 8601 date and time. A value that cannot be read becomes the import time
Sizecontent up to 5,000,000 characters
DuplicatesA transfer Ha Tang already has (same reference and amount on the account) is skipped
statusqueued, 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​

ErrorWhen
403 environment_mismatchA real bank in development, or the development bank in production
422 missing_required_fieldMB Bank without account_number, account_name and holder_id; BIDV or Pay2S without account_number; Pay2S without webhook_token
422 validation_errorUnknown 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_failedThe bank refused: unknown account, name or ID that does not match, account linked to another business, or the holder declined consent
422 invalid_otpWrong code
410 otp_expiredThe code expired; start linking again
409 invalid_stateThe account is already linked, already being linked, already unlinked, or does not confirm with a code
503 provider_unavailableThe bank did not answer; try again later

Next steps​

View this page as Markdown