LedgerBee Developer
  • Getting started
  • Conventions
  • Products
  • Advisor firms
  • Configuration
  • API Reference
Subscriptions
Payment flowCard paymentsProducts & PricingDiscountsProduct Entitlements
Billing documents
Accounting
    Journal entriesSettlementsBank transactionsBudgets
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
originalCurrencyToBaseCurrencyExchangeRate / originalCurrencyToBaseCurrencyExchangeRateDate7.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 originalCurrencyToBaseCurrencyExchangeRate converts from.

The response repeats the rate and its date under the deprecated names exchangeRate and exchangeRateDate, with the same values. amount and currency are not deprecated: they are the account figure and currency in the table above, not older names for originalAmount and originalCurrency.

The figures reconcile:

Code
originalAmount × originalCurrencyToBaseCurrencyExchangeRate = 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?originalCurrencyToBaseCurrencyExchangeRate with originalCurrencyToBaseCurrencyExchangeRateDate

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 the rate is 1. A null rate, under both names, 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, "originalCurrency": "EUR", "originalCurrencyToBaseCurrencyExchangeRate": 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 the rate under both its names and LedgerBee resolves it from its own feed at the posting date. The response and every read return the rate as originalCurrencyToBaseCurrencyExchangeRate either way, so you can compare it against the rate your settlement later used. originalCurrencyToBaseCurrencyExchangeRateDate is set by LedgerBee; the create accepts the rate alone.

The draft create takes the same two names, so one body can be sent to POST /v2/draft-journal-entries and to POST /v2/journal-entries. POST /v2/journal-entries and POST /v2/journal-entries/batch also accept the deprecated names currency and exchangeRate. Send either name of a pair, or both with the same value. Sending both names with different values is refused with 400 JOURNAL_ENTRY_DEPRECATED_FIELD_CONFLICT, naming the two fields in details.field and details.deprecatedField, and on a batch the entry in details.entryIndex. A read of the posted entry returns the rate and its date under the current names and again under the deprecated exchangeRate and exchangeRateDate. Its currency is the currency of the account accountId names; see The three currency layers.

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.

Paying an invoice in a foreign currency

A customer_payment or supplier_payment whose reference names an invoice kept on an account in a foreign currency clears the invoice at the rate it was booked at. The difference against the payment's own rate is posted to your exchange gain or loss account on the same entry, so the account clears to zero in your company's currency when the invoice is paid in full.

Post the payment in the invoice's currency. originalAmount is then how much of the invoice the payment settles, and a part payment leaves the rest open:

Code
{ "entryType": "customer_payment", "date": "2026-02-17", "reference": "INV-1042", "customerId": "018f6c2e-7a10-7c3a-9d21-3b5e8f1a2c44", "originalAmount": 500, "currency": "USD", "exchangeRate": 7.1, "lines": [ { "accountId": "018f6c2e-7b22-7e11-8c10-6a1d4f9e3b77", "type": "debit", "amount": 500 } ] }

A part payment posted in your company's currency is refused with 400 JOURNAL_ENTRY_CROSS_CURRENCY_PARTIAL_REQUIRES_APPLIED_AMOUNT, because its amount does not say how much of the invoice it pays. A payment in your company's currency worth more than the invoice still owes at its booked rate is booked and left open against the customer or supplier, and settles no part of the invoice.

A payment that names no invoice

A customer_payment or supplier_payment with no reference is booked as an advance held for the customer or supplier, unless it is a refund. LedgerBee moves the payment's line on the customer's or supplier's group default account to your advances account, and opens the advance against the counterparty. Every other line stays on the account it names.

A refund is the opposite direction on that group default account: a credit on a supplier's account (money back from the supplier) or a debit on a customer's account (money paid back to the customer). It is booked on the lines you send, with no advance.

POST /v2/journal-entries and POST /v2/draft-journal-entries append that line themselves, so a v2 payment always carries it. A draft created with POST /v1/draft-journal-entries carries the lines you send in splitEntries, or, without them, one line on accountId and one on contraAccountId. When none of its lines sits on the group default account or on your advances account, booking it is refused with 400 JOURNAL_ENTRY_PREPAYMENT_BALANCE_ACCOUNT_INVALID, whatever the order of the lines. Send the invoice number as reference, put the counter line on the group default account, or post the movement as a journal entry.

Document date and due date

date is the posting date: the accounting period the entry belongs to. documentDate is the date printed on the invoice or voucher, and the VAT period follows it. A supplier invoice dated 28 March and posted on 3 April belongs to the April accounting period and the March VAT period.

Code
{ "entryType": "supplier_invoice", "date": "2026-04-03", "documentDate": "2026-03-28", "dueDate": "2026-04-27", "originalAmount": 800, "supplierId": "019C483C-9DFD-7701-8C02-41AAF60BF51C", "reference": "BILL-1042", "lines": [ { "accountId": "019C483C-9DFC-798A-845C-7253BEA46C4B", "type": "debit", "amount": 800 } ] }

Both fields are optional and take YYYY-MM-DD. A date that does not exist, such as 2026-02-30, is refused.

FieldWhen omitted
documentDateThe entry has no document date. Its VAT period follows date, and the read returns documentDate: null
dueDateCalculated from your customer's or supplier's payment term, else your company default, counted from documentDate when sent and from date otherwise. With 30-day terms, the body above without dueDate falls due on 27 April; without documentDate as well, on 3 May

A dueDate you send is used exactly as sent. It belongs to the invoice the entry registers, so send it only on customer_invoice, supplier_invoice or the deprecated invoice, together with reference, the number the invoice is registered under.

The create response and GET /v1/journal-entries return both fields. dueDate is null on every entry type other than an invoice, on a credit note booked without one, and on an invoice posted through the API without reference.

POST /v2/draft-journal-entries and PATCH /v2/draft-journal-entries/{id} take both fields under the same rules, judged on the values the draft holds after the request. On the update, null clears a field and an omitted field keeps its value. Booking the draft carries both onto the entry, and a draft without dueDate gets one calculated from the payment term when it is booked. GET /v2/draft-journal-entries/{id} returns both fields.

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.

Journal categories

Journal categories group a company's entries and drafts. List them with GET /v1/journal-categories.

Set a category with journalCategoryId on POST /v2/journal-entries, POST /v2/journal-entries/batch, POST /v2/draft-journal-entries, POST /v2/draft-journal-batches and PATCH /v2/draft-journal-entries/{id}. Booking a draft with POST /v2/draft-journal-entries/{id}/book keeps its category. An entry posted or booked without one is filed under your company's default category.

To confirm where a record landed, read it back: GET on a draft or an entry, both lists, and the responses to an entry post, a draft create, a draft PATCH and a booking carry journalCategoryId. It is null on a record that has no category. Both lists filter on it: GET /v1/journal-entries?journalCategoryId=… and GET /v2/draft-journal-entries?journalCategoryId=….

Reversing an entry

POST /v1/journal-entries/reverse cancels a posted entry by posting a second entry with every line's debit and credit swapped. The reversal is dated the original's date unless you send reversalDate, and the response carries its id as reversalId. POST /v1/journal-entries/batch-reverse reverses up to 100 entries in one transaction, so one refused entry rolls back the whole batch.

Both entries stay on the books and keep their status. Two fields on every entry say which entries cancel each other:

FieldOn the reversed entryOn the reversalOn every other entry
reversedByEntryIdthe reversal's idnullnull
reversalOfEntryIdnullthe reversed entry's idnull

GET /v1/journal-entries/{id} and GET /v1/journal-entries both return them. To leave reversed pairs out of a list, pass excludeReversed=true, which leaves out each reversed entry together with its reversal:

Code
GET https://api.ledgerbee.com/api/v1/journal-entries?fromDate=2026-03-02&toDate=2026-03-02&excludeReversed=true

A reversal's reference is REV- followed by the original's reference, or by its entryNumber when the original has no reference. A search for REV- plus an entry number therefore misses the reversal of every entry that carries a reference. Read reversedByEntryId to find a reversal.

Each entry can be reversed once. A second reversal is refused with 400 JOURNAL_ENTRY_ALREADY_REVERSED, and details.existingReversalId names the reversal that already stands. When two reversals of one entry are sent at the same moment, one is posted and the other is refused, either with that 400 or with 409 JOURNAL_ENTRY_REVERSAL_IN_PROGRESS while the first is still being posted. A reversal cannot itself be reversed.

The two fields cover reversals made in LedgerBee. A reversal imported from e-conomic cancels its entry through opposite amounts and is not linked to it, so both entries read null in both fields and excludeReversed=true keeps both in the list. Net those entries by their amounts.

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. The v1 and v2 reads also return a deprecated splitEntries field; read lines, which is always present.

POST /v2/draft-journal-entries and POST /v2/draft-journal-batches answer with each new draft as GET /v2/draft-journal-entries/{id} returns it; the batch returns them in entries, in request order. A retry under the same x-api-idempotency-key answers without the draft: a single create with the draft's id, a batch with "id": null. How to find the drafts then is under Idempotency.

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 originalAmount, 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:

DraftlinessplitEntries
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.

Field names on a draft

Each amount, currency and rate field on a draft is named for the currency it is in, so a field called originalAmount and one called baseCurrencyAmount cannot be mistaken for each other. The deprecated names in the table below keep working on the v1 and v2 draft endpoints, where the Where column says: a read returns both names, the deprecated one with the same value, and a request takes either name. splitEntries is the one exception to "same value": it reports the lines in its own shape, described above. The deprecated names are marked in the API Reference; use the current names in new code.

POST /v2/journal-entries and its batch take originalCurrency and originalCurrencyToBaseCurrencyExchangeRate as well, with currency and exchangeRate as their deprecated names, so a body sent to both endpoints can use the current names; see Posting an entry. A read of a posted entry returns originalCurrencyToBaseCurrencyExchangeRate and originalCurrencyToBaseCurrencyExchangeRateDate, and the same values under the deprecated exchangeRate and exchangeRateDate.

Booking a draft answers with the posted entry, where amount and currency are in the currency of the account accountId names, not the draft's: a 200 EUR draft booked to a DKK account answers amount: 1492.24 and currency: "DKK", with the 200 EUR in originalAmount and originalCurrency. See The three currency layers.

Current nameWhat it holdsWhereDeprecated name
originalAmountThe draft in the currency it was entered inrequest and responseamount on the v1 create and batch
originalCurrencyThe currency the draft was entered inrequest and responsecurrency
baseCurrencyAmountThe draft in your company base currencyresponse onlyamount on a read
originalCurrencyToBaseCurrencyExchangeRateWhat one unit of originalCurrency is worth in your base currencyv2 request, and responseexchangeRate
originalCurrencyToBaseCurrencyExchangeRateDateThe date that rate is forresponse onlyexchangeRateDate
linesEvery line of the draftv2 request, and responsesplitEntries on a read. The v1 create takes splitEntries and no lines
minBaseCurrencyAmount / maxBaseCurrencyAmountA range on baseCurrencyAmountlist filterminAmount / maxAmount

The v1 create takes no exchange rate under either name. supplierId and its deprecated vendorId are described under Deprecated fields, including the two places drafts differ: the draft list filters on supplierId when both are sent, and the v1 create stores no supplier.

A request may send either name of a pair, or both with the same value. The pairs a request can send are originalAmount / amount on the v1 create and batch; originalCurrency / currency on any draft create, batch or v2 PATCH; originalCurrencyToBaseCurrencyExchangeRate / exchangeRate on the v2 create, batch and PATCH; and minBaseCurrencyAmount / minAmount and maxBaseCurrencyAmount / maxAmount on either draft list. Both names with different values is refused with 400 DRAFT_DEPRECATED_FIELD_CONFLICT, naming the two fields in details.field and details.deprecatedField. supplierId and vendorId answer differently, as described under Deprecated fields.

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
originalCurrency"EUR"The currency the draft was raised in
originalAmount1000In originalCurrency
lines[].amount1000In originalCurrency, like originalAmount
baseCurrencyAmount7450The company base currency
originalCurrencyToBaseCurrencyExchangeRate / originalCurrencyToBaseCurrencyExchangeRateDate7.45 / "2026-08-20"Converts originalCurrency to the base currency

Where originalCurrency 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: baseCurrencyAmount 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 stores the debit total of the draft's lines. On the four counterparty entry types those lines include the AR/AP leg it appends, sized from the originalAmount you send, so your figure counts toward the stored one only when that leg is a debit: on a positive customer invoice or supplier payment, or on a negative supplier invoice or customer payment (a credit note or a refund). On journal drafts the figure you send is not stored. 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 the rate is 1: if its debits total 1,000 against credits of 1,500, it reads originalAmount: 1000 beside baseCurrencyAmount: 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.

The deprecated name amount carries two meanings: the v1 create takes it as the document figure, and every read returns it as the base-currency figure. Send a EUR 1,000 draft to the v1 create as amount: 1000 and the 201 comes back with amount: 7450. The current names keep the two apart: send originalAmount, read originalAmount and baseCurrencyAmount.

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

These routes replay the full response body for a repeated x-api-idempotency-key, so a retry after a timeout returns the original answer instead of acting a second time:

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

Creating a draft does not replay the draft. A retry of POST /v2/draft-journal-entries with the same key creates nothing and answers { "idempotentReplay": true, "id": "<the draft's id>" }; read the draft back with GET /v2/draft-journal-entries/{id}. A retry of POST /v2/draft-journal-batches answers { "idempotentReplay": true, "id": null }, which names no draft: find the batch's drafts with the list endpoint, filtered by the externalSystemId you gave them. 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
A line's VAT code adds VAT on top and has no counter account, so the entry cannot balance400 JOURNAL_ENTRY_VAT_ON_TOP_CANNOT_BALANCE with details.vatCode / details.differenceUse a VAT code that includes VAT in the amount, or give this code a counter account
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
dueDate sent on an entry type other than an invoice400 JOURNAL_ENTRY_DUE_DATE_NOT_ALLOWEDRemove dueDate
dueDate sent on an invoice without reference400 JOURNAL_ENTRY_DUE_DATE_REQUIRES_REFERENCESend the invoice number as reference, or omit dueDate
A draft create, batch, update or list sends one value under both names of a pair listed under Field names on a draft, with two different values (supplierId / vendorId answer as described under Deprecated fields)400 DRAFT_DEPRECATED_FIELD_CONFLICT with details.field, details.deprecatedField, details.message, and on a batch details.entryIndex, the entry's position in entries counting from 0Send only the name in details.field, and leave out the deprecated one
A POST /v2/journal-entries or batch request sends originalCurrency and currency, or originalCurrencyToBaseCurrencyExchangeRate and exchangeRate, with two different values400 JOURNAL_ENTRY_DEPRECATED_FIELD_CONFLICT with details.field, details.deprecatedField, details.message, and on a batch details.entryIndex, the entry's position in entries counting from 0Send only the name in details.field, and leave out the deprecated one
A payment draft with no reference has no line on the customer's or supplier's group default account or on your advances account, at booking400 JOURNAL_ENTRY_PREPAYMENT_BALANCE_ACCOUNT_INVALID with details.accountIdsSend the invoice number as reference, move the counter line to the group default account, or post a journal entry; see A payment that names no invoice
A draft names no currency at booking400 JOURNAL_ENTRY_CURRENCY_REQUIREDSet the draft's currency before booking
A part payment in your company's currency names an invoice kept on a foreign account400 JOURNAL_ENTRY_CROSS_CURRENCY_PARTIAL_REQUIRES_APPLIED_AMOUNT with details.invoiceCurrency and details.invoiceOpenAmountPost the payment in details.invoiceCurrency; see Paying an invoice in a foreign currency
journalCategoryId names no category of your company, on a v2 post or a draft create or PATCH400 with errorCode: "JOURNAL_CATEGORY_NOT_FOUND"Use an id from GET /v1/journal-categories
The category does not allow the entry's journal type, on a post or when a draft is booked400 JOURNAL_CATEGORY_TYPE_NOT_ALLOWED with details.journalType and details.allowedJournalTypesPick a category whose allowedJournalTypes includes details.journalType. For a draft, move it with PATCH /v2/draft-journal-entries/{id} and book again
date falls in a closed period or year400, one of LEDGER_ACCOUNTING_PERIOD_CLOSED_FOR_POSTING, LEDGER_ACCOUNTING_YEAR_CLOSED, LEDGER_ACCOUNTING_PERIOD_NOT_CONFIGURED, LEDGER_ACCOUNTING_YEAR_NOT_RESOLVED, LEDGER_ACCOUNTING_YEAR_PENDING_RECLOSE, with details.datePost to a date in an open period
You book a draft that a person has sent for approval in LedgerBee, or that an approver has rejected or sent back400, one of DRAFT_APPROVAL_REQUIRED (waiting for an approver), DRAFT_APPROVAL_REJECTED, DRAFT_APPROVAL_CHANGES_REQUESTED, with details.draftJournalEntryId and details.requestStored: falseHave an approver approve the entry in LedgerBee, then book again. A rejected or sent-back draft is edited and sent for approval again in LedgerBee first. With an API key, approval is requested from inside LedgerBee, not by this call. When the person who sent it also booked it, the approval posts the entry and the draft is gone
You book a draft on a user's grant (MCP) and a Journal entry approval workflow on the company matches it400 DRAFT_APPROVAL_REQUIRED with details.draftJournalEntryId and details.requestStored: trueNothing to resend. The call created the approval request, notified the approvers and stored the booking; the approval posts the entry in the name of the user who granted access. A retry asks on the same request and creates no second one
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
You reverse an entry that is already reversed400 JOURNAL_ENTRY_ALREADY_REVERSED with details.existingReversalIdNothing to resend. The reversal named in details already cancels the entry
You reverse a reversal400 JOURNAL_ENTRY_CANNOT_CORRECT_REVERSAL with details.reversalOfIdPost the original's lines again as a new entry
Another request is reversing the same entry at that moment409 JOURNAL_ENTRY_REVERSAL_IN_PROGRESS with details.idFetch the entry. If reversedByEntryId is set it is reversed; if it is null, send the reversal again

Reference

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

Last modified on November 6, 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
    • Paying an invoice in a foreign currency
    • A payment that names no invoice
    • Document date and due date
    • Matching entries to your own records
    • Journal categories
  • Reversing an entry
  • Drafts
    • Field names on a draft
    • Currency on a draft
  • Idempotency
  • Limits
  • Errors
  • Reference
JSON
JSON
JSON