Skip to main content

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 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": "..." }'
{
"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:

FieldTypeRequiredDefaultMeaning
providerstringYes–matbao (GET /einvoices/providers lists them)
tax_code, username, passwordstringYes for matbao–The seller's tax code and the provider API account; stored encrypted
patternstring, 1 to 6Yes–Default template for new invoices; must be one of the account's
serialstring, exactly 6Yes–Default series, e.g. C26TAT
default_modedraft | publishNodraftWhat happens when a paid order asks for an invoice without mode: draft stops at the draft, publish also sends it for approval
is_defaultbooleanNotrue for the first providerNew invoices go to the default provider
import_externalbooleanNofalseAlso bring in invoices created directly on the provider's site
labelstring, up to 100No–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}.

EndpointDoes
GET /einvoices/providersProviders 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}/defaultMake it the default provider
GET /einvoices/connections/{id}/templatesThe templates the account may issue on, read live
POST /einvoices/connections/{id}/syncPull 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 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
}
}'
FieldTypeRequiredDefaultMeaning
modedraft | publishNothe connection's default_modedraft stops at the draft, which you review and send with POST /einvoices/{id}/publish; publish creates the draft and sends it for approval (signing)
templateobject { pattern, serial }Nothe connection'spattern: mẫu số, 1 to 6 characters; serial: ký hiệu, exactly 6
buyerbuyerYes–Needs a name or a legal_name
itemsarray of line items, 1 to 200Yes–The lines on the invoice
payment_methodstring, up to 50NoTM/CKHow it was paid, printed on the invoice
notestring, up to 500No–A note, printed on the invoice
send_emailbooleanNofalseEmail the issued invoice to the buyer (at buyer.email)

OpenAPI schema: POST /payments, POST /einvoices

Totals

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.statusMeaning
waiting_for_paymentNot paid yet; nothing sent (payment summary only)
creatingPaid; the draft is being created at the provider
draftThe draft exists. Stops here in draft mode
publishingSent for approval (being signed)
issuedSigned: it has a number and a lookup_code; waiting for the tax office
tax_accepted / tax_rejectedThe tax office's answer (tax_authority_code when accepted)
failedThe provider refused a step: error says why and failed_step which step
canceled / replaced / adjustedChanged afterwards, on the provider's site or by a replacement
deletedA 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:

CallDoes
PATCH /einvoices/{id}Edit buyer, items, payment_method, note
POST /einvoices/{id}/publishSend 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:

  1. Fix what the message says. When it failed while creating the draft (failed_step: "create_draft"), correct the buyer or the items with PATCH /einvoices/{id}.
  2. POST /einvoices/{id}/retry runs the same step again.
  3. GET /einvoices/{id}/history lists 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:

FieldTypeRequiredDefaultMeaning
payment_idstring pay_…No–Link it to a payment. A payment has at most one invoice; a second answers 409 invalid_state
transaction_idstring txn_…No–Link it to a bank transfer received without an order
connection_idstring icn_…Nothe default connectionThe provider connection that issues it

OpenAPI schema: POST /einvoices

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

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}/replace with a reason, the corrected buyer and items creates a replacement: a draft that names the original, then sent for approval. The original becomes replaced once 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 has import_external: true.
  • Conflicts. A draft edited both in Ha Tang and on the provider's site gets sync_state: "conflict" and the invoice.conflict event. Settle it with POST /einvoices/{id}/resolve and { "keep": "hatang" } or { "keep": "provider" }. Editing or publishing is refused until then.
  • Check now. POST /einvoices/{id}/sync asks 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 orderComes fromUse it to
Payment id (pay_…)The POST /payments answerGET /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 answerGET, PATCH, publish, retry, replace on /einvoices/{id}; it is data.id in invoice.* webhooks
number, series, lookup_codeThe invoice, once issuedShow the buyer their invoice and where to look it up
referenceThe invoice, from the startHa Tang's reference for it at the provider; find the invoice on the provider's site by it
  • Through a payment. Put an invoice block in POST /payments, or call POST /einvoices with payment_id. A payment has one invoice in force; a replacement keeps the same payment_id, and the payment's invoice field 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.received webhooks, call POST /einvoices with that transfer's transaction_id.
  • Events. An IPN call carries the payment, with the invoice's state in data.invoice. An invoice.* webhook carries the whole invoice object, plus source (hatang, or provider when 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:

WhereWhat it takes
invoice in POST /paymentsEverything below
POST /einvoicesEverything 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}/replacereason, 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",
"email": "[email protected]",
"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.

FieldTypeRequiredDefaultMeaning
modedraft | publishNothe connection's default_modedraft stops at the draft, which you review and send with POST /einvoices/{id}/publish; publish creates the draft and sends it for approval (signing)
templateobject { pattern, serial }Nothe connection'spattern: mẫu số, 1 to 6 characters; serial: ký hiệu, exactly 6
buyerbuyerYes–Needs a name or a legal_name
itemsarray of line items, 1 to 200Yes–The lines on the invoice
payment_methodstring, up to 50NoTM/CKHow it was paid, printed on the invoice
notestring, up to 500No–A note, printed on the invoice
send_emailbooleanNofalseEmail 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.

FieldTypeRequiredDefaultMeaning
namestring, up to 200One of the two–The person buying, printed as "Họ tên người mua hàng" (buyer's name)
legal_namestring, up to 400One of the two–The company's registered name, printed as "Tên đơn vị" (company name). Leave it out for a person
tax_code10 to 14 digits or dashesNo–The buyer's tax code (mã số thuế), e.g. 0109876543 or 0109876543-001
addressstring, up to 400No–The buyer's address
emailemailNo–Where send_email sends the invoice
phonestring, up to 20No–The buyer's phone number
buyer_codestring, up to 50No–Your own customer code, printed on the invoice

OpenAPI schema: POST /payments, POST /einvoices

Line item​

FieldTypeRequiredDefaultMeaning
namestring, 1 to 500Yes–What was sold
quantitynumber above 0Yes–Decimals allowed, e.g. 1.5 kg
unit_pricenumber, 0 or moreYes–Price of one unit before VAT, in đồng. Decimals allowed
vat_rate10 | 8 | 5 | 0 | -1 | -2No10-1: not subject to VAT (KCT); -2: not declared (KKKNT)
vat_amountwhole đồng, 0 or moreNoworked out from vat_rateThe line's VAT, used as given. Only with a vat_rate above 0. See Totals and VAT rounding
discountnumber, 0 or moreNo0Discount for the whole line, in đồng before VAT
unitstring, up to 50No–Unit, e.g. kg, plan
codestring, up to 50No–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.

AmountWorked out as
Line amountquantity × unit_price − discount, rounded to the đồng
Line vat_amountvat_amount when you give it; otherwise amount × vat_rate / 100, rounded; 0 when vat_rate is 0, -1 or -2
Line totalamount + vat_amount
totals.subtotal / totals.vat / totals.totalThe 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_pricevat_amounttotal
VAT worked out by Ha Tang45,4554,546 (45,455 × 10%, rounded)50,001
VAT given as vat_amount45,4554,54550,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.5 is refused with 422 validation_error; nothing is rounded for you.
  • Only on a line whose vat_rate is above 0; on a 0, -1 or -2 line it is refused with 422 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.545 or 4,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",
"email": "[email protected]"
},
"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"
}
FieldTypeCan be nullMeaning
objectstringNoAlways einvoice
idstringNoeinv_…
environmentdevelopment | productionNoThe environment of the API key that created it
connection_idstringNoThe provider connection that issues it (icn_…)
providerstringYesThe provider, e.g. matbao
payment_idstringYesThe payment it is for
transaction_idstringYesThe bank transfer it is for, when there is no payment
originhatang | providerNohatang when created through Ha Tang; provider when brought in from the provider's site (import_external)
modedraft | publishNoAs requested
statusstringNoWhere it stands: see What happens after payment
sync_statein_sync | pending_push | conflictNopending_push while a change is on its way to the provider; conflict when the draft was edited on both sides
versionintegerNoGoes up by one with every change made through Ha Tang
buyerbuyerNoAs sent; only the fields you sent
itemsarray of line itemsNoAs sent, each line with its computed amount, vat_amount and total
totalsobject { subtotal, vat, total }NoIn đồng
currencystringNoVND
payment_method, notestringYesAs sent
templateobject { pattern, serial }NoThe template and series it is issued on
referencestring, up to 20NoHa Tang's reference for it at the provider
provider_invoice_idstringYesThe provider's own id, once the draft exists
numberstringYesThe invoice number, once issued
seriesstringYesTemplate and series together, e.g. 1C26TAT
lookup_codestringYesWhat the buyer types on the provider's lookup page
tax_authority_codestringYesThe tax office's code, once tax_accepted
pdf_urlURLYesThe provider's PDF, once there is one
issued_atISO 8601 stringYesThe date on the invoice
related_invoice_idstringYesOn a replacement: the invoice it replaces
relationreplaces | adjustsYesOn a replacement (replaces) or an adjustment (adjusts)
conflictobjectYesWith sync_state: "conflict": the provider's version, under content
errorstringYesWhy it failed
failed_stepcreate_draft | update_draft | publish | delete_draftYesThe step that failed
created_at, updated_atISO 8601 stringNo

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.

FieldTypeCan be nullMeaning
requestedbooleanNotrue when the payment was created with an invoice block
modedraft | publishYesAs requested
statusstringNoAs 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
idstringYesThe invoice's einv_… id, null until it exists
number, series, template, lookup_code, tax_authority_code, pdf_url, sync_state, issued_at, error, failed_stepas on the invoiceYesAs 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​

ErrorWhen
422 validation_errorA 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_fieldA provider credential is missing
422 invoice_provider_errorThe provider refused the request; the message says why
409 invalid_stateNo 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_mismatchThe provider is not available in this environment
503 provider_unavailableThe provider did not answer; try again

Next steps​

View this page as Markdown