Skip to content
Data API: Draft, Finalise, Chain

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.

Do not send "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 read posting_status first and call once.
  • Stock is not checked. The screens’ route checks the stock balance at FINAL unless 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 FINAL with no journal behind it.
  • date_finalized is not written. The date_txn_logic and validate_stock_balance options 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:

  1. GET …/gen-doc/internal-sales-invoices/etl-ep/{guid}.
  2. On bl_fi_generic_doc_hdr, posting_status should read FINAL. That is your call landing.
  3. posting_journal should read POSTED. The journal job writes that on success and writes nothing on failure. posting_inventory works the same way on a document that moves stock.
  4. 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_comp and guid_store are required, and a doc_entity_hdr_guid you 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>_CREATED event 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.

The chain table is not on the 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.
There is no foreign key holding the chain together. 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 and amount on a link row are written by the caller, not by the conversion. A link row carries 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>_CREATED and <TYPE>_UPDATED events a write sends.
Last updated on