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
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:
| Field | On the VAT line |
|---|---|
isVatLine | true |
vatSourceLineIndex | index, in this same array, of the line the VAT was derived from |
vatCodeId | the 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.
| Field | Value | Denomination |
|---|---|---|
originalAmount / originalCurrency | 200 / "EUR" | What you posted, before conversion |
amount / currency | 1492.24 / "DKK" | The operating currency of the account accountId names |
lines[].accountAmount / accountCurrency | 1492.24 / "DKK" | Same denomination, per line |
lines[].functionalAmount | 1492.24 | The company base currency |
exchangeRate / exchangeRateDate | 7.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
Which figure answers which question
| Question | Field |
|---|---|
| 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.
Code
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 send | Debit | Credit |
|---|---|---|
| Clearing | 47,200 | |
| Clearing | 47,200 | |
| Revenue | 37,760 | |
| VAT | 9,440 |
with originalAmount: 47200. LedgerBee appends the receivable itself:
| Line LedgerBee appends | Debit | Credit |
|---|---|---|
| Accounts receivable | 47,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:
lines | splitEntries | |
|---|---|---|
| A plain one-debit-one-credit draft with no per-line memo or dimensions | present | omitted entirely |
| A line with no account | omits accountId | reports accountId as "" |
| A line with no amount | omits amount | reports 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:
| Field | Value | Denomination |
|---|---|---|
currency | "EUR" | The currency the draft was raised in |
originalAmount | 1000 | In currency |
lines[].amount | 1000 | In currency, like originalAmount |
amount | 7450 | The company base currency |
exchangeRate / exchangeRateDate | 7.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-entriesPOST /v2/journal-entries/batchPOST /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
| Limit | Value | Applies to |
|---|---|---|
| Lines per entry | 1000 | lines on one entry, posted or draft |
| Entries per batch | 100 | POST /v2/journal-entries/batch and POST /v2/draft-journal-batches |
| Lines per batch | 10000 | lines summed across every entry of one batch, posted or draft |
| Attachments per entry | 10 | attachments |
| Attachment size | 10 MB | one file, measured after base64 decoding |
| Request body | 15 MB | the 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
| Condition | What you observe | Fix |
|---|---|---|
lines do not balance on a journal entry | 400 SPLIT_ENTRIES_NOT_BALANCED with details.debitTotal / details.creditTotal | Balance lines against each other; nothing is appended to this entry type |
| The counter side and the appended leg do not agree | 400 SPLIT_ENTRIES_NOT_BALANCED with details.debitTotal / details.creditTotal, summed across every line including the appended one | Either 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 yourself | 400, 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 type | Remove that line. Submit only counter-side lines and let originalAmount size the leg |
| An account id does not exist for your company | 400 JOURNAL_ENTRY_ACCOUNT_NOT_FOUND | Resolve ids from GET /v1/accounts |
| A line posts to a heading or total account | 400 JOURNAL_ENTRY_ACCOUNT_CANNOT_BE_HEADING_OR_TOTAL | Post to a leaf account |
| A line posts to a supplier control account with no supplier | 400 JOURNAL_ENTRY_CONTROL_ACCOUNT_MISSING_SUPPLIER | Supply supplierId, or post to a non-control account |
| A line posts to a customer control account with no customer | 400 JOURNAL_ENTRY_CONTROL_ACCOUNT_MISSING_CUSTOMER | Supply 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 total | 400 SHARED_VALIDATION_FAILED with details.validationErrors | Read the offending property from details |
originalAmount is zero on a counterparty entry type | 400 AR_AP_LEG_REQUIRES_NONZERO_AMOUNT | Send entryType: "journal"; nothing else in the payload changes |
| A draft names no currency at booking | 400 JOURNAL_ENTRY_CURRENCY_REQUIRED | Set the draft's currency before booking |
| The entry posted but could not be read back | 500 JOURNAL_ENTRY_POSTED_BUT_RESPONSE_UNAVAILABLE with details.journalEntryIds | Fetch 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.