LedgerHashJoin the waitlist

Onboarding

Integration guide

The fast path to integrating LedgerHash as the immutable ledger behind your own accounting or finance product. For the full request/response reference, see the API docs.

Customer Getting Started

You're an engineer integrating LedgerHash as the immutable ledger behind your own accounting/finance product. This is the fast path from credentials to a posted, hash-verified entry — and reading your books back.

0. What you received

From the operator, over a secure channel:

  • Bearer tokenlh_live_<id>.<secret> (store like a password; shown once).
  • Ledger UUID — one per set of books / branch.
  • Base URLhttps://vo2q4j12r0.execute-api.eu-west-2.amazonaws.com/v1 (dev).

1. The request shape

Everything is POST {BASE}/rpc/{function_name} with three headers and a JSON body of p_-prefixed args:

Authorization: Bearer lh_live_<id>.<secret>
X-LedgerHash-Ledger: <ledger-uuid>
Content-Type: application/json

Response is { "success": true, "data": {…} } or { "success": false, "error": "PREFIX: …", "code": "…" }. Branch on success, switch on code. Money is a decimal string ("100.00"), dates are YYYY-MM-DD, timestamps are Unix epoch ms integers.

Full conventions: ../api/README.md. Every endpoint: ../api/endpoints.md / ../api/openapi.yaml.

2. Health check (no auth)

curl -s {BASE}/health     # {"status":"ok",...}

3. Create two accounts

curl -s -X POST -H "$AUTH" -H "$LED" -H "$CT" {BASE}/rpc/create_account \
  -d '{"p_ledger_id":"<L>","p_code":"1000","p_name":"Cash","p_type":"asset","p_currency_mode":"fixed","p_currency_code":"GBP"}'
curl -s -X POST -H "$AUTH" -H "$LED" -H "$CT" {BASE}/rpc/create_account \
  -d '{"p_ledger_id":"<L>","p_code":"4000","p_name":"Sales","p_type":"revenue","p_currency_mode":"fixed","p_currency_code":"GBP"}'

Save each returned data.id.

4. Create + post a journal entry

# create draft (>=2 lines; may be unbalanced at draft stage)
curl -s -X POST -H "$AUTH" -H "$LED" -H "$CT" {BASE}/rpc/create_journal_entry \
  -d '{"p_ledger_id":"<L>","p_reference":"INV-1","p_effective_date":"2026-04-22","p_currency_code":"GBP",
       "p_lines":[{"account_id":"<CASH>","debit_amount":"100.00"},{"account_id":"<SALES>","credit_amount":"100.00"}]}'
# post (seals into the hash chain; balance enforced here)
curl -s -X POST -H "$AUTH" -H "$LED" -H "$CT" -H "Idempotency-Key: post-INV-1" {BASE}/rpc/post_journal_entry \
  -d '{"p_ledger_id":"<L>","p_entry_id":"<ENTRY_ID>"}'

Always send an Idempotency-Key on post/reverse/close_period so retries are safe.

5. Read your books back

curl -s -X POST -H "$AUTH" -H "$LED" -H "$CT" {BASE}/rpc/trial_balance     -d '{"p_ledger_id":"<L>"}'
curl -s -X POST -H "$AUTH" -H "$LED" -H "$CT" {BASE}/rpc/balance_sheet     -d '{"p_ledger_id":"<L>"}'
curl -s -X POST -H "$AUTH" -H "$LED" -H "$CT" {BASE}/rpc/income_statement  -d '{"p_ledger_id":"<L>"}'
curl -s -X POST -H "$AUTH" -H "$LED" -H "$CT" {BASE}/rpc/verify_hash_chain -d '{"p_ledger_id":"<L>"}'

6. Foreign currency

Post a foreign line by adding its currency_code + exchange_rate (foreign→base); LedgerHash converts to base currency and records the rate:

{"account_id":"<EXPENSE>","debit_amount":"100.00","currency_code":"USD","exchange_rate":"0.80"}

That stores base_debit_amount = "80.00". The entry must balance in base currency. A foreign line with no rate is rejected (FX_RATE_REQUIRED). The account must permit the currency (currency_mode any or fixed to it).

7. Corrections

You never edit or delete a posted entry. To correct one, call reverse_journal_entry (posts a mirror, marks the original reversed) and book a replacement. Find entries with list_journal_entries / get_journal_entry.

8. Mirroring an upstream system (Xero/QB/Sage)

For each upstream entry: translate to lines, create_journal_entry with the upstream id in p_source_id, then post_journal_entry with Idempotency-Key: <upstream>:{id}. Read back / verify_hash_chain nightly.

9. Use the client

New to double-entry / the model? Read concepts-and-best-practices.md first.

Clients: Python../../examples/python/ledgerhash_client.py (below); Node.js../../examples/node/. The Python client wraps every endpoint with auth + idempotency + error handling:

lh = LedgerHash(base_url=..., bearer=..., ledger_id=...)
cash = lh.create_account(code="1000", name="Cash", type="asset", currency_mode="fixed", currency_code="GBP")["id"]
draft = lh.create_journal_entry(reference="INV-1", effective_date="2026-04-22", currency_code="GBP",
                                lines=[{"account_id":cash,"debit_amount":"100.00"}, ...])
lh.post_journal_entry(entry_id=draft["id"], idempotency_key="post-INV-1")
lh.trial_balance(); lh.verify_hash_chain()

10. Common errors

code / prefix Fix
401 unauthorized Check both headers; the bearer includes the lh_live_ prefix; key must be granted on this ledger.
400 bad_request Body p_ledger_id must equal the header ledger; no unknown fields (schemas are strict).
403 forbidden Your key's level is too low for this endpoint.
23514 / ENTRY_UNBALANCED Debits ≠ credits in base currency.
23514 / FX_RATE_REQUIRED Foreign line needs an exchange_rate.
code_conflict / already_exists Duplicate account code / entity ref.

Multi-branch, rate limits, and more: ../api/README.md.

Concepts & Best Practices

For the engineer integrating LedgerHash. The mental model behind the API; pairs with customer-getting-started.md.

Core objects

  • Ledger — one tenant's set of books; the isolation boundary (everything hangs off ledger_id). One legal entity = one ledger. A group with 5 subsidiaries = 5 ledgers (see multi-branch below).
  • Account — a chart-of-accounts line. Five canonical types (asset/liability/equity/revenue/expense).
  • Entity — an optional sub-ledger dimension on a line (customer, supplier, employee…). The who, vs the account's what.
  • Journal entry — a double-entry transaction (≥2 lines, debits = credits); draft → posted → reversed.
  • Dimension — an analytic tag on a line (department, project, branch) for segment reporting (balance_by_dimension).

Normal side (debit/credit)

Each account has a normal_side: debit for assets & expenses, credit for liabilities, equity & revenue. Omit p_normal_side on create_account and the server derives it from type; supplying a value that contradicts the type is rejected. A positive reported balance always means "more of the account's natural side."

Currency modes

create_account(p_currency_mode=…):

  • any — accepts lines in any currency (typical for P&L accounts that you post foreign expenses to).
  • home_only — only the ledger's default currency.
  • fixed — only the account's own currency_code (typical for a specific bank account); currency_code is required. A foreign-currency line is only allowed on an account whose mode permits it. For how foreign lines convert to base currency, see customer-getting-started.md §6 and ../schema/database.md.

Entity as a sub-ledger

The common AR/AP pattern: one control account (e.g. 1100 Accounts Receivable) plus each customer as an entity. "How much does Acme owe us?" is then Acme's balance on the AR account — read via entity_balances, not a separate account per customer.

Draft vs posted (and why drafts can be unbalanced)

A create_journal_entry draft has no hash, no balance impact, and may be unbalancedcreate accepts it; balance is enforced at post, which seals the entry into the hash chain (immutable thereafter). Don't rely on create to reject an imbalance — validate before, and check at post.

Note: there is no public RPC to edit or delete a draft today (update_draft_entry is planned, not live). So treat create as "commit this shape": if a draft is wrong, just create a corrected entry and post that one (an un-posted draft has no balance/chain effect). Don't build a flow that assumes you can mutate a draft in place via the API.

Periods

If a ledger is created with periods_required = true, every entry's effective_date must fall in an open period, else post is rejected. Closing a period (close_period, admin) seals it with a period hash / merkle root and blocks back-dated posting into it. (Creating periods is operator-side today.)

Corrections — never edit, always reverse

Posted entries are immutable. To correct one, reverse_journal_entry posts a mirror (debits/credits swapped), marks the original reversed, and links them. Reads net the pair to zero in base currency. Find the entry first with list_journal_entries / get_journal_entry.

Multi-branch (one company, many ledgers)

Each branch is its own ledger (own COA, own chain, own RLS). One API key can be granted on all of them; the customer switches branch by changing the X-LedgerHash-Ledger header. There is no companies table — the grouping is a convention in ledgers.metadata (see ../security/auth-and-permissions.md). Chains are independent and not consolidated; group reports are assembled client-side (or via a future consolidate_* family).

Best practices

  • Treat LedgerHash as append-only from your side — create, post, reverse; never expect to edit posted entries.
  • One ledger per legal entity. Don't mix books.
  • Money in decimal/numeric, passed as strings. Never float/JSON number.
  • Idempotency key on every write (post/reverse/close_period); unique per logical operation, stable across retries.
  • Store ids, not names (account_id, entity_id).
  • Put your upstream id in p_source_id and key idempotency off it (xero:{id}) so you can find/reconcile entries later.
  • Don't power a live dashboard from the report RPCs — they're for compliance/statement outputs; cache in your own store for sub-second reads.
  • Verify nightly: schedule verify_hash_chain per ledger; alert on ok:false/first_break.
  • Bulk historical loads go through the operator's migration/loader path, not a loop of create_journal_entry in your app.
  • Rotate API keys, store them in a secret manager, separate per environment.