Data API: Writing Documents
This page is the write half of the Data API: how a document goes from a draft to posted, how you check it did, and how it records the document it came from. The path grammar, headers, envelopes, paging and error codes stay on the Data API page.
Draft, then Finalise
Creating a document saves it. It does not post it. Nothing reaches the journal, stock, the cashbook
or e-invoicing until a second, separate call sets the document FINAL. The screens work the same
way, Save and then Finalise, and this split is the one most integrations miss.
Create as a draft
POST …/gen-doc/internal-sales-invoices/etl-ep answers 200 with OK_RESPONSE, and data is the
stored document, guid included. With no posting_status in the body it is stored as DRAFT.
"posting_status": "FINAL" on a create. The create path stores the posting status
you send and starts no posting. The document reads FINAL everywhere, and no journal is written for
it unless someone repairs it by hand. Since mid-October 2026 it is still handed to e-invoicing, so
it can reach LHDN with no journal behind it. Create as a draft, then finalise.Finalise
PUT /core2/tnt/dm/erp/gen-doc/internal-sales-invoices/etl-ep/update-posting-status/{guid}
Content-Type: application/json
{ "posting_status": "FINAL" }It answers 200 with the document as saved:
{
"code": "OK_RESPONSE",
"data": {
"bl_fi_generic_doc_hdr": {
"guid": "9f3c1a52-6b1e-4c3d-8a07-2e5d7b9c4f10",
"server_doc_type": "INTERNAL_SALES_INVOICE",
"posting_status": "FINAL"
},
"bl_fi_generic_doc_line": [
{ "item_code": "GS-AC-CABLE-C-1M", "quantity_base": 2.0, "amount_txn": 48.0 }
]
},
"message": ""
}The server loads the stored document and sets the status you sent. It then runs the checks a
document must pass at FINAL, saves it, and queues one job. Those checks include an open fiscal
period, an exchange rate on a foreign-currency document, serial, batch and bin quantities, and a
blacklisted customer on cash bills, invoices, orders and delivery orders. The response comes back
before the job runs.
That job is the primary posting job. It looks up which follow-on jobs your tenant subscribes to — journal, stock, cashbook, e-invoice, membership points and others — and queues each one. Which of them exist is decided per tenant, and a company can narrow the list. That is why the same call can post more for one business than for another. How work actually runs traces the whole chain.
An ordinary PUT …/etl-ep keeps the posting status already stored, whatever your body says. On
this surface the posting status changes only through Finalise and the void and discard routes.
What the etl-ep Finalise does differently
The screens finalise through routes that take a signed-in user’s token, commonly the sibling
PUT …/gen-doc/{docType}/update-posting-status/{guid} with no etl-ep segment. That route and this
one do not do the same work. On the etl-ep route:
- A second call is not refused. The screens’ route refuses a document that is already
FINAL, with “Generic Document has already been posted to FINAL”. This route runs the checks again, saves again and queues the posting job again. The journal and stock jobs each decline to post a document twice. We have not read every other follow-on job, so readposting_statusfirst and call once. - Stock is not checked. The screens’ route checks the stock balance at
FINALunless told not to. This one does not ask for that check. - A foreign-currency document posts no journal. The screens’ route first makes a base-currency
copy of the document and posts that. This route makes no copy, and the journal job declines a
foreign-currency document, so a USD invoice finalised here reads
FINALwith no journal behind it. date_finalizedis not written. Thedate_txn_logicandvalidate_stock_balanceoptions the screens’ route reads from the body have no effect here.
Whether all four are intended is a question BigLedger’s engineers have been asked and have not yet answered. Build for what the route does today, and come back to this section before you rely on any of the four.
How you know it worked
A 200 with OK_RESPONSE from Finalise means the document was saved FINAL and the posting job
was queued. It does not mean anything posted. The follow-on jobs run after the response, and a
failure inside one of them never comes back to you. So check the document, not the response:
GET …/gen-doc/internal-sales-invoices/etl-ep/{guid}.- On
bl_fi_generic_doc_hdr,posting_statusshould readFINAL. That is your call landing. posting_journalshould readPOSTED. The journal job writes that on success and writes nothing on failure.posting_inventoryworks the same way on a document that moves stock.- Give it a minute. If it is still blank after that, the job failed or never ran. From here the two look the same.
For one document, a person with the Financial Report applet can go further: Trace Document checks whether the journal, cashbook line and shadow document exist. Once the cause is fixed, its Resolve button posts the missing piece. For a day’s worth of documents, the reconciliation in What the API leaves to you is the check.
What must exist first, and what it sets off
Before the create:
- A key whose user may create and update this document type. The check is made against the company, branch and location the document itself names. A key that works for one branch can therefore be refused for another.
- The company, the location and the customer.
guid_compandguid_storeare required, and adoc_entity_hdr_guidyou send must exist. - The company’s default GL codes, for every transaction code the document will post to — see
General ledger defaults. This one is
not among the Finalise checks above. A missing code stops the journal job after Finalise has
already answered
200.
After Finalise:
- The ledger, stock and the rest, through the follow-on jobs above, on the tenant’s own subscriptions.
- Webhooks. Most document types queue a
<TYPE>_CREATEDevent on the create. A sales invoice queues it at Finalise instead. Every save, Finalise included, sends<TYPE>_UPDATED. See Webhooks. - E-invoicing, where the tenant subscribes to it: a finalised sales document is what the e-invoice job picks up. The E-Invoice API is where you follow what became of it.
The blast radius of one stopped job. The HTTP call has already succeeded, so a stopped journal
is invisible to your integration. It is invisible to the people at the screens too: the invoice
shows FINAL everywhere, and the ledger is short by its amount until somebody reposts it.
The document chain
The fourth key in the write container, bl_fi_generic_doc_link, is the one nothing else on these two pages
explains, and it is the one you will need. It is how a business document says which document it
came from — a delivery order pointing back at the sales order it fulfils, an invoice pointing back
at the delivery order.
The link is made line to line, not document to document. When BigLedger converts one document into another, it writes one link row for each goods-or-service line it carried across, and each row records the header and the line on both sides.
flowchart LR
subgraph SRC["Sales Order — doc 1, the source"]
direction TB
SH["bl_fi_generic_doc_hdr<br/>guid = A"]
SL1["bl_fi_generic_doc_line<br/>guid = A1"]
SL2["bl_fi_generic_doc_line<br/>guid = A2"]
SH --- SL1
SH --- SL2
end
subgraph LNK["bl_fi_generic_doc_link"]
direction TB
L1["guid_doc_1_hdr = A<br/>guid_doc_1_line = A1<br/>guid_doc_2_hdr = B<br/>guid_doc_2_line = B1<br/>server_doc_type_doc_1_hdr = INTERNAL_SALES_ORDER<br/>server_doc_type_doc_2_hdr = INTERNAL_OUTBOUND_DELIVERY_ORDER<br/>txn_type = KO"]
L2["guid_doc_1_hdr = A<br/>guid_doc_1_line = A2<br/>guid_doc_2_hdr = B<br/>guid_doc_2_line = B2<br/>same two document types<br/>txn_type = KO"]
end
subgraph DST["Delivery Order — doc 2, the target"]
direction TB
DH["bl_fi_generic_doc_hdr<br/>guid = B"]
DL1["bl_fi_generic_doc_line<br/>guid = B1"]
DL2["bl_fi_generic_doc_line<br/>guid = B2"]
DH --- DL1
DH --- DL2
end
SL1 --> L1
SL2 --> L2
L1 --> DL1
L2 --> DL2
Read the columns as doc 1 is where it came from, doc 2 is where it went. Every link row also denormalises the transaction date and the server document type of both ends, so you can filter a chain without joining back to the documents.
Three things follow, and all three change how you write against this.
etl-ep surface. Every other resource on the Data API
page has an etl-ep variant; this one does not. The routes are
GET /core2/tnt/ms/bl-fi-generic-doc-link/query and
GET /core2/tnt/ms/bl-fi-generic-doc-link/{guid} — no access-endpoint segment — with a
backoffice-ep twin. The query filters you want are guid_doc_1_hdr and guid_doc_2_hdr (walk
forwards and backwards from a document), guid_doc_1_line and guid_doc_2_line (from a single
line), and server_doc_type_doc_1_hdr / server_doc_type_doc_2_hdr (every conversion of one type
into another). Each also has a plural set form.bl_fi_generic_doc_link has exactly two
foreign keys, both to app_login_subject for the created-by and updated-by columns. The four
columns that carry the chain — guid_doc_1_hdr, guid_doc_1_line, guid_doc_2_hdr,
guid_doc_2_line — are unconstrained. The database will accept a link to a document that does not
exist, and it will not stop you deleting the document a link points at. If you write links, check
both ends yourself; if you read them, treat a missing target as possible rather than impossible.quantity_contra, quantity_signum, amount_contra and amount_signum. Document
conversion leaves all four null — it records that a line was carried across, not how much of it
was consumed. They are filled by the knock-off endpoints
(POST /core2/tnt/ms/bl-fi-generic-doc-link/knock-off/backoffice-ep and its draft and
quantity-validation siblings). So do not read quantity_contra as “how much of the order has
shipped” unless you know the rows you are reading came from a knock-off.Related documentation
- Data API — hosts, path grammar, the six operations, envelopes, query parameters, limits and error codes.
- How work actually runs — what one Finalise starts, and how you know it finished.
- Webhooks — the
<TYPE>_CREATEDand<TYPE>_UPDATEDevents a write sends.