LedgerBee Developer
  • Getting started
  • Conventions
  • Products
  • Configuration
  • API Reference
Subscriptions
Payment flowCard paymentsProducts & PricingProduct Entitlements
Billing documents
Accounting
    Journal entriesSettlementsBudgets
WebhooksPartner-Hosted Checkout
Customer Portal
Accounting

Journal entries

A journal entry is a set of ledger lines that balance. Post one with POST /v2/journal-entries, read it back with GET /v1/journal-entries/{id}, and list them with GET /v1/journal-entries. Route paths here are relative to the API mount, so the first of those is https://api.ledgerbee.com/api/v2/journal-entries in full, as in the examples below.

Read every leg from lines. Amounts come back in three currency layers.

Read lines, not the account pair

Every journal entry response carries a lines array with each ledger leg. Lines come from three places: the ones you posted, any VAT LedgerBee derived from them, and — on the four counterparty entry types — the AR/AP leg LedgerBee appends.

Code
{ "id": "01A01E2B-66A8-714C-95CF-C8EAC3308BD8", "entryNumber": "JE-00117", "accountId": "019C483C-9DFD-7701-8C02-41AAF60BF51B", "contraAccountId": "019C483C-9DFC-798A-845C-7253BEA46C4A", "lines": [ { "accountId": "019C483C-9DFD-7701-8C02-41AAF60BF51B", "type": "debit", "accountAmount": 400, "accountCurrency": "DKK", "functionalAmount": 400, "isVatLine": false }, { "accountId": "019C483C-9DFE-7C88-A52B-08093F701AFF", "type": "debit", "accountAmount": 400, "accountCurrency": "DKK", "functionalAmount": 400, "isVatLine": false, "vatCodeId": "019C483C-9DF7-717E-9079-F24C9B3ABBA1" }, { "accountId": "019C483C-9DFF-7A31-9C55-2E4B7F1D8A60", "type": "debit", "accountAmount": 100, "accountCurrency": "DKK", "functionalAmount": 100, "isVatLine": false }, { "accountId": "019C483C-9DFC-798A-845C-7253BEA46C4A", "type": "credit", "accountAmount": 1000, "accountCurrency": "DKK", "functionalAmount": 1000, "isVatLine": false }, { "accountId": "019C483C-9E00-7B44-8D12-6A3F5C90E7D2", "type": "debit", "accountAmount": 100, "accountCurrency": "DKK", "functionalAmount": 100, "isVatLine": true, "vatSourceLineIndex": 1, "vatCodeId": "019C483C-9DF7-717E-9079-F24C9B3ABBA1" } ] }

accountId and contraAccountId name two of those lines. They stay populated on every entry, so an integration that reads them keeps working. On an entry wider than a single debit-and-credit pair they describe two legs and nothing about the rest. The entry above posts five.

Line order is stable for a given entry and carries no ranking. lines[0] is not necessarily the line accountId names; the two are resolved independently.

The appended AR/AP leg

On customer_invoice, customer_payment, supplier_invoice and supplier_payment, you submit only the counter-side lines — revenue, expense, bank, outlay. LedgerBee appends the receivable or payable leg itself, on the customer or supplier group's default account, and rejects the request if you include one yourself.

That leg comes back in lines like any other. Reconciling by account, expect a line on a control account you did not post. journal entries have no appended leg: every line is one you sent.

VAT lines

A VAT-coded line splits when it posts, so what you send and what comes back differ. Line 1 above was sent as amount: 500 with a 25% gross-principle VAT code. It posts as 400 on that account plus a separate 100 VAT line — which is why its accountAmount reads 400 in the response and the array holds five lines for the four you sent.

Derived VAT lines are real postings and part of the array, which balances only with them. Three fields identify and link them:

FieldOn the VAT line
isVatLinetrue
vatSourceLineIndexindex, in this same array, of the line the VAT was derived from
vatCodeIdthe code it was derived under

The three currency layers

Post 200 EUR against accounts that operate in DKK, on a company whose base currency is DKK, and the response carries three denominations.

FieldValueDenomination
originalAmount / originalCurrency200 / "EUR"What you posted, before conversion
amount / currency1492.24 / "DKK"The operating currency of the account accountId names
lines[].accountAmount / accountCurrency1492.24 / "DKK"Same denomination, per line
lines[].functionalAmount1492.24The company base currency
exchangeRate / exchangeRateDate7.4612 / "2026-08-20"Converts originalCurrency to the base currency

currency reads "DKK" on an entry posted in EUR because it describes the account, not the document. originalCurrency is the currency exchangeRate converts from.

The figures reconcile:

Code
originalAmount × exchangeRate = the sum of lines[].functionalAmount on either side 200 EUR × 7.4612 = 1492.24 DKK

Which figure answers which question

QuestionField
What was the customer invoiced?originalAmount with originalCurrency
What is the total, or any sum across accounts?lines[].functionalAmount
What is the balance of one account?lines[].accountAmount, filtered to that account
What rate was applied?exchangeRate with exchangeRateDate

Sum functionalAmount for totals. Accounts can operate in different currencies, so summing accountAmount across them adds unlike units.

lines[].originalAmount is per-line and is not a summable total. A derived VAT line carries no document amount, so the per-line sum falls short by the VAT: on a 1000 entry with one VAT-coded line, the debit-side sum is 900 while the entry's originalAmount is 1000. Use the entry-level originalAmount for the document total.

On an entry posted in the base currency all three denominations agree and exchangeRate is 1. A null rate means an entry that recorded none.

Posting an entry

POST /v2/journal-entries, scope journal-entries-write — see the API Reference.

TerminalCode
curl -X POST 'https://api.ledgerbee.com/api/v2/journal-entries' \ -H 'x-api-key: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "entryType": "journal", "date": "2026-08-20", "originalAmount": 200, "currency": "EUR", "exchangeRate": 7.4612, "lines": [ { "accountId": "019C483C-9DFD-7701-8C02-41AAF60BF51B", "type": "debit", "amount": 200 }, { "accountId": "019C483C-9DFC-798A-845C-7253BEA46C4A", "type": "credit", "amount": 200 } ] }'

The response is the created entry, including its lines.

Omit exchangeRate and LedgerBee resolves the rate from its own feed at the posting date. The read returns the rate either way, so you can compare it against the rate your settlement later used. exchangeRateDate is set by LedgerBee; the create accepts the rate alone.

Reading requires scope journal-entries-read.

originalAmount

originalAmount is the face value of the document — what the counterparty is invoiced or pays. It is not the sum of lines, and on a wide entry the two differ.

On customer_invoice, customer_payment, supplier_invoice and supplier_payment the value is the appended AR/AP leg: LedgerBee posts its absolute value to the customer or supplier group's default account and takes the side from its sign. On the two invoice types it is also the face amount of the invoice anchor that payments settle against.

The figure to send is the receivable or payable, never the summed debit side. The two diverge whenever an account appears on both sides of the entry — a clearing or pass-through account — or another debit line sits beside the AR/AP leg, such as a discount or a write-off.

A 47,200 receivable routed through a clearing account is sent as four lines:

Line you sendDebitCredit
Clearing47,200
Clearing47,200
Revenue37,760
VAT9,440

with originalAmount: 47200. LedgerBee appends the receivable itself:

Line LedgerBee appendsDebitCredit
Accounts receivable47,200

The posted entry therefore carries 94,400 on each side, because the clearing account is counted twice. originalAmount is 47,200 throughout.

Send 94,400 and LedgerBee appends a 94,400 receivable to a counter side that only needed 47,200. The entry cannot balance, and the request is refused with 400 SPLIT_ENTRIES_NOT_BALANCED, whose details report the totals across every line, the appended one included — here debitTotal 141,600 against creditTotal 94,400. The mistake is caught rather than posted — but the error reports the imbalance, so it reads as a problem with lines when it is a problem with originalAmount.

On journal entries LedgerBee appends no AR/AP leg and derives the posting from lines, so originalAmount describes the document rather than driving it. Send the document's face value; the field is required.

On the four counterparty types a zero originalAmount is rejected: an entry whose own lines already balance moves no receivable or payable, and is a journal entry.

The name has another home: a draft read returns an originalAmount in its own currency layer — see "Currency on a draft" under Drafts. Wherever it appears it is denominated by the message it sits on, so read it there rather than carrying a meaning across from another endpoint.

Matching entries to your own records

externalSystemId stores your system's id for the entry. POST /v2/journal-entries, POST /v2/journal-entries/batch and the draft create all accept it, a draft carries it onto the entry it books, and PATCH /v1/journal-entries/{id} sets it on an entry that already exists.

Setting it on the create writes it in the entry's own INSERT, so an entry is never readable without its id. A job that dies part-way through a run can list what it already posted with GET /v1/journal-entries?externalSystemId=… and resume from there.

Leading and trailing whitespace is trimmed before storage, and a value that is only whitespace is stored as no id at all. The filter trims what it is given too, so the two always agree.

It is a lookup key, not a deduplication key. LedgerBee enforces no uniqueness on the value and never compares it between requests, so several entries may carry the same id and the filter returns all of them — repeating a request posts another entry. Retries that must not post twice carry x-api-idempotency-key, which replays the original response; see Idempotency.

Drafts

A draft is an unposted entry you build up and complete later. It follows the same line model.

POST and PATCH /v2/draft-journal-entries take lines, and the read returns lines — the same field name on both sides. A deprecated splitEntries field is also returned; read lines, which is always present.

The v2 create appends the AR/AP leg to a draft too. On the four counterparty entry types the draft is persisted with that leg already in it, so a supplier_invoice draft created from one expense line reads back with two. Same rule as a posted entry: on journal drafts every line is one you sent.

The deprecated v1 create appends no AR/AP leg, on any entry type. Sent as splitEntries, the lines read back as you sent them. Sent in the header form — accountId, contraAccountId and amount, with no lines — it builds the debit/credit pair from those fields, so the draft reads back with two lines you did not send.

splitEntries differs from lines:

linessplitEntries
A plain one-debit-one-credit draft with no per-line memo or dimensionspresentomitted entirely
A line with no accountomits accountIdreports accountId as ""
A line with no amountomits amountreports amount as 0

Those substitutions make a splitEntries line unusable as a write payload: "" is rejected as a malformed uuid. Send lines back instead.

A draft line may carry no account. Post the placeholder through POST /v1/draft-journal-entries, whose splitEntries accepts a line without one; the v2 lines create requires an account on every line. Both reads return the placeholder, and booking requires every line to carry a real account.

Currency on a draft

A draft posts to no accounts yet, so it carries two denominations rather than three. The fields below are the ones a draft read returns. originalAmount is also a create field, where it is the document's face value — that is the originalAmount section above, and it is a different field on a different message.

Read back a EUR 1,000 draft on a company whose base currency is DKK, raised at a rate of 7.45:

FieldValueDenomination
currency"EUR"The currency the draft was raised in
originalAmount1000In currency
lines[].amount1000In currency, like originalAmount
amount7450The company base currency
exchangeRate / exchangeRateDate7.45 / "2026-08-20"Converts currency to the base currency

currency describes the document here, so a EUR draft reads "EUR". On a posted entry that field describes the account instead, and the document's currency is originalCurrency.

Where currency is the company's own base currency the two totals share a denomination, so no conversion separates them.

The two totals do not reconcile through the rate, and this is where a draft differs from a posted entry. On a create that carries lines, both totals are built from those lines and built differently: amount takes the larger of the debit and credit sides, converted, while originalAmount takes the debit side.

So a create sending lines does not echo an originalAmount sent beside them — the v2 create derives that field from the lines and does not read the one you send. The deprecated v1 header form, which sends no lines, does store the figure you send.

They agree in the table above because that draft balances and states one amount, which is why the rate appears to connect them there. It does not in general. Take a draft entered in your own base currency, where there is nothing to convert and exchangeRate is 1: if its debits total 1,000 against credits of 1,500, it reads originalAmount: 1000 beside amount: 1500. Both figures are in that one currency, and they differ because the draft's two sides differ — no conversion is involved. Read each field for what it is rather than reconstructing one from the other.

amount collides across the two messages, and this is the collision that changes the number. The deprecated v1 create takes a request field called amount carrying the document figure; the read returns an amount in the base currency. Send a EUR 1,000 draft as amount: 1000 and the 201 comes back with amount: 7450 — the same name, one denomination apart. The v2 create avoids it by naming its request field originalAmount.

The draft above carries one line per side, so each line equals the total. A draft with more lines does not: lines[].amount is a per-line figure, and what it shares with originalAmount is the denomination, not the value.

Both totals read 0 on a draft that carries no amount yet.

Idempotency

Three routes replay the full response body for a repeated x-api-idempotency-key, so a retry after a timeout returns the original entry instead of posting a second one:

  • POST /v2/journal-entries
  • POST /v2/journal-entries/batch
  • POST /v2/draft-journal-entries/{id}/book

Creating a draft does not replay — retry it only when you can tell from your own records that the first attempt did not land. See Idempotency.

Limits

LimitValueApplies to
Lines per entry1000lines on one entry, posted or draft
Entries per batch100POST /v2/journal-entries/batch and POST /v2/draft-journal-batches
Lines per batch10000lines summed across every entry of one batch, posted or draft
Attachments per entry10attachments
Attachment size10 MBone file, measured after base64 decoding
Request body15 MBthe whole request, base64 payload included

Each of these bounds a single request. Nothing accumulates across entries or over time, so a backlog of unposted drafts is never a reason a create is refused.

Both attachment bounds are enforced, and either can be the one you hit. A file over 10 MB decoded is rejected with 400 DOCUMENT_UPLOAD_FILE_TOO_LARGE, before the request budget is reached. Base64 inflates a payload by about a third, so 15 MB of request carries roughly 11 MB of file across an entry's attachments together — two 6 MB files each clear the per-file bound and exceed the request one.

A line count grows with the accounts, VAT codes and segments one entry posts across, not with the transactions aggregated behind it.

A batch is atomic. One entry failing validation rolls back all of them, so nothing lands partially and the batch is safe to resend once the offending entry is fixed.

Request throughput is covered in Rate limits.

Errors

ConditionWhat you observeFix
lines do not balance on a journal entry400 SPLIT_ENTRIES_NOT_BALANCED with details.debitTotal / details.creditTotalBalance lines against each other; nothing is appended to this entry type
The counter side and the appended leg do not agree400 SPLIT_ENTRIES_NOT_BALANCED with details.debitTotal / details.creditTotal, summed across every line including the appended oneEither input can be the wrong one — check the counter-side amounts and their type as well as originalAmount, and correct whichever disagrees. Do not make lines balance on their own; the leg is what closes them
You supplied the AR/AP leg yourself400, one of INVOICE_AR_LEG_NOT_ON_GROUP_DEFAULT, INVOICE_AP_LEG_NOT_ON_GROUP_DEFAULT, PAYMENT_AR_LEG_NOT_ON_GROUP_DEFAULT, PAYMENT_AP_LEG_NOT_ON_GROUP_DEFAULT — by entry typeRemove that line. Submit only counter-side lines and let originalAmount size the leg
An account id does not exist for your company400 JOURNAL_ENTRY_ACCOUNT_NOT_FOUNDResolve ids from GET /v1/accounts
A line posts to a heading or total account400 JOURNAL_ENTRY_ACCOUNT_CANNOT_BE_HEADING_OR_TOTALPost to a leaf account
A line posts to a supplier control account with no supplier400 JOURNAL_ENTRY_CONTROL_ACCOUNT_MISSING_SUPPLIERSupply supplierId, or post to a non-control account
A line posts to a customer control account with no customer400 JOURNAL_ENTRY_CONTROL_ACCOUNT_MISSING_CUSTOMERSupply customerId, or post to a non-control account
A field fails validation, including an entry over 1000 lines, a batch over 100 entries, or a batch over 10000 lines in total400 SHARED_VALIDATION_FAILED with details.validationErrorsRead the offending property from details
originalAmount is zero on a counterparty entry type400 AR_AP_LEG_REQUIRES_NONZERO_AMOUNTSend entryType: "journal"; nothing else in the payload changes
A draft names no currency at booking400 JOURNAL_ENTRY_CURRENCY_REQUIREDSet the draft's currency before booking
The entry posted but could not be read back500 JOURNAL_ENTRY_POSTED_BUT_RESPONSE_UNAVAILABLE with details.journalEntryIdsFetch the entry by the ids in details. Do not resend — the posting succeeded

Reference

Field-level documentation for every endpoint and response shape is in the API Reference.

Last modified on September 13, 2026
Document templatesSettlements
On this page
  • Read lines, not the account pair
    • The appended AR/AP leg
    • VAT lines
  • The three currency layers
    • Which figure answers which question
  • Posting an entry
    • originalAmount
    • Matching entries to your own records
  • Drafts
    • Currency on a draft
  • Idempotency
  • Limits
  • Errors
  • Reference
JSON