E-invoices
Ha Tang issues e-invoices (hóa đơn điện tử) under your own tax code, through your own account at the e-invoice provider. Add an invoice block to a payment and the invoice is created once the order is paid; or create one yourself for any payment or bank transfer. This page covers connecting the provider, the invoice lifecycle, fixing failures, and reconciling money in against invoices.
Before you start
- A Mắt Bão e-invoice account with API access. The tax code, API user and password are under "Cấu hình API" in the Mắt Bão portal.
- At least one registered template and series (mẫu số and ký hiệu) on that account.
- An API key with
payments:write. E-invoices use the Payments scopes and environments.
In development, connect Mắt Bão's demo sandbox account: invoices are real sandbox invoices but never reach the tax office.
1. Connect the provider
Do it once per environment, in the dashboard (Payments settings → Invoice providers) or through the API. First check the credentials and list the account's templates; nothing is saved:
- cURL
- Node.js
- Python
- PHP
curl https://api.hatang.dev/einvoices/connections/check \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "provider": "matbao", "tax_code": "0109876543", "username": "api-user", "password": "..." }'
const { data: check, error } = await hatang.einvoices.connections.check({
provider: "matbao",
tax_code: "0109876543",
username: "api-user",
password: process.env.MATBAO_PASSWORD!,
});
check = requests.post("https://api.hatang.dev/einvoices/connections/check", headers=HEADERS, json={
"provider": "matbao", "tax_code": "0109876543", "username": "api-user", "password": os.environ["MATBAO_PASSWORD"],
}, timeout=30).json()
$check = json_decode($client->post('/einvoices/connections/check', ['json' => [
'provider' => 'matbao', 'tax_code' => '0109876543', 'username' => 'api-user', 'password' => getenv('MATBAO_PASSWORD'),
]])->getBody(), true);
{
"object": "einvoice_connection_check",
"ok": true,
"provider": "matbao",
"seller_tax_code": "0109876543",
"seller_name": "CONG TY TNHH VI DU",
"templates": [
{ "pattern": "1", "serial": "C26TAT", "name": "Hóa đơn giá trị gia tăng", "remaining": 4980 }
]
}
Then connect with the template you chose. The body:
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
provider | string | Yes | – | matbao (GET /einvoices/providers lists them) |
tax_code, username, password | string | Yes for matbao | – | The seller's tax code and the provider API account; stored encrypted |
pattern | string, 1 to 6 | Yes | – | Default template for new invoices; must be one of the account's |
serial | string, exactly 6 | Yes | – | Default series, e.g. C26TAT |
default_mode | draft | publish | No | draft | What happens when a paid order asks for an invoice without mode: draft stops at the draft, publish also sends it for approval |
is_default | boolean | No | true for the first provider | New invoices go to the default provider |
import_external | boolean | No | false | Also bring in invoices created directly on the provider's site |
label | string, up to 100 | No | – | Your name for the connection |
OpenAPI schema: POST /einvoices/connections
curl https://api.hatang.dev/einvoices/connections \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "provider": "matbao", "tax_code": "0109876543", "username": "api-user", "password": "...",
"pattern": "1", "serial": "C26TAT", "default_mode": "publish" }'
The answer (201) is the connection, with a callback_url. Send that URL to Mắt Bão support so that changes made on their side reach Ha Tang at once; without it, Ha Tang checks an invoice still pending an hour after it was created every 15 minutes, for up to 7 days. A draft you keep on purpose (mode: "draft") is not checked: call POST /einvoices/{id}/sync to bring in edits made on the provider's site. Credentials are stored encrypted. If the provider starts refusing them, the connection becomes needs_reauth and invoices pause until you save new ones with PATCH /einvoices/connections/{id}.
| Endpoint | Does |
|---|---|
GET /einvoices/providers | Providers you can connect, with the fields each needs |
GET /einvoices/connections / GET /einvoices/connections/{id} | Your connections |
PATCH /einvoices/connections/{id} | Change the default mode, template, credentials, label |
POST /einvoices/connections/{id}/default | Make it the default provider |
GET /einvoices/connections/{id}/templates | The templates the account may issue on, read live |
POST /einvoices/connections/{id}/sync | Pull changes from the provider now |
DELETE /einvoices/connections/{id} | Unlink. Its invoices stay; the next provider becomes the default |
2. Add an invoice to a payment
Add an invoice block when you create the payment. Nothing is sent to the provider until the payment is paid or overpaid.
- cURL
- Node.js
- Python
- PHP
curl https://api.hatang.dev/payments \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Idempotency-Key: order-1002" \
-H "Content-Type: application/json" \
-d '{
"amount": 275000,
"description": "Order #1002",
"ipn_url": "https://shop.example.com/hatang/ipn",
"metadata": { "order_id": "1002" },
"invoice": {
"mode": "publish",
"template": { "pattern": "1", "serial": "C26TAT" },
"buyer": {
"name": "Nguyen Van Khach",
"legal_name": "Cong ty TNHH Khach Hang",
"tax_code": "0109876543",
"address": "1 Trang Tien, Ha Noi",
"email": "[email protected]"
},
"items": [{ "name": "September service plan", "unit": "plan", "quantity": 1, "unit_price": 250000, "vat_rate": 10 }],
"payment_method": "CK",
"send_email": true
}
}'
const { data: payment, error } = await hatang.payments.createOrder({
order_id: "1002",
amount: 275_000,
description: "Order #1002",
ipn_url: "https://shop.example.com/hatang/ipn",
invoice: {
mode: "publish",
template: { pattern: "1", serial: "C26TAT" },
buyer: { name: "Nguyen Van Khach", legal_name: "Cong ty TNHH Khach Hang", tax_code: "0109876543", address: "1 Trang Tien, Ha Noi", email: "[email protected]" },
items: [{ name: "September service plan", unit: "plan", quantity: 1, unit_price: 250_000, vat_rate: 10 }],
payment_method: "CK",
send_email: true,
},
});
payment = hatang.payments.create({
"amount": 275000,
"description": "Order #1002",
"ipn_url": "https://shop.example.com/hatang/ipn",
"metadata": {"order_id": "1002"},
"invoice": {
"mode": "publish",
"template": {"pattern": "1", "serial": "C26TAT"},
"buyer": {"name": "Nguyen Van Khach", "legal_name": "Cong ty TNHH Khach Hang", "tax_code": "0109876543",
"items": [{"name": "September service plan", "unit": "plan", "quantity": 1, "unit_price": 250000, "vat_rate": 10}],
"payment_method": "CK",
"send_email": True,
},
}, idempotency_key="order-1002")
$payment = json_decode($client->post('/payments', [
'headers' => ['Idempotency-Key' => 'order-1002'],
'json' => [
'amount' => 275000,
'description' => 'Order #1002',
'ipn_url' => 'https://shop.example.com/hatang/ipn',
'metadata' => ['order_id' => '1002'],
'invoice' => [
'mode' => 'publish',
'template' => ['pattern' => '1', 'serial' => 'C26TAT'],
'buyer' => ['name' => 'Nguyen Van Khach', 'legal_name' => 'Cong ty TNHH Khach Hang', 'tax_code' => '0109876543',
'items' => [['name' => 'September service plan', 'unit' => 'plan', 'quantity' => 1, 'unit_price' => 250000, 'vat_rate' => 10]],
'payment_method' => 'CK',
'send_email' => true,
],
],
])->getBody(), true);
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
mode | draft | publish | No | the connection's default_mode | draft stops at the draft, which you review and send with POST /einvoices/{id}/publish; publish creates the draft and sends it for approval (signing) |
template | object { pattern, serial } | No | the connection's | pattern: mẫu số, 1 to 6 characters; serial: ký hiệu, exactly 6 |
buyer | buyer | Yes | – | Needs a name or a legal_name |
items | array of line items, 1 to 200 | Yes | – | The lines on the invoice |
payment_method | string, up to 50 | No | TM/CK | How it was paid, printed on the invoice |
note | string, up to 500 | No | – | A note, printed on the invoice |
send_email | boolean | No | false | Email the issued invoice to the buyer (at buyer.email) |
OpenAPI schema: POST /payments, POST /einvoices
Make the invoice total (after VAT) equal the payment amount: here 250,000 + 10% = 275,000. Ha Tang does not check it. Each line is rounded to the đồng on its own; when your prices include VAT, give each line's vat_amount so the total lands on the đồng. See Totals and VAT rounding.
3. What happens after payment
Every invoice takes the same two steps, create the draft and send it for approval, each queued and retried on its own:
invoice.status | Meaning |
|---|---|
waiting_for_payment | Not paid yet; nothing sent (payment summary only) |
creating | Paid; the draft is being created at the provider |
draft | The draft exists. Stops here in draft mode |
publishing | Sent for approval (being signed) |
issued | Signed: it has a number and a lookup_code; waiting for the tax office |
tax_accepted / tax_rejected | The tax office's answer (tax_authority_code when accepted) |
failed | The provider refused a step: error says why and failed_step which step |
canceled / replaced / adjusted | Changed afterwards, on the provider's site or by a replacement |
deleted | A draft that was deleted |
The payment's invoice field (its fields) always shows where its invoice stands, and every change is sent as an IPN call and as invoice.* webhooks:
"invoice": {
"requested": true,
"mode": "publish",
"status": "issued",
"id": "einv_01a0b90429f678e68ed7de882720a2b4",
"number": "8003",
"series": "1C26TAT",
"template": { "pattern": "1", "serial": "C26TAT" },
"lookup_code": "HTXAEM9P3AYXSM",
"tax_authority_code": null,
"pdf_url": "https://…",
"sync_state": "in_sync",
"issued_at": "2026-09-18T17:00:00.000Z",
"error": null,
"failed_step": null
}
If the payment is paid while no provider is connected in that environment, invoice.status is failed with id: null and the error "No e-invoice provider is connected in this environment".
The checkout page shows the payer only whether an invoice is coming and, once issued, its number and lookup code.
Review drafts before they are signed
With mode: "draft", the invoice stops at draft. You can then:
| Call | Does |
|---|---|
PATCH /einvoices/{id} | Edit buyer, items, payment_method, note |
POST /einvoices/{id}/publish | Send it for approval |
DELETE /einvoices/{id} | Delete the draft |
Only a draft can be edited or sent for approval; anything else answers 409 invalid_state. Even mode: "publish" never issues in one call: approval is always a separate, queued step.
When a step fails
Timeouts and provider outages are retried automatically. A refusal (for example an invalid buyer tax code) stops the invoice at failed with the provider's message:
- Fix what the message says. When it failed while creating the draft (
failed_step: "create_draft"), correct the buyer or the items withPATCH /einvoices/{id}. POST /einvoices/{id}/retryruns the same step again.GET /einvoices/{id}/historylists every status change, oldest first, with which side made it (source) and when.
{
"object": "list",
"data": [
{ "from": null, "to": "creating", "source": "hatang", "detail": { "mode": "publish", "template": "1/C26TAT" }, "at": "2026-09-18T16:59:58.120Z" },
{ "from": "draft", "to": "publishing", "source": "hatang", "detail": null, "at": "2026-09-18T16:59:59.010Z" }
]
}
Invoices without a payment
POST /einvoices takes the same fields as the invoice block, plus:
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
payment_id | string pay_… | No | – | Link it to a payment. A payment has at most one invoice; a second answers 409 invalid_state |
transaction_id | string txn_… | No | – | Link it to a bank transfer received without an order |
connection_id | string icn_… | No | the default connection | The provider connection that issues it |
OpenAPI schema: POST /einvoices
- cURL
- Node.js
- Python
- PHP
curl https://api.hatang.dev/einvoices \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mode": "publish",
"transaction_id": "txn_123",
"buyer": { "name": "Nguyen Van Khach" },
"items": [{ "name": "Coffee beans 1 kg", "quantity": 1, "unit_price": 91667, "vat_rate": 8 }]
}'
const { data: invoice, error } = await hatang.einvoices.create({
mode: "publish",
transaction_id: "txn_123",
buyer: { name: "Nguyen Van Khach" },
items: [{ name: "Coffee beans 1 kg", quantity: 1, unit_price: 91_667, vat_rate: 8 }],
});
invoice = requests.post("https://api.hatang.dev/einvoices", headers=HEADERS, json={
"mode": "publish",
"transaction_id": "txn_123",
"buyer": {"name": "Nguyen Van Khach"},
"items": [{"name": "Coffee beans 1 kg", "quantity": 1, "unit_price": 91667, "vat_rate": 8}],
}, timeout=10).json()
$invoice = json_decode($client->post('/einvoices', ['json' => [
'mode' => 'publish',
'transaction_id' => 'txn_123',
'buyer' => ['name' => 'Nguyen Van Khach'],
'items' => [['name' => 'Coffee beans 1 kg', 'quantity' => 1, 'unit_price' => 91667, 'vat_rate' => 8]],
]])->getBody(), true);
The answer (201) is the invoice object (object: "einvoice") in creating, with buyer, items and the computed totals (subtotal, vat, total). Follow it with GET /einvoices/{id} or the invoice.* webhooks. GET /einvoices lists invoices, filtered by status, payment_id or sync_state.
Replace, cancel and conflicts
- Replace. An invoice sent to the tax office (
issued,tax_accepted,tax_rejected) cannot be canceled or deleted through Ha Tang.POST /einvoices/{id}/replacewith areason, the correctedbuyeranditemscreates a replacement: a draft that names the original, then sent for approval. The original becomesreplacedonce the provider has the replacement. - Delete a draft (or a failed invoice) with
DELETE /einvoices/{id}. - Changes on the provider's site come back to Ha Tang: signing, the tax office's code, cancellation, replacement, draft edits and deletions. Their events carry
source: "provider". Invoices created directly on the provider's site are brought in only when the connection hasimport_external: true. - Conflicts. A draft edited both in Ha Tang and on the provider's site gets
sync_state: "conflict"and theinvoice.conflictevent. Settle it withPOST /einvoices/{id}/resolveand{ "keep": "hatang" }or{ "keep": "provider" }. Editing or publishing is refused until then. - Check now.
POST /einvoices/{id}/syncasks the provider about one invoice right away.
Reconcile money in against invoices
Every transfer into your accounts should end up with an invoice, unless it needs none. A transfer counts as invoiced when an invoice in force (not failed, deleted, canceled or tax-rejected) points at it, directly or through the payment it paid. Otherwise it is missing, unless you marked it exempt.
curl "https://api.hatang.dev/einvoices/reconciliation?from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $HATANG_API_KEY"
from and to are days in Vietnam time (YYYY-MM-DD), at most 400 days apart; the default is the last 30 days. The answer has totals (money_in, invoiced, missing, exempt, each with count and amount), one row per day, and the missing and exempt transfers. The dashboard shows the same report under Payments → Invoice reconciliation.
Mark a transfer as needing no invoice (a refund, money between your own accounts), or undo it:
curl https://api.hatang.dev/payments/transactions/txn_123/invoice-exempt \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Transfer between our own accounts" }'
curl -X DELETE https://api.hatang.dev/payments/transactions/txn_123/invoice-exempt \
-H "Authorization: Bearer $HATANG_API_KEY"
Owners and admins of a business with an active provider also get a daily email at 08:00 (Vietnam time) listing yesterday's money in that still has no invoice, and an email when an invoice fails, is rejected by the tax office, is in conflict, or has not reached the tax office an hour after it was created. A draft you keep on purpose (mode: "draft") is not mailed about.
Objects and how they link
Three objects take part when an order is paid and invoiced: the payment you create, the transaction the bank reports, and the e-invoice. Each points at the others by id, so you can start from whichever one an event hands you.
| Keep in your order | Comes from | Use it to |
|---|---|---|
Payment id (pay_…) | The POST /payments answer | GET /payments/{id}; it is data.id in every IPN call |
Invoice id (einv_…) | The payment's invoice.id once the invoice exists (null before the payment is paid), or the POST /einvoices answer | GET, PATCH, publish, retry, replace on /einvoices/{id}; it is data.id in invoice.* webhooks |
number, series, lookup_code | The invoice, once issued | Show the buyer their invoice and where to look it up |
reference | The invoice, from the start | Ha Tang's reference for it at the provider; find the invoice on the provider's site by it |
- Through a payment. Put an
invoiceblock inPOST /payments, or callPOST /einvoiceswithpayment_id. A payment has one invoice in force; a replacement keeps the samepayment_id, and the payment'sinvoicefield shows the one that currently stands.GET /einvoices?payment_id=pay_…lists all of them. - Through a transfer, without a payment. If you only receive
transaction.receivedwebhooks, callPOST /einvoiceswith that transfer'stransaction_id. - Events. An IPN call carries the payment, with the invoice's state in
data.invoice. Aninvoice.*webhook carries the whole invoice object, plussource(hatang, orproviderwhen the change was made on the provider's site).
The invoice request
The same object, with the same rules, is what you send in four places:
| Where | What it takes |
|---|---|
invoice in POST /payments | Everything below |
POST /einvoices | Everything below, plus payment_id, transaction_id, connection_id |
PATCH /einvoices/{id} (a draft) | buyer, items, payment_method, note; what you send replaces what was there; all optional, send only what changes |
POST /einvoices/{id}/replace | reason, buyer, items, payment_method, note |
A complete request, a company buyer with two lines (one with its VAT typed in, one not subject to VAT):
{
"mode": "publish",
"template": { "pattern": "1", "serial": "C26TAT" },
"buyer": {
"name": "Nguyen Van Khach",
"legal_name": "Cong ty TNHH Khach Hang",
"tax_code": "0109876543",
"address": "1 Trang Tien, Ha Noi",
"phone": "0901234567",
"buyer_code": "KH-0042"
},
"items": [
{ "name": "Coffee 1 kg", "code": "CF-1KG", "unit": "kg", "quantity": 1, "unit_price": 45455, "vat_rate": 10, "vat_amount": 4545 },
{ "name": "Delivery", "unit": "order", "quantity": 1, "unit_price": 20000, "vat_rate": -1 }
],
"payment_method": "CK",
"note": "Order #1002",
"send_email": true
}
Its totals: subtotal 65,455, VAT 4,545, total 70,000.
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
mode | draft | publish | No | the connection's default_mode | draft stops at the draft, which you review and send with POST /einvoices/{id}/publish; publish creates the draft and sends it for approval (signing) |
template | object { pattern, serial } | No | the connection's | pattern: mẫu số, 1 to 6 characters; serial: ký hiệu, exactly 6 |
buyer | buyer | Yes | – | Needs a name or a legal_name |
items | array of line items, 1 to 200 | Yes | – | The lines on the invoice |
payment_method | string, up to 50 | No | TM/CK | How it was paid, printed on the invoice |
note | string, up to 500 | No | – | A note, printed on the invoice |
send_email | boolean | No | false | Email the issued invoice to the buyer (at buyer.email) |
OpenAPI schema: POST /payments, POST /einvoices
Buyer
Give name, legal_name or both. For a company, legal_name is the company and name the person who bought; for a person, name alone.
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
name | string, up to 200 | One of the two | – | The person buying, printed as "Họ tên người mua hàng" (buyer's name) |
legal_name | string, up to 400 | One of the two | – | The company's registered name, printed as "Tên đơn vị" (company name). Leave it out for a person |
tax_code | 10 to 14 digits or dashes | No | – | The buyer's tax code (mã số thuế), e.g. 0109876543 or 0109876543-001 |
address | string, up to 400 | No | – | The buyer's address |
email | No | – | Where send_email sends the invoice | |
phone | string, up to 20 | No | – | The buyer's phone number |
buyer_code | string, up to 50 | No | – | Your own customer code, printed on the invoice |
OpenAPI schema: POST /payments, POST /einvoices
Line item
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
name | string, 1 to 500 | Yes | – | What was sold |
quantity | number above 0 | Yes | – | Decimals allowed, e.g. 1.5 kg |
unit_price | number, 0 or more | Yes | – | Price of one unit before VAT, in đồng. Decimals allowed |
vat_rate | 10 | 8 | 5 | 0 | -1 | -2 | No | 10 | -1: not subject to VAT (KCT); -2: not declared (KKKNT) |
vat_amount | whole đồng, 0 or more | No | worked out from vat_rate | The line's VAT, used as given. Only with a vat_rate above 0. See Totals and VAT rounding |
discount | number, 0 or more | No | 0 | Discount for the whole line, in đồng before VAT |
unit | string, up to 50 | No | – | Unit, e.g. kg, plan |
code | string, up to 50 | No | – | Your product code (SKU) |
OpenAPI schema: POST /payments, POST /einvoices
In the invoice object every line comes back with what you sent plus three amounts: amount (before VAT), vat_amount and total.
Totals and VAT rounding
The đồng has no smaller unit, so every line is rounded on its own, and the invoice adds up the rounded lines. That is also what the provider prints.
| Amount | Worked out as |
|---|---|
Line amount | quantity × unit_price − discount, rounded to the đồng |
Line vat_amount | vat_amount when you give it; otherwise amount × vat_rate / 100, rounded; 0 when vat_rate is 0, -1 or -2 |
Line total | amount + vat_amount |
totals.subtotal / totals.vat / totals.total | The sum of the lines' amount / vat_amount / total |
Halves round up. Ha Tang does not compare the invoice total with the payment amount: make them equal yourself.
When prices include VAT. Shops usually know what the buyer pays, VAT included, and work the price before VAT back from it. Rounding that price and then the VAT can land a đồng off. 50,000 đồng at 10%:
unit_price | vat_amount | total | |
|---|---|---|---|
| VAT worked out by Ha Tang | 45,455 | 4,546 (45,455 × 10%, rounded) | 50,001 |
VAT given as vat_amount | 45,455 | 4,545 | 50,000 |
So give each line's vat_amount as what is left of the price paid once the price before VAT is rounded:
// gross: what the buyer pays for the whole line, VAT included, in đồng.
function lineFromGross(name: string, quantity: number, gross: number, vatRate: 10 | 8 | 5) {
const unit_price = Math.round(gross / quantity / (1 + vatRate / 100));
const amount = Math.round(quantity * unit_price);
return { name, quantity, unit_price, vat_rate: vatRate, vat_amount: gross - amount };
}
lineFromGross("Coffee 1 kg", 1, 50_000, 10);
// { name: "Coffee 1 kg", quantity: 1, unit_price: 45455, vat_rate: 10, vat_amount: 4545 }
Rules for vat_amount:
- Whole đồng, 0 or more.
4545.5is refused with422 validation_error; nothing is rounded for you. - Only on a line whose
vat_rateis above 0; on a0,-1or-2line it is refused with422 validation_error. - Ha Tang does not check it against
vat_rate; keep it to the rounding difference you need. - It is sent to the provider as the line's VAT, and kept when the invoice is read back from the provider, so a later sync or an edit on the provider's site does not turn it into a conflict.
- In the dashboard, the VAT amount field on New invoice takes the same value, written
4545,4.545or4,545.
The einvoice object
What POST /einvoices and GET /einvoices/{id} answer, and the data of every invoice.* webhook. The invoice from the request above (first line only), paid and issued:
{
"object": "einvoice",
"id": "einv_01a178887ba6771caef28863e6f55879",
"environment": "production",
"connection_id": "icn_01a178887b357a9c8bc4ad098f6b8855",
"provider": "matbao",
"payment_id": "pay_01a178887b59783cb5f4f18f06071ed1",
"transaction_id": null,
"origin": "hatang",
"mode": "publish",
"status": "issued",
"sync_state": "in_sync",
"version": 1,
"buyer": {
"name": "Nguyen Van Khach",
"legal_name": "Cong ty TNHH Khach Hang",
"tax_code": "0109876543",
"address": "1 Trang Tien, Ha Noi",
},
"items": [
{ "name": "Coffee 1 kg", "unit": "kg", "quantity": 1, "unit_price": 45455, "vat_rate": 10, "amount": 45455, "vat_amount": 4545, "total": 50000 }
],
"totals": { "subtotal": 45455, "vat": 4545, "total": 50000 },
"currency": "VND",
"payment_method": "CK",
"note": null,
"template": { "pattern": "1", "serial": "C26TAT" },
"reference": "HT45MSKX4UY3AA",
"provider_invoice_id": "4/AUx/syzogcoBsIReMUk6…",
"number": "8001",
"series": "1C26TAT",
"lookup_code": "HT45MSKX4UY3AA",
"tax_authority_code": null,
"pdf_url": "https://…",
"issued_at": "2026-10-25T17:00:00.000Z",
"related_invoice_id": null,
"relation": null,
"conflict": null,
"error": null,
"failed_step": null,
"created_at": "2026-10-26T14:06:12.895Z",
"updated_at": "2026-10-26T14:06:13.004Z"
}
| Field | Type | Can be null | Meaning |
|---|---|---|---|
object | string | No | Always einvoice |
id | string | No | einv_… |
environment | development | production | No | The environment of the API key that created it |
connection_id | string | No | The provider connection that issues it (icn_…) |
provider | string | Yes | The provider, e.g. matbao |
payment_id | string | Yes | The payment it is for |
transaction_id | string | Yes | The bank transfer it is for, when there is no payment |
origin | hatang | provider | No | hatang when created through Ha Tang; provider when brought in from the provider's site (import_external) |
mode | draft | publish | No | As requested |
status | string | No | Where it stands: see What happens after payment |
sync_state | in_sync | pending_push | conflict | No | pending_push while a change is on its way to the provider; conflict when the draft was edited on both sides |
version | integer | No | Goes up by one with every change made through Ha Tang |
buyer | buyer | No | As sent; only the fields you sent |
items | array of line items | No | As sent, each line with its computed amount, vat_amount and total |
totals | object { subtotal, vat, total } | No | In đồng |
currency | string | No | VND |
payment_method, note | string | Yes | As sent |
template | object { pattern, serial } | No | The template and series it is issued on |
reference | string, up to 20 | No | Ha Tang's reference for it at the provider |
provider_invoice_id | string | Yes | The provider's own id, once the draft exists |
number | string | Yes | The invoice number, once issued |
series | string | Yes | Template and series together, e.g. 1C26TAT |
lookup_code | string | Yes | What the buyer types on the provider's lookup page |
tax_authority_code | string | Yes | The tax office's code, once tax_accepted |
pdf_url | URL | Yes | The provider's PDF, once there is one |
issued_at | ISO 8601 string | Yes | The date on the invoice |
related_invoice_id | string | Yes | On a replacement: the invoice it replaces |
relation | replaces | adjusts | Yes | On a replacement (replaces) or an adjustment (adjusts) |
conflict | object | Yes | With sync_state: "conflict": the provider's version, under content |
error | string | Yes | Why it failed |
failed_step | create_draft | update_draft | publish | delete_draft | Yes | The step that failed |
created_at, updated_at | ISO 8601 string | No |
OpenAPI schema: GET /einvoices/{id}
The payment's invoice summary
The payment object keeps a short copy of its invoice in invoice, so an IPN handler sees the invoice without another call. It is null when no invoice was asked for and none exists.
| Field | Type | Can be null | Meaning |
|---|---|---|---|
requested | boolean | No | true when the payment was created with an invoice block |
mode | draft | publish | Yes | As requested |
status | string | No | As on the invoice, plus waiting_for_payment before the payment is paid. failed with id: null when it was paid while no provider was connected |
id | string | Yes | The invoice's einv_… id, null until it exists |
number, series, template, lookup_code, tax_authority_code, pdf_url, sync_state, issued_at, error, failed_step | as on the invoice | Yes | As on the invoice; null until the invoice exists |
OpenAPI schema: GET /payments/{id}
To read the lines and totals, fetch the invoice: GET /einvoices/{invoice.id}.
Errors
| Error | When |
|---|---|
422 validation_error | A field is wrong (buyer without a name, bad tax code, more than 200 items), or the template is not registered on the account |
422 missing_required_field | A provider credential is missing |
422 invoice_provider_error | The provider refused the request; the message says why |
409 invalid_state | No provider connected, the payment already has an invoice, the invoice is not in a state that allows this call, or a conflict must be resolved first |
403 environment_mismatch | The provider is not available in this environment |
503 provider_unavailable | The provider did not answer; try again |
Next steps
- Receive payment results: the IPN calls that carry the invoice.
- Webhooks: every
invoice.*event. - Receive transfers without orders.