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

```json
{
  "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:

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

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

### 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](/api).

```bash
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 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](/guides/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-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](/guides/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](/guides/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](/api).
