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 |
originalCurrencyToBaseCurrencyExchangeRate / originalCurrencyToBaseCurrencyExchangeRateDate | 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
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
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? | 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.
Code
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 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.
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
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
Both fields are optional and take YYYY-MM-DD. A date that does not exist,
such as 2026-02-30, is refused.
| Field | When omitted |
|---|---|
documentDate | The entry has no document date. Its VAT period follows date, and the read returns documentDate: null |
dueDate | Calculated 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:
| Field | On the reversed entry | On the reversal | On every other entry |
|---|---|---|---|
reversedByEntryId | the reversal's id | null | null |
reversalOfEntryId | null | the reversed entry's id | null |
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
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:
| Draft | 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.
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 name | What it holds | Where | Deprecated name |
|---|---|---|---|
originalAmount | The draft in the currency it was entered in | request and response | amount on the v1 create and batch |
originalCurrency | The currency the draft was entered in | request and response | currency |
baseCurrencyAmount | The draft in your company base currency | response only | amount on a read |
originalCurrencyToBaseCurrencyExchangeRate | What one unit of originalCurrency is worth in your base currency | v2 request, and response | exchangeRate |
originalCurrencyToBaseCurrencyExchangeRateDate | The date that rate is for | response only | exchangeRateDate |
lines | Every line of the draft | v2 request, and response | splitEntries on a read. The v1 create takes splitEntries and no lines |
minBaseCurrencyAmount / maxBaseCurrencyAmount | A range on baseCurrencyAmount | list filter | minAmount / 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:
| Field | Value | Denomination |
|---|---|---|
originalCurrency | "EUR" | The currency the draft was raised in |
originalAmount | 1000 | In originalCurrency |
lines[].amount | 1000 | In originalCurrency, like originalAmount |
baseCurrencyAmount | 7450 | The company base currency |
originalCurrencyToBaseCurrencyExchangeRate / originalCurrencyToBaseCurrencyExchangeRateDate | 7.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-entriesPOST /v2/journal-entries/batchPOST /v2/draft-journal-entries/{id}/bookPOST /v2/draft-journal-entries/{id}/attachmentsPOST /v1/draft-journal-entries/{id}/bookPOST /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
| 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 |
| A line's VAT code adds VAT on top and has no counter account, so the entry cannot balance | 400 JOURNAL_ENTRY_VAT_ON_TOP_CANNOT_BALANCE with details.vatCode / details.difference | Use a VAT code that includes VAT in the amount, or give this code a counter account |
| 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 |
dueDate sent on an entry type other than an invoice | 400 JOURNAL_ENTRY_DUE_DATE_NOT_ALLOWED | Remove dueDate |
dueDate sent on an invoice without reference | 400 JOURNAL_ENTRY_DUE_DATE_REQUIRES_REFERENCE | Send 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 0 | Send 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 values | 400 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 0 | Send 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 booking | 400 JOURNAL_ENTRY_PREPAYMENT_BALANCE_ACCOUNT_INVALID with details.accountIds | Send 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 booking | 400 JOURNAL_ENTRY_CURRENCY_REQUIRED | Set the draft's currency before booking |
| A part payment in your company's currency names an invoice kept on a foreign account | 400 JOURNAL_ENTRY_CROSS_CURRENCY_PARTIAL_REQUIRES_APPLIED_AMOUNT with details.invoiceCurrency and details.invoiceOpenAmount | Post 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 PATCH | 400 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 booked | 400 JOURNAL_CATEGORY_TYPE_NOT_ALLOWED with details.journalType and details.allowedJournalTypes | Pick 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 year | 400, 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.date | Post 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 back | 400, one of DRAFT_APPROVAL_REQUIRED (waiting for an approver), DRAFT_APPROVAL_REJECTED, DRAFT_APPROVAL_CHANGES_REQUESTED, with details.draftJournalEntryId and details.requestStored: false | Have 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 it | 400 DRAFT_APPROVAL_REQUIRED with details.draftJournalEntryId and details.requestStored: true | Nothing 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 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 |
| You reverse an entry that is already reversed | 400 JOURNAL_ENTRY_ALREADY_REVERSED with details.existingReversalId | Nothing to resend. The reversal named in details already cancels the entry |
| You reverse a reversal | 400 JOURNAL_ENTRY_CANNOT_CORRECT_REVERSAL with details.reversalOfId | Post the original's lines again as a new entry |
| Another request is reversing the same entry at that moment | 409 JOURNAL_ENTRY_REVERSAL_IN_PROGRESS with details.id | Fetch 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.