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 token —
lh_live_<id>.<secret>(store like a password; shown once). - Ledger UUID — one per set of books / branch.
- Base URL —
https://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 owncurrency_code(typical for a specific bank account);currency_codeis required. A foreign-currency line is only allowed on an account whose mode permits it. For how foreign lines convert to base currency, seecustomer-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
unbalanced — create 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. Neverfloat/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_idand 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_chainper ledger; alert onok:false/first_break. - Bulk historical loads go through the operator's migration/loader path, not
a loop of
create_journal_entryin your app. - Rotate API keys, store them in a secret manager, separate per environment.