Ledger migration recipes — bring an existing ledger in
How to load history into the ledger block — opening balances, then raw journal import through the transactions API — and what to prepare for inventory cost lots.
Updated 9/12/2026
The quickstart shows the four calls that get a new entity to a trial balance. This page is for the other starting point: you already keep books somewhere else and want them in the ledger block, with history.
There are two routes, and most migrations use both:
- Opening balances — one balanced journal that sets every account to where it stood on your cutover date. Cheapest, and the right choice when the old system stays the archive for anything earlier.
- Raw journal import — post the historical transactions themselves, one by one, through the transactions API. Use it when you need the detail (drill-down, audit, reversals) inside the block rather than in the old system.
Inventory and other lot-costed positions need one more ingredient — the cost lots — covered at the end.
Before you import
Create the entity with a cutover date. ledger.create_entity takes
effective_date; it is the reference date for the period calendar and
the accounting date of the opening journal. Make it the last day of the
period you are closing out in the old system.
Rehearse on a practice entity first. Create it with test: true,
run the whole import, read ledger.trial_balance, and compare it to the
old system's balance. Practice entities are excluded from compliance
exports, so a failed rehearsal costs nothing. Then repeat against the
real entity.
Resolve account ids up front. Historical postings name accounts by
id, never by code. ledger.list_accounts returns the chart with each
account's id, code, name and role; build your code → id map from
it once. Filter by role prefix (asset.cash, liability.tax) when you
are mapping an old chart onto the block's semantic roles.
Register every unit you post in. Fiat, crypto and quantity units each
carry a scale (decimal places, at most 18) and a min_increment; a
posting in a unit the entity does not know is rejected. Units are listed
and registered under /v1/accounts/{accountSlug}/ledger/units.
Route 1 — opening balances
Pass opening_balances to ledger.create_entity and the block posts one
journal on effective_date. Amounts are signed decimal strings,
debit-positive. You list the asset, liability, income and expense
positions; the chart template names an opening-balance equity account
and the block infers its amount as the balancing figure, so the journal
always ties without you computing the equity line. Balances in a unit
other than the functional currency are translated at the rate the book
resolves for that date, so register those FX rates first.
// tool: ledger.create_entity
{
"account_slug": "acme",
"legal_name": "Acme Trading Ltd",
"functional_currency": "USD",
"fiscal_year_end": "12-31",
"framework": "us-gaap",
"chart_template": "us-gaap/smb-v1",
"effective_date": "2026-08-31",
"opening_balances": [
{ "account_code": "1000", "amount": "48210.55" },
{ "account_code": "1100", "amount": "12980.00" },
{ "account_code": "2000", "amount": "-7440.20" }
]
}
The result's opening_transaction_id is the journal that was posted.
Read ledger.trial_balance with as_of set to the cutover date and
check functional_total is 0 and every line matches the old system.
Route 2 — raw journal import
Historical transactions go through the raw posting path, which takes explicit postings instead of a business event. The same operation is available two ways:
- MCP:
ledger.post_transaction— dry-run first, then commit with theconfirmation_tokenthe dry run returned. - REST:
POST /v1/accounts/{accountSlug}/ledger/transactions— the same body in camelCase;dryRun: truefor the preview, and every commit carries anIdempotency-Keyheader.
The REST route is the natural fit for a batch import: it is the surface a script drives, and the idempotency key is what makes a re-run after a network failure safe.
One transaction
POST /v1/accounts/acme/ledger/transactions
Authorization: Bearer <token>
Idempotency-Key: import-2026-03-14-inv-1042
Content-Type: application/json
{
"entityId": "6c1f0f1e-6c1b-4a9d-9c0e-2e0b1b1c4d21",
"bookId": "b3a7c4e8-1d2f-4f7a-9b6e-5c8d2a1f0e33",
"occurredAt": "2026-03-14T09:12:00Z",
"effectiveDate": "2026-03-14",
"description": "Invoice 1042 — Northwind",
"externalRef": "old-system:je:88213",
"postings": [
{ "accountId": "<id of 1100 Accounts receivable>", "unit": "USD", "quantity": "1250.00" },
{ "accountId": "<id of 4000 Sales revenue>", "unit": "USD", "quantity": "-1250.00" }
],
"provenance": { "rule": "migration:old-system-v3", "evidence": [{ "uri": "s3://exports/je-88213.json", "hash": "sha256:…" }] },
"dryRun": true
}
The dry run answers with the exact journal, the before/after balances of
every touched account, the validations that ran, and a
confirmationToken. Commit by sending the same body again with
dryRun removed and confirmationToken set. The response is either
{ "outcome": "posted", "transaction": … } or
{ "outcome": "proposed", "proposal": … } — the auto-post policy can
park an entry for review, and a parked entry has not been posted.
For a bulk import by a human operator with high stated confidence this is
rare, but the importer must check outcome rather than assume.
Keep the old system's identifier in externalRef on every transaction.
It is what lets you reconcile the two sides afterwards, and
ledger.list_transactions returns it.
The invariants that will reject rows
These are enforced in the database, not only in the API, so no client path gets around them. An importer that has not prepared for them stops on the first bad row.
Every transaction must balance, per book, in functional currency.
SUM(functional_amount) over a transaction's postings must be exactly
0. The check is a deferred constraint that runs at commit, so a
transaction that does not balance is rejected as a whole — no header,
no legs, nothing partial to clean up. The error names the sum it found.
You may omit quantity on at most one leg to have it inferred as the
balancing figure, which is the simplest way to guarantee this.
Every quantity must be an exact multiple of the unit's
min_increment. A USDC unit registered with min_increment
0.000001 refuses a quantity of 0.0000005, and the error says which
unit and which increment. If your source system carries more precision
than the unit allows, round before you post and book the rounding
difference explicitly — the block will not round for you.
Quantities carry 18 decimal places and do not drift. A quantity such
as 1.234567890123456789 is stored, read back and aggregated exactly.
Send amounts as decimal strings; if your importer parses them into
floating-point numbers on the way through, the drift you introduce is
yours, and it will show up as a trial balance that no longer ties.
Dates are two different things. occurredAt is when the economic
event happened (a timestamp); effectiveDate is the accounting date
and decides the period. Backdating into a closed period is rejected;
close periods only after their history is in.
Corrections are reversals, never edits. A posted transaction is
immutable. If an imported row turns out wrong, post a reversal (the
reverses field, or POST …/transactions/{id}/reverse) and re-post it
correctly. Plan the import so this is the exception, not the mechanism.
Foreign-currency legs
A leg whose unit differs from the book's functional currency needs a
functionalAmount and, for the audit trail, the rate, rateType
(spot, daily, average, closing, historical) and rateSource
used. Register the FX rates you relied on under
/v1/accounts/{accountSlug}/ledger/fx-rates so later revaluations start
from the same numbers.
Ordering and pacing
Post in effectiveDate order, oldest first, so that period assignment
and any lot consumption see history in the order it happened. The block
enforces a posting-value budget per account; ledger.get_limits reports
what is left in the current window so a long import can pace itself
instead of hitting the cap mid-batch.
Cost lots for inventory and digital assets
Accounts whose chart entry carries a booking method (FIFO, LIFO,
AVERAGE, HIFO, STRICT, SPEC_ID — inventory and digital-asset
accounts in the standard chart) are lot-costed: the block keeps a
cost lot per acquisition and consumes lots in the account's method when
units leave. Everything downstream — cost of goods sold, write-downs,
purchase-price variances, average-cost rebasing — reads those lots. A
migration that brings the balances in but not the lots produces
quantities and values that tie today and costing that is silently wrong
from the first sale onwards.
What a lot is, in the block's terms:
| Field | Meaning |
|---|---|
| account + unit + wallet | The partition the lot lives in. wallet is the location or wallet address; lots never cross partitions. |
quantity | Units acquired, decimal string, positive. |
cost_basis_amount | Total cost of the lot in the book's functional currency (or a stated basis unit). |
acquired_at | Timestamp; the ordering key for FIFO/LIFO/HIFO selection. |
| source posting | The journal leg the lot came from — for imported history, the receipt you posted in route 2. |
To prepare:
- Export open lots only from the old system — each remaining layer with its acquired date, remaining quantity and remaining basis, per location or wallet. Consumed history is not needed; the block starts consuming from what is open.
- Make sure the sum of open-lot quantities equals the account's quantity balance, and the sum of their bases equals its functional balance, per partition. A mismatch here is the number-one cause of a costing run that does not reconcile.
- Post the receipts that carry those balances in route 2 (or route 1), so every lot has a source posting to point at.
Lot seeding is not yet self-serve through the API or the MCP tools: the lot layer is engaged by the block's own costing flows, and importing prepared lots is run for you as part of onboarding. Have the export from step 1 ready in that shape and ask for the import when your journal history is in. Consumption never exceeds what the open lots hold — an oversell is rejected unless the account explicitly allows negative lots — so a partition that was seeded short shows up on the first sale, not at year end.
Checking the result
ledger.trial_balanceon the cutover date ties to the old system, andfunctional_totalis0.ledger.explain_balanceon a handful of accounts shows the imported postings, with theirexternalRef, as the contributors.ledger.list_transactionsfiltered by period returns the count you exported.- Only then close the migrated periods with
ledger.close_period. Close is one-way and materialises the balances, which is exactly what you want for history nobody should touch again.
Ask Zero
Ask a question about connect0 and get an answer grounded in the docs, with links to the sources. Signed in? Zero answers with your account in mind.