TaskOther

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 the confirmation_token the dry run returned.
  • REST: POST /v1/accounts/{accountSlug}/ledger/transactions — the same body in camelCase; dryRun: true for the preview, and every commit carries an Idempotency-Key header.

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:

FieldMeaning
account + unit + walletThe partition the lot lives in. wallet is the location or wallet address; lots never cross partitions.
quantityUnits acquired, decimal string, positive.
cost_basis_amountTotal cost of the lot in the book's functional currency (or a stated basis unit).
acquired_atTimestamp; the ordering key for FIFO/LIFO/HIFO selection.
source postingThe journal leg the lot came from — for imported history, the receipt you posted in route 2.

To prepare:

  1. 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.
  2. 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.
  3. 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_balance on the cutover date ties to the old system, and functional_total is 0.
  • ledger.explain_balance on a handful of accounts shows the imported postings, with their externalRef, as the contributors.
  • ledger.list_transactions filtered 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.