Getting Started
By the end of this page you will have an access key, a verified connection to a tenant, a paginated read of real records, and a clear picture of exactly how far the write path is documented and where you have to ask us.
Everything below is plain HTTPS and JSON. There is no SDK to install.
The worked example
Throughout, the example is GadgetSphere Online Sdn Bhd (tenant code gso), the e-commerce arm
of a Malaysian multi-branch consumer electronics group. Its storefront runs outside BigLedger and
needs two flows:
- Out of BigLedger: the item master, so the storefront shows the right products and prices.
- Into BigLedger: completed online orders, as sales documents.
Substitute your own tenant code and resources; the shape does not change.
Before you start
You need three things. One of them you can get yourself.
| Where it comes from | |
|---|---|
| A tenant code | BigLedger, or the customer whose tenant you are integrating with |
| A user in that tenant with the permissions your integration needs | A tenant administrator, inside the product |
| An access key for that user | You, in Step 2 |
integration-storefront can do what you asked for and
nothing more.The two hosts
| Host | Use it for |
|---|---|
https://api.akaun.com | Signing in and managing access keys |
https://api-etl.akaun.com | Your data traffic |
Both serve the same /core2/ paths and behave identically. api-etl.akaun.com is the ingress
BigLedger’s own integrations point at, and it is the one to use for anything running unattended.
Step 1 — Sign in and get a token
You need a token once, to mint the key. After that you will not use it again.
curl -s -X POST "https://api.akaun.com/core2/platform/dm/identity/login" \
-H "Content-Type: application/json" \
-d '{"email":"integration-storefront@gadgetsphere.example","password":"…"}'{
"code": "OK_RESPONSE",
"data": {
"authToken": "eyJhbGciOiJSUzI1NiJ9…",
"subjectGuid": "…",
"email": "integration-storefront@gadgetsphere.example",
"mobileNumber": null,
"data": [],
"appletTenantTokenList": []
},
"message": ""
}Take data.authToken.
If this fails: code will tell you why. A wrong password gives an authentication code, not a
500. Logging in is a platform-level operation and one login can be attached to more than one tenant, so
tenantCode is optional here — supply it only to have the response scoped to one tenant’s applet
tokens.
Step 2 — Issue an access key
curl -s -X POST "https://api.akaun.com/core2/platform/dm/identity/access-keys/login-ep" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"label": "GadgetSphere storefront sync",
"type": "",
"validDateEnd": "2027-09-06T00:00:00+08:00"
}'{
"code": "OK_RESPONSE",
"data": { "keyId": "7K2QW9XR4M", "keySecret": "p3Rt9mZ2vQ8hL5nD1sJ6kW4bA" },
"message": ""
}keyId— 10 characters — is yourAccessId.keySecret— 25 characters — is yourAccessKey.
Store both now.
keySecret is returned exactly once. It is stored hashed and no endpoint will show it again.
If you lose it, DELETE the key and issue another.validDateEnd. Omit it and the key is created with an expiry of 31 December 9999 — a
credential that never lapses. Most keys in production were created that way. Set a real date,
diarise it, and rotate by issuing the new key, deploying it, confirming traffic has moved, then
deleting the old one. Both work at once, so there is no cutover gap.GET /core2/platform/dm/identity/access-keys/login-ep lists your own keys, and
DELETE /core2/platform/dm/identity/access-keys/login-ep/{guid} removes one. The full credential
model is in Authentication.
Step 3 — Prove the connection
Three things have to be right — the key, the tenant code, and the user’s permissions in that tenant. BigLedger checks them in that order and stops at the first one that fails. Prove all three with a call your integration will really make: a one-row read of sales invoices.
export BLG=https://api-etl.akaun.com
export H_ID="AccessId: 7K2QW9XR4M"
export H_KEY="AccessKey: p3Rt9mZ2vQ8hL5nD1sJ6kW4bA"
export H_TNT="tenantCode: gso"
curl -s "$BLG/core2/tnt/dm/erp/gen-doc/internal-sales-invoices/etl-ep/query?limit=1" \
-H "$H_ID" -H "$H_KEY" -H "$H_TNT"A 200 with "code": "OK_RESPONSE" — even with an empty data list, on a tenant with no invoices
yet — says that the key is valid, that gso is a tenant BigLedger knows, and that the user behind
the key may read sales invoices there. This is the real answer from the demo tenant, which holds
GadgetSphere’s synthetic data, shortened to one invoice and four of its header fields, with its
lines, events and links each collapsed to one placeholder:
{
"totalRecords": 0,
"offset": 0,
"limit": 1,
"code": "OK_RESPONSE",
"message": "",
"data": [
{
"bl_fi_generic_doc_hdr": {
"guid": "9d7595d2-5f7a-43a3-aab5-b269bf321c10",
"server_doc_type": "INTERNAL_SALES_INVOICE",
"server_doc_1": "1000001",
"status": "ACTIVE"
},
"bl_fi_generic_doc_line": [ { "…": "…" } ],
"bl_fi_generic_doc_event": [ { "…": "…" } ],
"bl_fi_generic_doc_ext": [],
"bl_fi_generic_doc_link": [ { "…": "…" } ],
"bl_fi_generic_doc_budget_sub_line": []
}
]
}If the user has not been granted that read, the answer is a 403 with
CLIENT_AUTH_USER_NOT_AUTHORIZED and no rows; Authentication
shows that refusal.
Then run the same call once more with one character of the AccessKey changed. It must answer
AUTH_TOKEN_OR_ACCESS_ID_TOKEN_NOT_MATCHED. If it does, your client really is sending the key you
think it is.
curl -s "$BLG/core2/tnt/dm/erp/gen-doc/internal-sales-invoices/etl-ep/query?limit=1" \
-H "$H_ID" -H "AccessKey: p3Rt9mZ2vQ8hL5nD1sJ6kW4bB" -H "$H_TNT"{
"code": "AUTH_TOKEN_OR_ACCESS_ID_TOKEN_NOT_MATCHED",
"data": null,
"message": ""
}…/gen-doc/etl-ep/server-doc-types is a useful reference, but it checks no permission: a 200
from it proves only that the key is valid and the tenant code exists. Before your first real run,
make a one-row …/etl-ep/query?limit=1 read on every resource your integration touches. A 403
on any of them is a list to send the tenant administrator now, not a failure at three in the
morning.server-doc-types returns
INTERNAL_SALES_INVOICE; the URL for that document is
…/gen-doc/internal-sales-invoices/etl-ep — lower case, hyphenated, and usually plural. Use that
list to confirm a document type exists, then check the slug in the
Data API.If it fails, read code rather than the HTTP status — the table in
Authentication maps every one. The three you will
actually hit, in the order BigLedger checks them:
code | Fix |
|---|---|
AUTH_TOKEN_OR_INVALID_ACCESS_ID | The AccessId is wrong or the key was deleted |
CLIENT_TENANT_CODE_DOES_NOT_EXIST | The tenantCode is wrong |
CLIENT_AUTH_USER_NOT_AUTHORIZED | Key and tenant are fine; the user lacks the permission. Go back to the tenant administrator. |
Step 4 — Read data out
Every resource has a query endpoint that filters and paginates. For GadgetSphere’s item master:
curl -s "$BLG/core2/tnt/dm/erp/fi/fi-items/etl-ep/query?limit=200&offset=0&calcTotalRecords=true" \
-H "$H_ID" -H "$H_KEY" -H "$H_TNT"{
"totalRecords": 5217,
"offset": 0,
"limit": 200,
"code": "OK_RESPONSE",
"message": "",
"data": [ { "bl_fi_mst_item_hdr": { "…": "…" }, "bl_fi_mst_item_lines": [] } ]
}Then walk offset forward in steps of limit until you have totalRecords rows.
Three things to know before you build the loop:
limitdefaults to 100 if you omit it. There is no enforced maximum, but responses stream and are not buffered — a very large page is a long single response, not a fast one.calcTotalRecordsdefaults tofalseand costs an extraCOUNTquery when true. Send it on the first page to size the job, then drop it.- Set the sort explicitly. With no
orderBy, results come back ordered by the record’sguid— stable, but arbitrary, and rows created while you are paging still land unpredictably. PassorderBy(a column on the record, e.g.updated_date) andorder(ASCorDESC).
Step 5 — Pull only what changed
Full pulls are for the first run. After that, filter on the update timestamp. Most of the
resources you will actually sync — documents, customers and suppliers, items — accept
updated_date_from and updated_date_to in ISO-8601. Not every resource does; if a filter has no
effect on the result count, that resource does not support it, and you will have to page the whole
set and diff on your side.
curl -s -G "$BLG/core2/tnt/dm/erp/gen-doc/internal-sales-invoices/etl-ep/query" \
--data-urlencode "updated_date_from=2026-09-01T00:00:00+08:00" \
--data-urlencode "limit=200" \
--data-urlencode "offset=0" \
--data-urlencode "orderBy=updated_date" \
--data-urlencode "order=ASC" \
-H "$H_ID" -H "$H_KEY" -H "$H_TNT"The answer holds only the invoices changed since the mark, oldest change first. This is the real answer
from the demo tenant above, shortened to the header fields that matter here and with the child lists collapsed: of its two readable
invoices, only GadgetSphere’s last changed after 1 September 2026. A run where nothing changed since
the mark answers the same envelope with an empty data list:
{
"totalRecords": 0,
"offset": 0,
"limit": 200,
"code": "OK_RESPONSE",
"message": "",
"data": [
{
"bl_fi_generic_doc_hdr": {
"guid": "9d7595d2-5f7a-43a3-aab5-b269bf321c10",
"server_doc_1": "1000001",
"date_txn": "2026-09-18T02:30:00Z",
"updated_date": "2026-10-18T15:02:15.474403Z"
},
"bl_fi_generic_doc_line": [ { "…": "…" } ],
"bl_fi_generic_doc_event": [ { "…": "…" } ],
"bl_fi_generic_doc_ext": [],
"bl_fi_generic_doc_link": [ { "…": "…" } ],
"bl_fi_generic_doc_budget_sub_line": []
}
]
}Store the high-water mark and pass it as updated_date_from next run. Overlap it by a minute or
two and de-duplicate on guid — cheaper than missing a record.
created_date_from / _to and date_txn_from / _to are available on document queries too.
date_txn is the business transaction date and updated_date is when the row last changed; for
change-data-capture you want updated_date.
Step 6 — Write data in
Creating a record is a POST of the same JSON object shape a GET returns, to the same path
without a trailing GUID:
POST /core2/tnt/dm/erp/gen-doc/internal-sales-invoices/etl-epPOST /core2/tnt/dm/erp/entity/customers/etl-epPOST /core2/tnt/dm/erp/fi/fi-items/etl-ep
A worked create with its real answer, then update, finalise and void, is on the Purchase Invoice page.
PUT to the same path updates; DELETE …/etl-ep/{guid} removes.
Bodies are containers — a header object plus arrays of related rows — and the JSON keys are the underlying table names:
{
"bl_fi_generic_doc_hdr": { "…": "…" },
"bl_fi_generic_doc_line": [ { "…": "…" } ],
"bl_fi_generic_doc_ext": [],
"bl_fi_generic_doc_link": []
}bl_fi_generic_doc_line (singular) while the item lines array is bl_fi_mst_item_lines (plural).
The key is whatever the platform’s own field is called. Do not guess it — read a record back and
copy the keys exactly.The technique: read one, then mirror it
Because GET and POST on a resource use the same container type, the reliable way to learn a
write body is to create one record by hand in the BigLedger user interface, read it back through
…/etl-ep/query, and use that as your template. Before you post it:
- Strip the server-assigned fields —
guid,revision,created_date,updated_date,vrsn— on the header and on every line. - For a document, strip what belongs to the template document itself: its document numbers
(
server_doc_1toserver_doc_4), itspin_…fields, and the template customer’s name-and-address block (doc_entity_hdr_json). - For a document, send the related-row arrays empty —
bl_fi_generic_doc_event,bl_fi_generic_doc_ext,bl_fi_generic_doc_linkandbl_fi_generic_doc_budget_sub_line. Their rows belong to the template. - Substitute your own values, and post it.
server_doc_1 keeps the template’s number. When that invoice is finalised and the
template’s number is already in the e-invoice queue, the e-invoice step treats the new invoice as a
duplicate and skips it without raising an error. A copied PIN and a copied customer name and
address are kept the same way.This works for any resource on the surface and it is what we would tell you to do anyway.
Where this stops, and what to ask for
So, concretely, this is where you stop and ask:
- Master data — customers, suppliers, items, branches, locations, labels, pricing. Read-one-then-mirror is enough. Expect to iterate against validation errors; the response tells you which field failed.
- Business documents — invoices, orders, deliveries, credit notes. Read-one-then-mirror gets you close, but the posting rules are where integrations fail. Ask your BigLedger contact (see Support) — not the public tracker, because the request names your tenant — for a worked request body for that document type in your tenant’s configuration, with one real example of what you want to create. That is a request BigLedger can answer quickly and one you cannot answer from documentation.
There is also a bulk path — POST …/{docType}/etl-ep/multi takes an array of the same containers,
and several resources have a CSV import-file endpoint. Both have the same schema question, so
settle the single-record body first.
Step 7 — Keep your own identifiers
Do not build your correlation on BigLedger’s GUIDs alone. Document headers carry three fields for your side of the mapping:
| Field | Use it for |
|---|---|
client_key | Your primary key for the record |
client_source | Which of your systems it came from |
client_value | Anything else you need to carry |
Both client_key and client_source are filterable, as hdr_client_key and hdr_client_source:
curl -s -G "$BLG/core2/tnt/dm/erp/gen-doc/internal-sales-invoices/etl-ep/query" \
--data-urlencode "hdr_client_source=storefront" \
--data-urlencode "hdr_client_key=WEB-2026-0043117" \
-H "$H_ID" -H "$H_KEY" -H "$H_TNT"The answer when the order is not there yet, as the test tenant gives it:
{
"totalRecords": 0,
"offset": 0,
"limit": 100,
"code": "OK_RESPONSE",
"message": "",
"data": []
}That query is your duplicate check: before posting an order, ask whether it is already there.
An empty data means post it; any row means it is already in BigLedger. The API has no
idempotency-key header, so this is the mechanism. It is a check, not a lock: two workers that ask
at the same moment both see nothing and both try to post. Give each source system one posting
worker.
What success looks like
Run these four in order. If all four pass, your integration has everything it needs.
GET …/internal-sales-invoices/etl-ep/query?limit=1returnsOK_RESPONSE, and the same call with one character of theAccessKeychanged returnsAUTH_TOKEN_OR_ACCESS_ID_TOKEN_NOT_MATCHED.GET …/fi/fi-items/etl-ep/query?limit=1&calcTotalRecords=truereturns atotalRecordsthat matches what the tenant’s users see.- The same query with
offset=1returns a different record. - A
POSTof a mirrored record returnsOK_RESPONSE, and reading it back byhdr_client_keyfinds it.
Common mistakes
Paging on the default sort. With no orderBy you get guid order — arbitrary, and rows
created mid-run still appear unpredictably. Sort explicitly, and prefer updated_date.
Treating CLIENT_AUTH_USER_NOT_AUTHORIZED as a credential problem. It is a permission
problem. Re-issuing the key will not fix it; granting the permission to the user will.
Assuming an unknown path returns 404. A path the platform does not recognise returns 417
with SERVER_GENERAL_UNKNOWN_ERROR and a message beginning No static resource. If you see that,
fix the path before you suspect anything else.
Using the document-type name from server-doc-types in a URL. It returns
INTERNAL_SALES_INVOICE; the URL wants internal-sales-invoices.
Letting a key live forever. Omitting validDateEnd produces a credential dated 31 December
9999. Set an expiry, and delete keys for integrations you have retired.