API Reference

The LedgerHash /v1 API.

Every endpoint, every parameter with its exact restrictions, every error code — and a real example for each, captured live from a demo company. Read endpoints are runnable right here against that demo ledger.

Conventions

Base URL & auth

https://inmz19azze.execute-api.eu-west-2.amazonaws.com

Authorization: Bearer lh_live_<key_id>.<secret>   # production
Authorization: Bearer lh_test_<key_id>.<secret>   # test

The key IS the address: it names your account and your ledger. No tenant header, no ledger id in any body or query. Every auth failure returns an identical 401 — there is nothing to probe. Key tiers form a ladder: read < write < admin; a higher key satisfies a lower requirement.

The envelope

{ "ok": true,  "data": { … },            "requestId": "…" }
{ "ok": false, "errorCode": "ENTRY_UNBALANCED",
  "message": "…",                          "requestId": "…" }

Every response, success or failure, carries the same camelCase envelope. Every failure is a registered error code — never a naked 500. Keep the requestId when contacting support.

Amounts & dates

Amounts are integer minor units (cents, pence, satoshi…) from 0 to 1038−1 — sent as JSON numbers or digit strings; values above 253 MUST be strings. All amounts come back as exact digit strings, so crypto-scale precision survives every language's JSON parser. Timestamps are RFC 3339 UTC (Z); calendar dates are YYYY-MM-DD within 1900-01-01 to 2200-12-31.

HTTP status map

  • 200 success · 400 validation/domain refusal · 401 auth (uniform) · 403 key tier too low
  • 404 path resource missing · 409 conflict/taken/already-done · 429 throttled
  • 503 SERVICE_BUSY — safe to retry · 500 INTERNAL (safe message, never a leak)

Idempotency — retries are always safe

Every write accepts an Idempotency-Key header (on POST /v1/entries the body field idempotencyKey is required and sealed with the entry). One key maps to exactly one permanent outcome: replaying a succeeded key returns the original 200; the same key with different content is refused with 409 IDEMPOTENCY_CONFLICT; concurrent duplicates produce exactly one result. Transient 5xx never poison a key. Check any key's fate with GET /v1/idempotency-requests/{key}.

The demo ledger behind every example

Every example below is a REAL request/response pair captured from Demo Company 03 — a live company on the staging environment: USD base currency, a GBP FX purchase, a customer sub-ledger, department dimensions, a reversal, and a Merkle-sealed January. Endpoints marked runnable execute against that same ledger from your browser through a rate-limited read-only proxy — the responses you see are computed by the engine at that moment, hashes and all. Write endpoints show captured examples only.

Setup

GET/v1/ledgerread key

Read the ledger addressed by the API key. The key IS the address — no ledger id ever travels in a body or query.

No parameters.

returns (inside the envelope's data)

  • namestring
  • baseCurrencyCodestring
  • presentationCurrencyCodestring | null
  • status"active" | "suspended" | "closed"
Error codes this endpoint can return (1)
LEDGER_NOT_FOUND

Real example — captured live from the demo company on staging:

request · GET /v1/ledger

GET /v1/ledger
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "baseCurrencyCode": "USD",
    "name": "Demo Company 03",
    "presentationCurrencyCode": null,
    "status": "active"
  },
  "requestId": "79c7815f-2f6a-4719-9ca9-bb9ee9181f9f"
}
POST/v1/accountsadmin key

Create a chart-of-accounts account.

parameterintyperequiredrestrictions
codebodystringrequired1–20 chars, ^[A-Za-z0-9][A-Za-z0-9._-]*$, no spaces; trimmed; unique per ledger; immutable forever
namebodystringrequired1–120 chars, any language; no control/invisible chars; trimmed, inner whitespace collapsed
accountTypebodystringrequiredasset | liability | equity | revenue | expense — immutable
normalSidebodystringrequireddebit | credit — immutable
parentCodebodystring | nulloptionalmust be an existing account in this ledger; null = top level; cycles refused
postingAllowedbodybooleanoptionaldefault true; false = header account (subtotals only, no postings)

returns (inside the envelope's data)

  • codestring
  • namestring
  • accountTypestring
  • normalSidestring
  • parentCodestring | null
  • postingAllowedboolean
  • activeboolean

Idempotency: Idempotency-Key header (optional). Same key + same body replays the original 200; the per-ledger UNIQUE code is the second guard (409 COA_CODE_TAKEN).

Error codes this endpoint can return (13)
COA_CODE_REQUIREDCOA_CODE_INVALIDCOA_CODE_TAKENCOA_NAME_REQUIREDCOA_NAME_TOO_LONGCOA_NAME_INVALID_CHARSCOA_TYPE_INVALIDCOA_NORMAL_SIDE_INVALIDCOA_PARENT_NOT_FOUNDCOA_PARENT_CYCLELEDGER_NOT_ACTIVEIDEMPOTENCY_CONFLICTIDEMPOTENCY_REPLAYED_FAILURE

Real example — captured live from the demo company on staging:

request · POST /v1/accounts

{
  "code": "6000",
  "name": "Marketing costs",
  "accountType": "expense",
  "normalSide": "debit"
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "accountType": "expense",
    "active": true,
    "code": "6000",
    "name": "Marketing costs",
    "normalSide": "debit",
    "parentCode": null,
    "postingAllowed": true
  },
  "requestId": "00678c53-291b-4341-af61-150c8d32c708"
}
GET/v1/accountsread key

List the chart of accounts, ordered by code.

parameterintyperequiredrestrictions
activeOnlyquerybooleanoptionaldefault false

returns (inside the envelope's data)

  • (array)Account[] — data is a bare array on this route

Real example — captured live from the demo company on staging:

request · GET /v1/accounts?activeOnly=true

GET /v1/accounts?activeOnly=true
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": [
    {
      "accountType": "asset",
      "active": true,
      "code": "1000",
      "name": "Cash at bank",
      "normalSide": "debit",
      "parentCode": null,
      "postingAllowed": true
    },
    {
      "accountType": "asset",
      "active": true,
      "code": "1100",
      "name": "Accounts receivable",
      "normalSide": "debit",
      "parentCode": null,
      "postingAllowed": true
    },
    {
      "accountType": "liability",
      "active": true,
      "code": "2000",
      "name": "Accounts payable",
      "normalSide": "credit",
      "parentCode": null,
      "postingAllowed": true
    },
    {
      "accountType": "equity",
      "active": true,
      "code": "3000",
      "name": "Share capital",
      "normalSide": "credit",
      "parentCode": null,
      "postingAllowed": true
    },
    {
      "accountType": "revenue",
      "active": true,
      "code": "4000",
      "name": "Sales revenue",
      "normalSide": "credit",
      "parentCode": null,
      "postingAllowed": true
    },
    {
      "accountType": "expense",
      "active": true,
      "code": "5000",
      "name": "Purchases",
      "normalSide": "debit",
      "parentCode": null,
      "postingAllowed": true
    },
    {
      "accountType": "expense",
      "active": true,
      "code": "6000",
      "name": "Marketing costs",
      "normalSide": "debit",
      "parentCode": null,
      "postingAllowed": true
    },
    {
      "accountType": "expense",
      "active": true,
      "code": "7000",
      "name": "FX gains and losses",
      "normalSide": "debit",
      "parentCode": null,
      "postingAllowed": true
    }
  ],
  "requestId": "a702a698-771c-489d-a52b-23871554afdd"
}
PUT/v1/accounts/{code}/report-settingsadmin key

Upsert an account's reporting flags: cash account, cost-of-sales, cash-flow section.

parameterintyperequiredrestrictions
codepathstringrequiredan existing COA account code
isCashbodybooleanoptionaldefault false; only an asset account may be true — drives the cash-flow statement
isCostOfSalesbodybooleanoptionaldefault false; only an expense account may be true — drives gross profit
cashflowSectionbodystring | nulloptionaloperating | investing | financing; null = derive from accountType

returns (inside the envelope's data)

  • codestring
  • isCashboolean
  • isCostOfSalesboolean
  • cashflowSectionstring | null

Idempotency: Naturally idempotent upsert — same body, same state.

Error codes this endpoint can return (2)
REPORT_ACCOUNT_NOT_FOUNDREPORT_SETTINGS_INVALID

Real example — captured live from the demo company on staging:

request · PUT /v1/accounts/1000/report-settings

{
  "isCash": true
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "cashflowSection": null,
    "code": "1000",
    "isCash": true,
    "isCostOfSales": false
  },
  "requestId": "be03ed9d-952a-46ba-984a-826c03a8a130"
}
POST/v1/journalsadmin key

Create a posting book (journal).

parameterintyperequiredrestrictions
codebodystringrequired1–30 chars, ^[a-z][a-z0-9_]*$; trimmed AND lowercased ("GEN" becomes "gen"); unique per ledger; immutable
namebodystringrequired1–120 chars; no control/invisible chars; trimmed, whitespace collapsed
kindbodystringoptionalgeneral | adjustment | system — default general; immutable

returns (inside the envelope's data)

  • codestring
  • namestring
  • kindstring
  • activeboolean

Idempotency: Idempotency-Key header (optional); UNIQUE code is the second guard.

Error codes this endpoint can return (8)
JOURNAL_CODE_REQUIREDJOURNAL_CODE_INVALIDJOURNAL_CODE_TAKENJOURNAL_NAME_REQUIREDJOURNAL_NAME_TOO_LONGJOURNAL_NAME_INVALID_CHARSJOURNAL_KIND_INVALIDLEDGER_NOT_ACTIVE

Real example — captured live from the demo company on staging:

request · POST /v1/journals

{
  "code": "docs",
  "name": "Docs example journal"
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "active": true,
    "code": "docs",
    "kind": "general",
    "name": "Docs example journal"
  },
  "requestId": "744c35d5-bb1c-4894-a461-8649bd618921"
}
GET/v1/journalsread key

List journals, ordered by code.

No parameters.

returns (inside the envelope's data)

  • (array)Journal[] — data is a bare array on this route

Real example — captured live from the demo company on staging:

request · GET /v1/journals

GET /v1/journals
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": [
    {
      "active": true,
      "code": "docs",
      "kind": "general",
      "name": "Docs example journal"
    },
    {
      "active": true,
      "code": "gen",
      "kind": "general",
      "name": "General journal"
    },
    {
      "active": true,
      "code": "sales",
      "kind": "general",
      "name": "Sales journal"
    }
  ],
  "requestId": "785a3c1b-2c11-4b0a-ade9-f2991cd61ce8"
}
POST/v1/entitiesadmin key

Create a party (customer, supplier, employee, other) for sub-ledger tracking.

parameterintyperequiredrestrictions
entityRefhashedbodystringrequired1–80 chars, printable, NO spaces, no control/invisible chars; trimmed; unique per ledger; immutable forever (sealed into entry fingerprints)
entityTypebodystringrequiredcustomer | supplier | employee | other — immutable
namebodystringrequired1–120 chars, any language; no control/invisible chars; trimmed, whitespace collapsed

returns (inside the envelope's data)

  • entityRefstring
  • entityTypestring
  • namestring
  • activeboolean

Idempotency: Idempotency-Key header (optional); UNIQUE entityRef is the second guard.

Error codes this endpoint can return (8)
ENTITY_REF_REQUIREDENTITY_REF_INVALIDENTITY_REF_TAKENENTITY_TYPE_INVALIDENTITY_NAME_REQUIREDENTITY_NAME_TOO_LONGENTITY_NAME_INVALID_CHARSLEDGER_NOT_ACTIVE

Real example — captured live from the demo company on staging:

request · POST /v1/entities

{
  "entityRef": "CUST-02",
  "entityType": "customer",
  "name": "Nordwind Trading AS"
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "active": true,
    "entityRef": "CUST-02",
    "entityType": "customer",
    "name": "Nordwind Trading AS"
  },
  "requestId": "3e4abae9-382c-463e-98a7-a7e34dc46530"
}
GET/v1/entitiesread key

List parties, ordered by entityRef.

parameterintyperequiredrestrictions
entityTypequerystringoptionalexact-match filter; an unknown value returns zero rows

returns (inside the envelope's data)

  • (array)Entity[] — data is a bare array on this route

Real example — captured live from the demo company on staging:

request · GET /v1/entities?entityType=customer

GET /v1/entities?entityType=customer
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": [
    {
      "active": true,
      "entityRef": "CUST-01",
      "entityType": "customer",
      "name": "Aurora Retail Ltd"
    },
    {
      "active": true,
      "entityRef": "CUST-02",
      "entityType": "customer",
      "name": "Nordwind Trading AS"
    }
  ],
  "requestId": "eae0ded8-19ee-4051-91e2-0e1f74d64b76"
}

Transactions

POST/v1/entrieswrite key

Post a balanced double-entry page — validated, sealed with SHA-256 into the hash chain under the per-ledger lock, gapless sequence assigned.

parameterintyperequiredrestrictions
idempotencyKeyhashedbodystringrequired1–255 chars, trimmed, no control/invisible chars; sealed with the entry; same key + same content replays, same key + different content is refused
journalCodebodystringrequiredmust be an existing journal; trimmed + lowercased before lookup
effectiveDatehashedbodystringrequiredYYYY-MM-DD, 1900-01-01 to 2200-12-31
documentDatehashedbodystringoptionalYYYY-MM-DD, 1900-01-01 to 2200-12-31; independent of effectiveDate
entryTypehashedbodystringoptionalstandard | adjustment | opening — default standard; "reversal" is REFUSED here (use /reverse)
currencyCodehashedbodystringrequired2–10 chars, ^[A-Z][A-Z0-9]{1,9}$; trimmed, uppercased; must exist in the currency dictionary
exchangeRatebodystringoptionaldecimal > 0, max 10 whole + 10 fraction digits; entry-level fallback for lines without their own rate
descriptionhashedbodystringoptional1–500 chars; newline allowed, other control chars refused; trimmed; blank becomes null
referencehashedbodystringoptional1–120 chars; no control/invisible chars; trimmed, whitespace collapsed
metadatahashedbodyobjectoptionalJSON object; canonical form max 8192 bytes; numbers must be WHOLE numbers; keys sorted at canonicalization
sourceSystembodystringoptional1–80 chars, printable; provenance tag, NOT hashed
sourceDocumentTypebodystringoptional1–80 chars, printable; NOT hashed
sourceDocumentIdbodystringoptional1–255 chars, printable; NOT hashed
linesbodyarrayrequired2 to 500 line objects; every currency present must self-balance (single-currency entries), base currency always balances (FX entries)
lines[].accountCodehashedbodystringrequiredmust exist, be active, and allow posting at seal time
lines[].debithashedbodynumber | stringoptionalinteger minor units, 0 to 10^38−1; JSON number OR digit string (values above 2^53 MUST be strings); exactly ONE of debit/credit must be > 0 per line
lines[].credithashedbodynumber | stringoptionalinteger minor units, 0 to 10^38−1; JSON number OR digit string (values above 2^53 MUST be strings)
lines[].currencyCodehashedbodystringoptionaldefaults to the entry currency; 2–10 chars, ^[A-Z][A-Z0-9]{1,9}$; trimmed, uppercased; must exist in the currency dictionary
lines[].exchangeRatehashedbodystringoptionaldecimal > 0, max 10 whole + 10 fraction digits; REQUIRED on foreign-currency lines, FORBIDDEN on base-currency lines
lines[].exchangeRateUnithashedbodystringoptionalcurrency code naming the "1 unit" side of the directed quote; must be exactly the line currency or the ledger base currency; both-or-neither with exchangeRate
lines[].exchangeRateDatehashedbodystringoptionalYYYY-MM-DD, 1900-01-01 to 2200-12-31; which day's quote the rate is — sealed provenance
lines[].quantityhashedbodystringoptionaldecimal > 0, max 14 whole + 6 fraction digits
lines[].unitOfMeasurehashedbodystringoptional1–20 chars, printable, no whitespace
lines[].entityRefhashedbodystringoptionalmust exist and be active — drives the AR/AP sub-ledger
lines[].memobodystringoptional1–255 chars; no control/invisible chars; NOT hashed
lines[].dimensionshashedbodyobjectoptional{ "AXIS": "VALUE" } — codes ^[A-Z0-9_]{1,64}$; one value per axis; axis + value must be active; a required axis must appear on EVERY line

returns (inside the envelope's data)

  • entryIdstring (uuid)
  • sequenceinteger — gapless, starts at 1
  • entryHashstring — 64 hex chars (SHA-256)
  • previousHashstring — 64 hex; the GENESIS constant for entry 1
  • postedAtstring — RFC 3339 UTC (Z)
  • replayedboolean — true when this is an idempotent replay

Idempotency: idempotencyKey lives in the BODY (an Idempotency-Key header also works; the header wins). Same key + same content: the original result returns with replayed=true — never a second posting, even under concurrent duplicates. Same key + different content: 409 IDEMPOTENCY_KEY_CONFLICT. Fingerprints are computed over normalized values, so 100 and "100", "gbp" and "GBP" replay as one key.

Error codes this endpoint can return (44)
IDEMPOTENCY_KEY_INVALIDIDEMPOTENCY_KEY_CONFLICTENTRY_LINE_COUNT_INVALIDENTRY_EFFECTIVE_DATE_REQUIREDENTRY_EFFECTIVE_DATE_OUT_OF_RANGEENTRY_DOCUMENT_DATE_OUT_OF_RANGEENTRY_TYPE_INVALIDENTRY_DESCRIPTION_TOO_LONGENTRY_DESCRIPTION_INVALID_CHARSENTRY_REFERENCE_TOO_LONGENTRY_REFERENCE_INVALID_CHARSENTRY_METADATA_INVALIDENTRY_METADATA_TOO_LARGEENTRY_SOURCE_TOO_LONGENTRY_SOURCE_INVALID_CHARSENTRY_CURRENCY_UNKNOWNENTRY_CURRENCY_MISMATCHENTRY_UNBALANCEDENTRY_JOURNAL_NOT_FOUNDENTRY_REQUIRED_DIMENSION_MISSINGLINE_SIDE_INVALIDLINE_AMOUNT_INVALIDLINE_ACCOUNT_NOT_FOUNDLINE_ACCOUNT_INACTIVELINE_ENTITY_NOT_FOUNDLINE_ENTITY_INACTIVELINE_CURRENCY_UNKNOWNLINE_RATE_INVALIDLINE_RATE_REQUIREDLINE_RATE_UNIT_INVALIDLINE_RATE_DATE_OUT_OF_RANGELINE_MEMO_TOO_LONGLINE_MEMO_INVALID_CHARSLINE_QUANTITY_INVALIDLINE_UOM_INVALIDCOA_POSTING_NOT_ALLOWEDACCOUNT_CURRENCY_NOT_ALLOWEDDIMENSION_UNKNOWNDIMENSION_VALUE_UNKNOWNPERIOD_CLOSEDPERIOD_HARD_LOCKEDPERIOD_TAX_LOCKEDLEDGER_NOT_ACTIVELEDGER_BUSY

Real example — captured live from the demo company on staging:

request · POST /v1/entries

{
  "idempotencyKey": "docs-example-entry-01",
  "journalCode": "docs",
  "effectiveDate": "2026-02-10",
  "currencyCode": "USD",
  "description": "Marketing invoice paid from bank",
  "reference": "DOC-0001",
  "metadata": {
    "campaign": "spring-launch",
    "approvedBy": "cfo"
  },
  "lines": [
    {
      "accountCode": "6000",
      "debit": 42000,
      "credit": 0,
      "dimensions": {
        "DEPT": "OPS",
        "REGION": "EU"
      },
      "memo": "Spring launch ads"
    },
    {
      "accountCode": "1000",
      "debit": 0,
      "credit": 42000
    }
  ]
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "entryHash": "cf464ac54082192431c630a3a78ab41a1e89fb50f82bae5ff76cc1323bf383a0",
    "entryId": "109a3464-4264-439c-9dbe-63f2dbf3a597",
    "postedAt": "2026-08-15T19:28:56.302970Z",
    "previousHash": "7609282885456721b71647f5117644b72bee4471e2f64cc225ac09908df10dec",
    "replayed": false,
    "sequence": 8
  },
  "requestId": "85f88f2d-df80-45fa-9f81-c61efec121d1"
}

Replay: same key + same body → original result, replayed=true

Same idempotencyKey + same body sent again: the ORIGINAL result returns, replayed=true, no second posting.

request · POST /v1/entries

{
  "idempotencyKey": "docs-example-entry-01",
  "journalCode": "docs",
  "effectiveDate": "2026-02-10",
  "currencyCode": "USD",
  "description": "Marketing invoice paid from bank",
  "reference": "DOC-0001",
  "metadata": {
    "campaign": "spring-launch",
    "approvedBy": "cfo"
  },
  "lines": [
    {
      "accountCode": "6000",
      "debit": 42000,
      "credit": 0,
      "dimensions": {
        "DEPT": "OPS",
        "REGION": "EU"
      },
      "memo": "Spring launch ads"
    },
    {
      "accountCode": "1000",
      "debit": 0,
      "credit": 42000
    }
  ]
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "entryHash": "cf464ac54082192431c630a3a78ab41a1e89fb50f82bae5ff76cc1323bf383a0",
    "entryId": "109a3464-4264-439c-9dbe-63f2dbf3a597",
    "postedAt": "2026-08-15T19:28:56.302970Z",
    "previousHash": "7609282885456721b71647f5117644b72bee4471e2f64cc225ac09908df10dec",
    "replayed": false,
    "sequence": 8
  },
  "requestId": "686b674c-add1-4b57-9b3e-76cb6afb8d59"
}

Conflict: same key + different body → 409

Same idempotencyKey with DIFFERENT content: refused, the key is pinned to its first request.

request · POST /v1/entries

{
  "idempotencyKey": "docs-example-entry-01",
  "journalCode": "docs",
  "effectiveDate": "2026-02-10",
  "currencyCode": "USD",
  "description": "Marketing invoice paid from bank",
  "reference": "DOC-0001",
  "metadata": {
    "campaign": "spring-launch",
    "approvedBy": "cfo"
  },
  "lines": [
    {
      "accountCode": "6000",
      "debit": 42000,
      "credit": 0
    },
    {
      "accountCode": "1000",
      "debit": 0,
      "credit": 41000
    }
  ]
}

response · HTTP 409 (captured live)

{
  "ok": false,
  "errorCode": "IDEMPOTENCY_CONFLICT",
  "message": "this Idempotency-Key was already used for a different request",
  "requestId": "b5a0ebe9-43c3-4e3f-b2fc-9eda881870cb"
}

Refusal: unbalanced lines → 400 with the exact imbalance

Debits and credits differ — the engine refuses with the exact imbalance.

request · POST /v1/entries

{
  "idempotencyKey": "docs-unbalanced-01",
  "journalCode": "docs",
  "effectiveDate": "2026-02-11",
  "currencyCode": "USD",
  "lines": [
    {
      "accountCode": "6000",
      "debit": 10000,
      "credit": 0
    },
    {
      "accountCode": "1000",
      "debit": 0,
      "credit": 9000
    }
  ]
}

response · HTTP 400 (captured live)

{
  "ok": false,
  "errorCode": "ENTRY_UNBALANCED",
  "message": "debits and credits differ by 1000 minor units of USD",
  "requestId": "e7324345-a9ed-4678-a3f7-472118940585"
}
POST/v1/entries/{id}/reversewrite key

Post an append-only mirrored reversal of a posted entry. There is no void, no cancel, no edit — the original, the reversal, and their link stay on the chain forever.

parameterintyperequiredrestrictions
idpathstring (uuid)requiredthe posted entry to reverse
reasonbodystringoptionalmax 500 chars; newline allowed; sealed into the reversal's description
effectiveDatebodystringoptionalYYYY-MM-DD, 1900-01-01 to 2200-12-31; OMITTED = the original's date, which the period gate refuses once that month is closed — closed-period corrections must send an open-period date

returns (inside the envelope's data)

  • reversalIdstring (uuid)
  • sequenceinteger
  • entryHashstring — 64 hex

Idempotency: Idempotency-Key header (optional). A second unwrapped attempt is a controlled 409 ENTRY_ALREADY_REVERSED — exactly one reversal can ever win.

Error codes this endpoint can return (11)
ENTRY_NOT_FOUNDENTRY_NOT_POSTEDENTRY_ALREADY_REVERSEDENTRY_REVERSAL_OF_REVERSALENTRY_REVERSAL_REASON_TOO_LONGENTRY_REVERSAL_REASON_INVALID_CHARSENTRY_EFFECTIVE_DATE_OUT_OF_RANGEPERIOD_CLOSEDPERIOD_HARD_LOCKEDPERIOD_TAX_LOCKEDLEDGER_NOT_ACTIVE

Real example — captured live from the demo company on staging:

request · POST /v1/entries/1864aa3e-53f9-4ed2-aea4-1ed2f80aeceb/reverse

{
  "reason": "Posted in error — documentation example"
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "entryHash": "65f9d5bdc3d2c73656f10c11fb53280b1a4548930aa8bc848a31aa15546f53e0",
    "reversalId": "e3f39e9c-70f3-45b2-9f50-5a1a49b41cce",
    "sequence": 10
  },
  "requestId": "f2365a0a-2b8e-4302-865a-8b1c8ff0a9c2"
}
GET/v1/entries/{id}read key

Read one entry with its hashes and full lines — including the server-computed base-currency amounts (never accepted from callers).

parameterintyperequiredrestrictions
idpathstring (uuid)requirednon-UUID shapes are refused

returns (inside the envelope's data)

  • id / journalCode / effectiveDate / documentDate / currencyCodeidentity fields
  • description / reference / metadata / source*as posted (string | null / object | null)
  • status"draft" | "posted" | "reversed" | "discarded"
  • sequence / entryHash / previousHash / hashAlgorithmVersionchain fields
  • postedAt / createdAtRFC 3339 UTC
  • lines[]lineNo, accountCode, debit, credit, baseDebit, baseCredit, entityRef, memo, currencyCode, exchangeRate, exchangeRateUnit, exchangeRateDate, quantity, unitOfMeasure — ALL amounts as exact digit strings
Error codes this endpoint can return (1)
ENTRY_NOT_FOUND

Real example — captured live from the demo company on staging:

The GBP FX purchase — note per-line currency, directional rate, server-computed base amounts.

request · GET /v1/entries/e6b5e16c-6d98-44f9-9834-5fde2407ed06

GET /v1/entries/e6b5e16c-6d98-44f9-9834-5fde2407ed06
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "createdAt": "2026-08-15T19:22:37.441694Z",
    "currencyCode": "USD",
    "description": "UK consultancy invoice paid from USD cash (GBP 100.00 at 1 GBP = 1.35 USD)",
    "documentDate": null,
    "effectiveDate": "2026-01-25",
    "entryHash": "fb128a9cdaf4c6af74c95e6236c655c9a784e3869581f30a85df421f17c2ab5b",
    "entryType": "standard",
    "hashAlgorithmVersion": 1,
    "id": "e6b5e16c-6d98-44f9-9834-5fde2407ed06",
    "journalCode": "gen",
    "lines": [
      {
        "accountCode": "5000",
        "baseCredit": "0",
        "baseDebit": "13500",
        "credit": "0",
        "currencyCode": "GBP",
        "debit": "10000",
        "entityRef": null,
        "exchangeRate": "1.3500000000",
        "exchangeRateDate": "2026-01-25",
        "exchangeRateUnit": "GBP",
        "lineNo": 0,
        "memo": null,
        "quantity": null,
        "unitOfMeasure": null
      },
      {
        "accountCode": "1000",
        "baseCredit": "13500",
        "baseDebit": "0",
        "credit": "13500",
        "currencyCode": "USD",
        "debit": "0",
        "entityRef": null,
        "exchangeRate": null,
        "exchangeRateDate": null,
        "exchangeRateUnit": null,
        "lineNo": 0,
        "memo": null,
        "quantity": null,
        "unitOfMeasure": null
      }
    ],
    "metadata": null,
    "postedAt": "2026-08-15T19:22:37.461820Z",
    "previousHash": "54740a329878edf16a0bf74c4a00c5e27eee7b61dae79393afc74be6ebcc1dab",
    "reference": null,
    "sequence": 5,
    "sourceDocumentId": null,
    "sourceDocumentType": null,
    "sourceSystem": null,
    "status": "posted"
  },
  "requestId": "fcd585d6-c672-46a5-8f6e-afa34b3828a7"
}

Run “List entries” first and copy an id from the response.

GET/v1/entriesread key

List posted entries by posting sequence.

parameterintyperequiredrestrictions
fromquerystringoptionalYYYY-MM-DD; inclusive lower bound on effectiveDate
toquerystringoptionalYYYY-MM-DD; inclusive upper bound
limitqueryintegeroptionaldefault 50; clamped to 1–500
offsetqueryintegeroptionaldefault 0; negatives clamped to 0

returns (inside the envelope's data)

  • entries[]id, journalCode, effectiveDate, currencyCode, description, sequence, postedAt
  • limit / offsetinteger — as applied
Error codes this endpoint can return (1)
ENTRY_EFFECTIVE_DATE_OUT_OF_RANGE

Real example — captured live from the demo company on staging:

request · GET /v1/entries?from=2026-01-01&to=2026-02-28&limit=5

GET /v1/entries?from=2026-01-01&to=2026-02-28&limit=5
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "entries": [
      {
        "currencyCode": "USD",
        "description": "Owner capital injection",
        "effectiveDate": "2026-01-05",
        "id": "d896f762-9c74-4d43-84c4-94e08671f533",
        "journalCode": "gen",
        "postedAt": "2026-08-15T19:22:35.371651Z",
        "sequence": 1
      },
      {
        "currencyCode": "USD",
        "description": "Invoice INV-1001 — Aurora Retail",
        "effectiveDate": "2026-01-10",
        "id": "65a1fb8c-4f50-484c-ac11-9c5cf7ad96c8",
        "journalCode": "sales",
        "postedAt": "2026-08-15T19:22:36.033751Z",
        "sequence": 2
      },
      {
        "currencyCode": "USD",
        "description": "Customer receipt against INV-1001",
        "effectiveDate": "2026-01-15",
        "id": "0ba38da6-c06e-4440-b2c6-c969402cb247",
        "journalCode": "gen",
        "postedAt": "2026-08-15T19:22:36.529255Z",
        "sequence": 3
      },
      {
        "currencyCode": "USD",
        "description": "Office supplies on credit",
        "effectiveDate": "2026-01-20",
        "id": "b132bf46-9c4f-4ac1-a0ac-6cf88e93e06d",
        "journalCode": "gen",
        "postedAt": "2026-08-15T19:22:37.011653Z",
        "sequence": 4
      },
      {
        "currencyCode": "USD",
        "description": "UK consultancy invoice paid from USD cash (GBP 100.00 at 1 GBP = 1.35 USD)",
        "effectiveDate": "2026-01-25",
        "id": "e6b5e16c-6d98-44f9-9834-5fde2407ed06",
        "journalCode": "gen",
        "postedAt": "2026-08-15T19:22:37.461820Z",
        "sequence": 5
      }
    ],
    "limit": 5,
    "offset": 0
  },
  "requestId": "9f97b078-9c6e-4dfb-aee8-93b5b4993938"
}

Periods

GET/v1/periodsread key

List the ledger's monthly accounting periods and their lock state.

No parameters.

returns (inside the envelope's data)

  • periods[]periodCode (YYYY-MM), startDate, endDate, status (open | closed | locked), closedAt

Real example — captured live from the demo company on staging:

request · GET /v1/periods

GET /v1/periods
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "periods": [
      {
        "closedAt": "2026-08-15T19:22:38.989223Z",
        "endDate": "2026-01-31",
        "periodCode": "2026-01",
        "startDate": "2026-01-01",
        "status": "closed"
      },
      {
        "closedAt": null,
        "endDate": "2026-02-28",
        "periodCode": "2026-02",
        "startDate": "2026-02-01",
        "status": "open"
      }
    ]
  },
  "requestId": "88c9812a-117e-4579-8fac-50136752411c"
}
POST/v1/periods/{code}/closeadmin key

Close and seal a month: a Merkle root over every entry in the period, chained to the previous period's seal, written once under the ledger lock. Irreversible.

parameterintyperequiredrestrictions
codepathstringrequiredperiod code, exactly YYYY-MM

returns (inside the envelope's data)

  • periodCodestring
  • sealSequenceinteger — gapless per ledger
  • merkleRootstring — 64 hex
  • previousPeriodHashstring — 64 hex; period-genesis for the first seal
  • periodHashstring — SHA-256(merkleRoot ‖ previousPeriodHash)
  • entryCountinteger

Idempotency: Idempotency-Key header (optional). Atomic under the ledger lock — a concurrent duplicate gets a controlled 409, never a second seal (model-checked in TLA+).

Error codes this endpoint can return (6)
PERIOD_NOT_FOUNDPERIOD_ALREADY_CLOSEDPERIOD_CODE_INVALIDPERIOD_SEAL_INVALIDLEDGER_NOT_ACTIVELEDGER_BUSY

Real example — captured live from the demo company on staging:

request · POST /v1/periods/2026-02/close

POST /v1/periods/2026-02/close
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "entryCount": 5,
    "merkleRoot": "a787ee121bd7fb455c3a12f0cabd773846ce147158181e82f382f915e32a90ff",
    "periodCode": "2026-02",
    "periodHash": "157dab6c0bab17d4450e887988ed47c27d2aa99b027bf91ca768b98866180d22",
    "previousPeriodHash": "b4b3ce02f39c047d180ae5ead606632c6daeb74479c6f0b4b43db6ca1fc889fd",
    "sealSequence": 2
  },
  "requestId": "dbb7f2e8-0162-47fd-88e5-b92bac7f7bc9"
}

Reports

GET/v1/reports/trial-balanceread key

Every account's balance as at a date — the two columns must total equal, always.

parameterintyperequiredrestrictions
asAtquerystringrequiredYYYY-MM-DD
includeZeroquerybooleanoptionaldefault false

returns (inside the envelope's data)

  • rows[]accountCode, accountName, accountType, debit, credit — amounts as exact digit strings, base currency

Balance-sheet accounts are cumulative; revenue/expense are fiscal-year-to-date (the year-end roll is automatic by date — there is no closing journal).

Error codes this endpoint can return (1)
REPORT_DATE_INVALID

Real example — captured live from the demo company on staging:

request · GET /v1/reports/trial-balance?asAt=2026-02-28

GET /v1/reports/trial-balance?asAt=2026-02-28
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "asAt": "2026-02-28",
    "rows": [
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "accountType": "asset",
        "credit": "0",
        "debit": "5094500"
      },
      {
        "accountCode": "1100",
        "accountName": "Accounts receivable",
        "accountType": "asset",
        "credit": "0",
        "debit": "100000"
      },
      {
        "accountCode": "2000",
        "accountName": "Accounts payable",
        "accountType": "liability",
        "credit": "90000",
        "debit": "0"
      },
      {
        "accountCode": "3000",
        "accountName": "Share capital",
        "accountType": "equity",
        "credit": "5000000",
        "debit": "0"
      },
      {
        "accountCode": "4000",
        "accountName": "Sales revenue",
        "accountType": "revenue",
        "credit": "250000",
        "debit": "0"
      },
      {
        "accountCode": "5000",
        "accountName": "Purchases",
        "accountType": "expense",
        "credit": "0",
        "debit": "103500"
      },
      {
        "accountCode": "6000",
        "accountName": "Marketing costs",
        "accountType": "expense",
        "credit": "0",
        "debit": "42000"
      }
    ]
  },
  "requestId": "8eac118b-ce71-4cfb-8f6d-573cd32d98fd"
}
GET/v1/reports/general-ledgerread key

The transaction detail behind one account (or all), with opening/closing rows and a running balance.

parameterintyperequiredrestrictions
fromquerystringrequiredYYYY-MM-DD
toquerystringrequiredYYYY-MM-DD; must be ≥ from
accountCodequerystringoptionalan existing account; unknown code → 404 (not an empty result)

returns (inside the envelope's data)

  • rows[]accountCode, accountName, lineKind (opening | entry | closing), effectiveDate, journalCode, reference, description, debit, credit, running
Error codes this endpoint can return (3)
REPORT_DATE_INVALIDREPORT_DATE_RANGE_INVALIDLINE_ACCOUNT_NOT_FOUND

Real example — captured live from the demo company on staging:

request · GET /v1/reports/general-ledger?from=2026-01-01&to=2026-02-28&accountCode=1000

GET /v1/reports/general-ledger?from=2026-01-01&to=2026-02-28&accountCode=1000
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "from": "2026-01-01",
    "rows": [
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "0",
        "debit": "0",
        "description": null,
        "effectiveDate": "",
        "journalCode": null,
        "lineKind": "opening",
        "reference": null,
        "running": "0"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "0",
        "debit": "5000000",
        "description": "Owner capital injection",
        "effectiveDate": "2026-01-05",
        "journalCode": "gen",
        "lineKind": "entry",
        "reference": null,
        "running": "5000000"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "0",
        "debit": "150000",
        "description": "Customer receipt against INV-1001",
        "effectiveDate": "2026-01-15",
        "journalCode": "gen",
        "lineKind": "entry",
        "reference": null,
        "running": "5150000"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "13500",
        "debit": "0",
        "description": "UK consultancy invoice paid from USD cash (GBP 100.00 at 1 GBP = 1.35 USD)",
        "effectiveDate": "2026-01-25",
        "journalCode": "gen",
        "lineKind": "entry",
        "reference": null,
        "running": "5136500"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "0",
        "debit": "60000",
        "description": "Cash sale (posted in error — will be reversed)",
        "effectiveDate": "2026-02-03",
        "journalCode": "sales",
        "lineKind": "entry",
        "reference": null,
        "running": "5196500"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "60000",
        "debit": "0",
        "description": "Reversal: Posted in error — duplicate of a till record",
        "effectiveDate": "2026-02-03",
        "journalCode": "sales",
        "lineKind": "entry",
        "reference": "REV-",
        "running": "5136500"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "42000",
        "debit": "0",
        "description": "Marketing invoice paid from bank",
        "effectiveDate": "2026-02-10",
        "journalCode": "docs",
        "lineKind": "entry",
        "reference": "DOC-0001",
        "running": "5094500"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "5000",
        "debit": "0",
        "description": "Posted in error (docs demo)",
        "effectiveDate": "2026-02-12",
        "journalCode": "docs",
        "lineKind": "entry",
        "reference": null,
        "running": "5089500"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "0",
        "debit": "5000",
        "description": "Reversal: Posted in error — documentation example",
        "effectiveDate": "2026-02-12",
        "journalCode": "docs",
        "lineKind": "entry",
        "reference": "REV-",
        "running": "5094500"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "credit": "0",
        "debit": "0",
        "description": null,
        "effectiveDate": "",
        "journalCode": null,
        "lineKind": "closing",
        "reference": null,
        "running": "5094500"
      }
    ],
    "to": "2026-02-28"
  },
  "requestId": "67c7b50e-25e4-44b2-ba8b-091183082e15"
}
GET/v1/reports/profit-and-lossread key

Income minus expenses over a period, sectioned: Income / Cost of Sales / Gross Profit / Operating Expenses / Net Profit.

parameterintyperequiredrestrictions
fromquerystringrequiredYYYY-MM-DD
toquerystringrequiredYYYY-MM-DD; must be ≥ from

returns (inside the envelope's data)

  • rows[]sortOrder, section, lineType (detail | subtotal | total), accountCode, accountName, amount
Error codes this endpoint can return (2)
REPORT_DATE_INVALIDREPORT_DATE_RANGE_INVALID

Real example — captured live from the demo company on staging:

request · GET /v1/reports/profit-and-loss?from=2026-01-01&to=2026-02-28

GET /v1/reports/profit-and-loss?from=2026-01-01&to=2026-02-28
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "from": "2026-01-01",
    "rows": [
      {
        "accountCode": "4000",
        "accountName": "Sales revenue",
        "amount": "250000",
        "lineType": "detail",
        "section": "Income",
        "sortOrder": 10
      },
      {
        "accountCode": "",
        "accountName": "Total Income",
        "amount": "250000",
        "lineType": "subtotal",
        "section": "Income",
        "sortOrder": 19
      },
      {
        "accountCode": "5000",
        "accountName": "Purchases",
        "amount": "103500",
        "lineType": "detail",
        "section": "Operating Expenses",
        "sortOrder": 40
      },
      {
        "accountCode": "6000",
        "accountName": "Marketing costs",
        "amount": "42000",
        "lineType": "detail",
        "section": "Operating Expenses",
        "sortOrder": 40
      },
      {
        "accountCode": "",
        "accountName": "Total Operating Expenses",
        "amount": "145500",
        "lineType": "subtotal",
        "section": "Operating Expenses",
        "sortOrder": 49
      },
      {
        "accountCode": "",
        "accountName": "Net Profit",
        "amount": "104500",
        "lineType": "total",
        "section": "Net Profit",
        "sortOrder": 90
      }
    ],
    "to": "2026-02-28"
  },
  "requestId": "45e0bfcb-2352-4cf0-8f73-016dd0d3eb95"
}
GET/v1/reports/balance-sheetread key

Assets vs liabilities vs equity as at a date — Retained Earnings and Current Year Earnings are synthesized automatically from the fiscal year start.

parameterintyperequiredrestrictions
asAtquerystringrequiredYYYY-MM-DD

returns (inside the envelope's data)

  • rows[]structured sections, same row shape as profit-and-loss
Error codes this endpoint can return (1)
REPORT_DATE_INVALID

Real example — captured live from the demo company on staging:

request · GET /v1/reports/balance-sheet?asAt=2026-02-28

GET /v1/reports/balance-sheet?asAt=2026-02-28
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "asAt": "2026-02-28",
    "rows": [
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "amount": "5094500",
        "lineType": "detail",
        "section": "Assets",
        "sortOrder": 10
      },
      {
        "accountCode": "1100",
        "accountName": "Accounts receivable",
        "amount": "100000",
        "lineType": "detail",
        "section": "Assets",
        "sortOrder": 10
      },
      {
        "accountCode": "",
        "accountName": "Total Assets",
        "amount": "5194500",
        "lineType": "total",
        "section": "Assets",
        "sortOrder": 19
      },
      {
        "accountCode": "2000",
        "accountName": "Accounts payable",
        "amount": "90000",
        "lineType": "detail",
        "section": "Liabilities",
        "sortOrder": 20
      },
      {
        "accountCode": "",
        "accountName": "Total Liabilities",
        "amount": "90000",
        "lineType": "total",
        "section": "Liabilities",
        "sortOrder": 29
      },
      {
        "accountCode": "3000",
        "accountName": "Share capital",
        "amount": "5000000",
        "lineType": "detail",
        "section": "Equity",
        "sortOrder": 30
      },
      {
        "accountCode": "(current-year-earnings)",
        "accountName": "Current Year Earnings",
        "amount": "104500",
        "lineType": "detail",
        "section": "Equity",
        "sortOrder": 39
      },
      {
        "accountCode": "",
        "accountName": "Total Equity",
        "amount": "5104500",
        "lineType": "total",
        "section": "Equity",
        "sortOrder": 40
      }
    ]
  },
  "requestId": "946817ef-d527-4ba8-b802-744fc3a076ab"
}
GET/v1/reports/cash-flowread key

Direct-method statement of cash flows in three sections (operating / investing / financing), closing to the cash movement.

parameterintyperequiredrestrictions
fromquerystringrequiredYYYY-MM-DD
toquerystringrequiredYYYY-MM-DD

returns (inside the envelope's data)

  • rows[]structured sections incl. opening/closing cash rows

Requires at least one account flagged isCash via report-settings.

Error codes this endpoint can return (3)
REPORT_DATE_INVALIDREPORT_DATE_RANGE_INVALIDREPORT_NO_CASH_ACCOUNTS

Real example — captured live from the demo company on staging:

request · GET /v1/reports/cash-flow?from=2026-01-01&to=2026-02-28

GET /v1/reports/cash-flow?from=2026-01-01&to=2026-02-28
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "from": "2026-01-01",
    "rows": [
      {
        "accountCode": "",
        "accountName": "Net cash from operating activities",
        "amount": "5094500",
        "lineType": "subtotal",
        "section": "Operating",
        "sortOrder": 10
      },
      {
        "accountCode": "",
        "accountName": "Net cash from investing activities",
        "amount": "0",
        "lineType": "subtotal",
        "section": "Investing",
        "sortOrder": 20
      },
      {
        "accountCode": "",
        "accountName": "Net cash from financing activities",
        "amount": "0",
        "lineType": "subtotal",
        "section": "Financing",
        "sortOrder": 30
      },
      {
        "accountCode": "",
        "accountName": "Net change in cash",
        "amount": "5094500",
        "lineType": "total",
        "section": "Net",
        "sortOrder": 40
      },
      {
        "accountCode": "",
        "accountName": "Cash at start of period",
        "amount": "0",
        "lineType": "detail",
        "section": "Cash",
        "sortOrder": 50
      },
      {
        "accountCode": "",
        "accountName": "Cash at end of period",
        "amount": "5094500",
        "lineType": "total",
        "section": "Cash",
        "sortOrder": 60
      }
    ],
    "to": "2026-02-28"
  },
  "requestId": "29c5a32f-d90f-48a0-b599-00ca261ed780"
}
GET/v1/reports/entity-balancesread key

Who owes you (customers) or whom you owe (suppliers) as at a date, per currency.

parameterintyperequiredrestrictions
asAtquerystringrequiredYYYY-MM-DD
kindquerystringrequiredcustomer | supplier

returns (inside the envelope's data)

  • rows[]entityRef, entityName, currencyCode, balance
Error codes this endpoint can return (2)
REPORT_DATE_INVALIDREPORT_ENTITY_KIND_INVALID

Real example — captured live from the demo company on staging:

request · GET /v1/reports/entity-balances?asAt=2026-02-28&kind=customer

GET /v1/reports/entity-balances?asAt=2026-02-28&kind=customer
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "asAt": "2026-02-28",
    "kind": "customer",
    "rows": [
      {
        "balance": "100000",
        "currencyCode": "USD",
        "entityName": "Aurora Retail Ltd",
        "entityRef": "CUST-01"
      }
    ]
  },
  "requestId": "fcb5c481-4ec8-4d27-956e-39b427a22a91"
}
GET/v1/reports/entity-statementread key

Balance-forward activity statement for one party over a date range.

parameterintyperequiredrestrictions
entityRefquerystringrequiredan existing party
fromquerystringrequiredYYYY-MM-DD
toquerystringrequiredYYYY-MM-DD

returns (inside the envelope's data)

  • rows[]lineKind (opening | entry | closing), effectiveDate, reference, description, charge, payment, running
Error codes this endpoint can return (3)
REPORT_DATE_INVALIDREPORT_ENTITY_NOT_FOUNDREPORT_DATE_RANGE_INVALID

Real example — captured live from the demo company on staging:

request · GET /v1/reports/entity-statement?entityRef=CUST-01&from=2026-01-01&to=2026-02-28

GET /v1/reports/entity-statement?entityRef=CUST-01&from=2026-01-01&to=2026-02-28
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "entityRef": "CUST-01",
    "from": "2026-01-01",
    "rows": [
      {
        "charge": "0",
        "description": null,
        "effectiveDate": "",
        "lineKind": "opening",
        "payment": "0",
        "reference": null,
        "running": "0"
      },
      {
        "charge": "250000",
        "description": "Invoice INV-1001 — Aurora Retail",
        "effectiveDate": "2026-01-10",
        "lineKind": "entry",
        "payment": "0",
        "reference": "INV-1001",
        "running": "250000"
      },
      {
        "charge": "0",
        "description": "Customer receipt against INV-1001",
        "effectiveDate": "2026-01-15",
        "lineKind": "entry",
        "payment": "150000",
        "reference": null,
        "running": "100000"
      },
      {
        "charge": "0",
        "description": null,
        "effectiveDate": "",
        "lineKind": "closing",
        "payment": "0",
        "reference": null,
        "running": "100000"
      }
    ],
    "to": "2026-02-28"
  },
  "requestId": "05b33cb8-e647-4314-b9e8-b8f325ef23ea"
}
GET/v1/reports/agingread key

Aged receivables/payables for one control account — per party, per currency, FIFO by transaction date, in transaction AND base currency.

parameterintyperequiredrestrictions
accountquerystringrequiredthe AR/AP control account code; unknown → 404
asAtquerystringrequiredYYYY-MM-DD

returns (inside the envelope's data)

  • rows[]entityRef, entityName, currencyCode, current (0–30), days31to60, days61to90, over90, total + base* mirrors
Error codes this endpoint can return (2)
REPORT_DATE_INVALIDLINE_ACCOUNT_NOT_FOUND

Real example — captured live from the demo company on staging:

request · GET /v1/reports/aging?account=1100&asAt=2026-02-28

GET /v1/reports/aging?account=1100&asAt=2026-02-28
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "account": "1100",
    "asAt": "2026-02-28",
    "rows": [
      {
        "base31to60": "100000",
        "base61to90": "0",
        "baseCurrent": "0",
        "baseOver90": "0",
        "baseTotal": "100000",
        "currencyCode": "USD",
        "current": "0",
        "days31to60": "100000",
        "days61to90": "0",
        "entityName": "Aurora Retail Ltd",
        "entityRef": "CUST-01",
        "over90": "0",
        "total": "100000"
      }
    ]
  },
  "requestId": "e8848b62-9c27-4667-b581-671907248f55"
}
GET/v1/reports/account-subtreeread key

Hierarchical roll-up: each descendant of a parent account plus a normal-side-aware rolled-up total.

parameterintyperequiredrestrictions
accountquerystringrequiredthe root account code; unknown → 404
fromquerystringoptionalYYYY-MM-DD
toquerystringoptionalYYYY-MM-DD

returns (inside the envelope's data)

  • rows[]accountCode, accountName, depth, lineType (detail | total), debit, credit, balance
Error codes this endpoint can return (2)
REPORT_DATE_INVALIDLINE_ACCOUNT_NOT_FOUND

Real example — captured live from the demo company on staging:

request · GET /v1/reports/account-subtree?account=1000

GET /v1/reports/account-subtree?account=1000
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "account": "1000",
    "rows": [
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "balance": "5094500",
        "credit": "120500",
        "debit": "5215000",
        "depth": 0,
        "lineType": "detail"
      },
      {
        "accountCode": "1000",
        "accountName": "Cash at bank",
        "balance": "5094500",
        "credit": "120500",
        "debit": "5215000",
        "depth": -1,
        "lineType": "total"
      }
    ]
  },
  "requestId": "1d1e3306-87b7-49c3-895c-add41b89ec83"
}
GET/v1/reports/cash-basisread key

Cash-basis P&L: accrual amounts re-timed to the cash-movement date (report-only — the ledger itself never changes). AR/AP settlements FIFO-trace to their original income/expense.

parameterintyperequiredrestrictions
fromquerystringrequiredYYYY-MM-DD
toquerystringrequiredYYYY-MM-DD

returns (inside the envelope's data)

  • rows[]accountCode, accountName, accountType, currencyCode, amount, baseAmount
  • excludedUnsupportedinteger — mixed entries counted and excluded, never guessed
Error codes this endpoint can return (2)
REPORT_DATE_INVALIDREPORT_DATE_RANGE_INVALID

Real example — captured live from the demo company on staging:

request · GET /v1/reports/cash-basis?from=2026-01-01&to=2026-02-28

GET /v1/reports/cash-basis?from=2026-01-01&to=2026-02-28
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "excludedUnsupported": 0,
    "from": "2026-01-01",
    "rows": [
      {
        "accountCode": "5000",
        "accountName": "Purchases",
        "accountType": "expense",
        "amount": "10000",
        "baseAmount": "13500",
        "currencyCode": "GBP"
      },
      {
        "accountCode": "6000",
        "accountName": "Marketing costs",
        "accountType": "expense",
        "amount": "42000",
        "baseAmount": "42000",
        "currencyCode": "USD"
      },
      {
        "accountCode": "4000",
        "accountName": "Sales revenue",
        "accountType": "revenue",
        "amount": "150000",
        "baseAmount": "150000",
        "currencyCode": "USD"
      }
    ],
    "to": "2026-02-28"
  },
  "requestId": "0f757f81-6054-4cfc-9fe2-cfd5faad3bc5"
}

Dimensions

POST/v1/dimensionsadmin key

Create an analytic axis (department, project, cost centre…). Tags are sealed into entry fingerprints.

parameterintyperequiredrestrictions
codehashedbodystringrequired1–64 chars, ^[A-Z0-9_]{1,64}$; trimmed + UPPERCASED; unique per ledger
namebodystringrequired1–128 chars, any language; no control/invisible chars
isRequiredbodybooleanoptionaldefault false; true = EVERY line of every entry must carry this axis; locked once values or tags exist

returns (inside the envelope's data)

  • idstring (uuid)
  • codestring
  • namestring
  • isRequiredboolean
  • isActiveboolean

Idempotency: Not wrapped — the per-ledger UNIQUE code is the duplicate guard (409).

Error codes this endpoint can return (7)
DIMENSION_CODE_REQUIREDDIMENSION_CODE_INVALIDDIMENSION_CODE_TAKENDIMENSION_NAME_REQUIREDDIMENSION_NAME_TOO_LONGDIMENSION_NAME_INVALID_CHARSDIMENSION_REQUIRED_LOCKED

Real example — captured live from the demo company on staging:

request · POST /v1/dimensions

{
  "code": "REGION",
  "name": "Region"
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "code": "REGION",
    "id": "059b319c-3135-42d0-8564-2f681f51822f",
    "isActive": true,
    "isRequired": false,
    "name": "Region"
  },
  "requestId": "ab6151f1-e65d-4ca2-a4d7-55b42dc1ce71"
}
POST/v1/dimensions/{code}/valuesadmin key

Add a value to an axis.

parameterintyperequiredrestrictions
codepathstringrequiredan ACTIVE axis code
code (body)hashedbodystringrequired1–64 chars, ^[A-Z0-9_]{1,64}$; trimmed + UPPERCASED; unique per (ledger, axis)
namebodystringrequired1–128 chars; no control/invisible chars

returns (inside the envelope's data)

  • idstring (uuid)
  • dimensionCodestring
  • codestring
  • namestring
  • isActiveboolean

Idempotency: Not wrapped — UNIQUE (ledger, axis, code) is the guard.

Error codes this endpoint can return (7)
DIMENSION_UNKNOWNDIMENSION_VALUE_CODE_REQUIREDDIMENSION_VALUE_CODE_INVALIDDIMENSION_VALUE_CODE_TAKENDIMENSION_VALUE_NAME_REQUIREDDIMENSION_VALUE_NAME_TOO_LONGDIMENSION_VALUE_NAME_INVALID_CHARS

Real example — captured live from the demo company on staging:

request · POST /v1/dimensions/REGION/values

{
  "code": "EU",
  "name": "European Union"
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "code": "EU",
    "dimensionCode": "REGION",
    "id": "de0dfc6f-2b0d-4ab8-8130-51034afcd1d8",
    "isActive": true,
    "name": "European Union"
  },
  "requestId": "dd68b0bb-c8bf-4614-b900-08ee533a230a"
}
GET/v1/dimensionsread key

List all axes with their values.

No parameters.

returns (inside the envelope's data)

  • dimensions[]id, code, name, isRequired, isActive, values[] { code, name, isActive }

Real example — captured live from the demo company on staging:

request · GET /v1/dimensions

GET /v1/dimensions
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "dimensions": [
      {
        "code": "DEPT",
        "id": "c189bc87-9f2d-4054-91e3-212b57d81032",
        "isActive": true,
        "isRequired": false,
        "name": "Department",
        "values": [
          {
            "code": "OPS",
            "isActive": true,
            "name": "Operations"
          },
          {
            "code": "SALES",
            "isActive": true,
            "name": "Sales team"
          }
        ]
      },
      {
        "code": "REGION",
        "id": "059b319c-3135-42d0-8564-2f681f51822f",
        "isActive": true,
        "isRequired": false,
        "name": "Region",
        "values": [
          {
            "code": "EU",
            "isActive": true,
            "name": "European Union"
          }
        ]
      }
    ]
  },
  "requestId": "9a602119-2753-4be1-81eb-3dc3d28d4d1b"
}
GET/v1/reports/balance-by-dimensionread key

Per-account balances filtered to one (axis, value) tag — the dimension verification oracle.

parameterintyperequiredrestrictions
dimensionquerystringrequiredaxis code; unknown axis → 404
valuequerystringrequiredvalue code; unknown value → 200 with zero rows
fromquerystringoptionalYYYY-MM-DD
toquerystringoptionalYYYY-MM-DD

returns (inside the envelope's data)

  • rows[]accountCode, accountName, debit, credit, balance (sign follows the account's normalSide)
Error codes this endpoint can return (4)
DIMENSION_CODE_INVALIDDIMENSION_VALUE_CODE_INVALIDREPORT_DATE_INVALIDDIMENSION_NOT_FOUND

Real example — captured live from the demo company on staging:

request · GET /v1/reports/balance-by-dimension?dimension=DEPT&value=SALES

GET /v1/reports/balance-by-dimension?dimension=DEPT&value=SALES
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "dimension": "DEPT",
    "rows": [
      {
        "accountCode": "1100",
        "accountName": "Accounts receivable",
        "balance": "250000",
        "credit": "0",
        "debit": "250000"
      },
      {
        "accountCode": "4000",
        "accountName": "Sales revenue",
        "balance": "250000",
        "credit": "250000",
        "debit": "0"
      }
    ],
    "value": "SALES"
  },
  "requestId": "7c82cd41-bc41-4265-ab14-a06618e6935d"
}

Multi-currency

PUT/v1/accounts/{code}/fx-settingsadmin key

Set which currencies an account accepts and whether it is monetary (revalued) — the per-account currency-mode gate.

parameterintyperequiredrestrictions
codepathstringrequiredan existing COA account code
currencyModebodystringrequiredbase_only | single_foreign | multi
accountCurrencybodystring | nulloptionalREQUIRED when single_foreign, FORBIDDEN otherwise; 2–10 chars, ^[A-Z][A-Z0-9]{1,9}$; trimmed, uppercased; must exist in the currency dictionary
isMonetarybodybooleanoptionaldefault false; true = bank/AR/AP (revaluation applies); false = frozen at cost

returns (inside the envelope's data)

  • accountCodestring
  • currencyModestring
  • accountCurrencystring | null
  • isMonetaryboolean

Idempotency: Naturally idempotent upsert. Tightening the mode while the account holds a balance in a now-forbidden currency is refused (FX_MODE_HAS_BALANCE).

Error codes this endpoint can return (7)
FX_CURRENCY_MODE_INVALIDFX_ACCOUNT_CURRENCY_REQUIREDFX_ACCOUNT_CURRENCY_UNEXPECTEDCURRENCY_UNKNOWNFX_ACCOUNT_NOT_FOUNDFX_MODE_HAS_BALANCELEDGER_BUSY

Real example — captured live from the demo company on staging:

request · PUT /v1/accounts/1000/fx-settings

{
  "currencyMode": "multi",
  "isMonetary": true
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "accountCode": "1000",
    "accountCurrency": null,
    "currencyMode": "multi",
    "isMonetary": true
  },
  "requestId": "6a0289ba-86b9-4a5e-8261-9b7696160929"
}
GET/v1/accounts/{code}/fx-carryingread key

Foreign units + base carrying value for a monetary account — everything needed to compute a period-end revaluation.

parameterintyperequiredrestrictions
codepathstringrequiredCOA account code; a non-monetary or unknown account answers { monetary: false }

returns (inside the envelope's data)

  • accountCodestring
  • monetaryboolean
  • currencies[]currencyCode, foreignUnits, baseOwn — one row per foreign currency held (exact digit strings, crypto-safe)
  • baseAdjustmentsstring — net of the account's base-currency rows (revaluation deltas land here)
  • baseCarryingTotalstring — Σ baseOwn + baseAdjustments

Real example — captured live from the demo company on staging:

request · GET /v1/accounts/1000/fx-carrying

GET /v1/accounts/1000/fx-carrying
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "accountCode": "1000",
    "baseAdjustments": "0",
    "baseCarryingTotal": "0",
    "currencies": [],
    "monetary": true
  },
  "requestId": "e34d3d9d-5423-45d5-9639-61bcb5b82f18"
}

Verify & proofs

GET/v1/verify/chainread key

Recompute every entry's SHA-256 in sequence order and check each link. Empty issues = the chain is provably intact.

No parameters.

returns (inside the envelope's data)

  • okboolean
  • issues[]postingSequence, issue (CHAIN_LINK_BROKEN | FINGERPRINT_MISMATCH), expected, actual

Real example — captured live from the demo company on staging:

request · GET /v1/verify/chain

GET /v1/verify/chain
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "issues": [],
    "ok": true
  },
  "requestId": "dd5bdb92-19e1-4a9d-8cfe-65524be6eca0"
}
GET/v1/verify/sequenceread key

Prove the posting sequence is a perfect 1..N — nothing was ever silently deleted.

No parameters.

returns (inside the envelope's data)

  • okboolean
  • missinginteger[] — empty means gapless

Real example — captured live from the demo company on staging:

request · GET /v1/verify/sequence

GET /v1/verify/sequence
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "missing": [],
    "ok": true
  },
  "requestId": "e7e25d8d-eff4-41cd-ba77-1dae0b7f0782"
}
GET/v1/verify/period/{code}read key

Recompute a sealed period's Merkle root, period hash, and entry count from the raw entries and compare against the seal.

parameterintyperequiredrestrictions
codepathstringrequiredYYYY-MM; the period must be sealed

returns (inside the envelope's data)

  • okboolean
  • checks[]checkName (merkle_root | period_hash | entry_count), ok, expected, actual
Error codes this endpoint can return (2)
PERIOD_NOT_SEALEDPERIOD_NOT_FOUND

Real example — captured live from the demo company on staging:

request · GET /v1/verify/period/2026-01

GET /v1/verify/period/2026-01
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "checks": [
      {
        "actual": "796c1a849ec8d61a52258172eb42e988b4e5571325f9560c89b9f7cd2cc3a29b",
        "checkName": "merkle_root",
        "expected": "796c1a849ec8d61a52258172eb42e988b4e5571325f9560c89b9f7cd2cc3a29b",
        "ok": true
      },
      {
        "actual": "b4b3ce02f39c047d180ae5ead606632c6daeb74479c6f0b4b43db6ca1fc889fd",
        "checkName": "period_hash",
        "expected": "b4b3ce02f39c047d180ae5ead606632c6daeb74479c6f0b4b43db6ca1fc889fd",
        "ok": true
      },
      {
        "actual": "5",
        "checkName": "entry_count",
        "expected": "5",
        "ok": true
      }
    ],
    "ok": true,
    "periodCode": "2026-01"
  },
  "requestId": "30bec62f-6236-4091-9dbc-6fe96ef0a065"
}
GET/v1/verify/entry/{id}/proofread key

Merkle inclusion proof: cryptographic evidence that one entry is inside its sealed period, without revealing the others. Fold the leaf up the sibling path to reproduce the root.

parameterintyperequiredrestrictions
idpathstring (uuid)requiredan entry inside a SEALED period

returns (inside the envelope's data)

  • periodCodestring
  • entryHashstring — 64 hex (the leaf)
  • merkleRootstring — 64 hex (the sealed root)
  • leafIndexinteger
  • proof[]{ sibling: hex, left: boolean } steps — tagged SHA-256 (0x00 leaves, 0x01 nodes)
Error codes this endpoint can return (3)
PROOF_ENTRY_NOT_FOUNDPROOF_ENTRY_NOT_SEALEDPROOF_PERIOD_NOT_SEALED

Real example — captured live from the demo company on staging:

Merkle inclusion proof: fold the leaf up the sibling path to reproduce the sealed root.

request · GET /v1/verify/entry/d896f762-9c74-4d43-84c4-94e08671f533/proof

GET /v1/verify/entry/d896f762-9c74-4d43-84c4-94e08671f533/proof
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "entryHash": "6cc5293c8ff7785db731525d18035ac7bdd5d4c23513eefbcf1ab1b12863221a",
    "leafIndex": 0,
    "merkleRoot": "796c1a849ec8d61a52258172eb42e988b4e5571325f9560c89b9f7cd2cc3a29b",
    "periodCode": "2026-01",
    "proof": [
      {
        "left": false,
        "sibling": "9c802537b5720fb891ee1a6e9cd26ddbe892eb60745ddd93c3e0fcca5fa33c74"
      },
      {
        "left": false,
        "sibling": "6b290716af3778598d8a7f9c4ee3b167824751830bf6ba71fdd891fb374c2c8a"
      },
      {
        "left": false,
        "sibling": "71295af17b9def5b1798f8c8c88a19e79b583e69d863b809f106050efabfa796"
      }
    ]
  },
  "requestId": "7e799f54-134d-4c16-8d14-59edd08df63c"
}

Copy a sealed entry's id from “List entries” (January entries are sealed).

GET/v1/verify/anchor/{code}read key

Compare the recomputed period witness against the S3 Object Lock (WORM) anchor — storage even the operator cannot rewrite.

parameterintyperequiredrestrictions
codepathstringrequiredYYYY-MM; the period must be sealed

returns (inside the envelope's data)

  • anchoredboolean — false is the honest state when no anchor exists yet
  • contentMatchesboolean
  • s3Bucket / s3Key / s3VersionId / objectSha256string | null — the anchor coordinates
Error codes this endpoint can return (2)
ANCHOR_PERIOD_NOT_SEALEDANCHOR_CONTENT_MISMATCH

Real example — captured live from the demo company on staging:

anchored=false is the HONEST state: this demo period has no S3 Object Lock anchor yet.

request · GET /v1/verify/anchor/2026-01

GET /v1/verify/anchor/2026-01
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "anchored": false,
    "contentMatches": false,
    "objectSha256": null,
    "periodCode": "2026-01",
    "s3Bucket": null,
    "s3Key": null,
    "s3VersionId": null
  },
  "requestId": "c5724b01-07ad-4633-88b6-a36f02c8a564"
}
GET/v1/verify/reportsread key

Report-reconciliation oracle: the trial balance balances, the balance sheet balances, sub-ledgers tie to their control accounts, and the cash-flow closes to cash.

parameterintyperequiredrestrictions
asAtquerystringrequiredYYYY-MM-DD

returns (inside the envelope's data)

  • okboolean
  • checks[]checkName (trial_balance_balances | balance_sheet_balances | subledger_ties_to_control | cash_flow_closes), ok, detail
Error codes this endpoint can return (1)
REPORT_DATE_INVALID

Real example — captured live from the demo company on staging:

request · GET /v1/verify/reports?asAt=2026-02-28

GET /v1/verify/reports?asAt=2026-02-28
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "asAt": "2026-02-28",
    "checks": [
      {
        "checkName": "trial_balance_balances",
        "detail": "Σdr=5340000 Σcr=5340000",
        "ok": true
      },
      {
        "checkName": "balance_sheet_balances",
        "detail": "assets=5194500 L+E=5194500",
        "ok": true
      },
      {
        "checkName": "subledger_ties_to_control",
        "detail": "0 entity discrepancies",
        "ok": true
      },
      {
        "checkName": "cash_flow_closes",
        "detail": "cashflow-closing=5094500 cash-balance=5094500",
        "ok": true
      }
    ],
    "ok": true
  },
  "requestId": "ddc665c5-7823-45bd-b4c8-057346076455"
}
GET/v1/auditread key

The ledger's hash-chained action log, newest first. Actor references are public key ids — never secrets.

parameterintyperequiredrestrictions
limitqueryintegeroptionaldefault 100; clamped to 1–500

returns (inside the envelope's data)

  • events[]auditSequence, action, actorType, actorRef, targetType, targetId, eventHash, previousHash, createdAt

Real example — captured live from the demo company on staging:

request · GET /v1/audit?limit=5

GET /v1/audit?limit=5
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "events": [
      {
        "action": "close_period",
        "actorRef": "EaumheHQFrpWv2aj",
        "actorType": "api_key",
        "auditSequence": 35,
        "createdAt": "2026-08-15T19:29:06.798663Z",
        "eventHash": "9b2ec4d86f24d8a16bd9e3a005fb1bc5ea8195beaab5d001ca16dd2afc4f30e2",
        "previousHash": "30f63b9469ffcc5842d1c1c45c9a17b2e7aee8bce47b98b20ac480c0308266ef",
        "targetId": "2026-02",
        "targetType": "period"
      },
      {
        "action": "reverse_entry",
        "actorRef": "v1gMtgtyQJjfRN1R",
        "actorType": "api_key",
        "auditSequence": 34,
        "createdAt": "2026-08-15T19:28:58.388455Z",
        "eventHash": "30f63b9469ffcc5842d1c1c45c9a17b2e7aee8bce47b98b20ac480c0308266ef",
        "previousHash": "c1e9e7483dc5528de1e9581bec4268c1d27547984d9c4b8db9c6e3bdbe30bcd3",
        "targetId": "e3f39e9c-70f3-45b2-9f50-5a1a49b41cce",
        "targetType": "journal_entry"
      },
      {
        "action": "post_entry",
        "actorRef": "v1gMtgtyQJjfRN1R",
        "actorType": "api_key",
        "auditSequence": 33,
        "createdAt": "2026-08-15T19:28:57.954603Z",
        "eventHash": "c1e9e7483dc5528de1e9581bec4268c1d27547984d9c4b8db9c6e3bdbe30bcd3",
        "previousHash": "6d7bd56971b0c5b17480e14b6014aede58aa8eaf38d7e85bee4bc73d7c29376c",
        "targetId": "1864aa3e-53f9-4ed2-aea4-1ed2f80aeceb",
        "targetType": "journal_entry"
      },
      {
        "action": "post_entry",
        "actorRef": "v1gMtgtyQJjfRN1R",
        "actorType": "api_key",
        "auditSequence": 32,
        "createdAt": "2026-08-15T19:28:56.370947Z",
        "eventHash": "6d7bd56971b0c5b17480e14b6014aede58aa8eaf38d7e85bee4bc73d7c29376c",
        "previousHash": "bdafb9abc337125c21e55e359036854c9408bb5661aecf7378c63da9d21c13d1",
        "targetId": "109a3464-4264-439c-9dbe-63f2dbf3a597",
        "targetType": "journal_entry"
      },
      {
        "action": "create_dimension_value",
        "actorRef": "EaumheHQFrpWv2aj",
        "actorType": "api_key",
        "auditSequence": 31,
        "createdAt": "2026-08-15T19:28:55.856305Z",
        "eventHash": "bdafb9abc337125c21e55e359036854c9408bb5661aecf7378c63da9d21c13d1",
        "previousHash": "37db7437ddaba9214fa2526e7a2f524739d7f94164008ecf292c70819ca9bdbe",
        "targetId": "REGION/EU",
        "targetType": "dimension_value"
      }
    ]
  },
  "requestId": "d79d948a-3904-4322-a211-6895427f0852"
}
GET/v1/verify/audit-chainread key

Recompute the audit log's own hash chain — the log that guards the ledger is itself tamper-evident.

No parameters.

returns (inside the envelope's data)

  • okboolean
  • issues[]auditSequence, issue (SEQUENCE_GAP | CHAIN_LINK_BROKEN | FINGERPRINT_MISMATCH)

Real example — captured live from the demo company on staging:

request · GET /v1/verify/audit-chain

GET /v1/verify/audit-chain
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "issues": [],
    "ok": true
  },
  "requestId": "c3c0d389-4630-4ed2-8964-9e6c7ebb4c06"
}
POST/v1/proofsadmin key

Build a durable attestation snapshot over a sealed period or the whole ledger — self-verifiable offline via its content hash.

parameterintyperequiredrestrictions
snapshotTypebodystringrequiredperiod | full
scopebodystring | nulloptionalREQUIRED for period (a YYYY-MM code), FORBIDDEN for full; 1–32 chars
finalizebodybooleanoptionaldefault false; true = build + finalize in one call

returns (inside the envelope's data)

  • idstring (uuid)
  • snapshotType / scopeas requested
  • entryCountinteger
  • rootHash / contentHashstring — 64 hex each
  • status"provisional" | "final" | "superseded"
  • supersededById / finalizedAt / createdAtlifecycle fields

Idempotency: Intentionally NOT idempotent — every call is an explicit new attestation.

Error codes this endpoint can return (6)
PROOF_SNAPSHOT_TYPE_INVALIDPROOF_SCOPE_INVALIDPROOF_SCOPE_REQUIREDPROOF_SCOPE_NOT_ALLOWEDPROOF_PERIOD_NOT_SEALEDPROOF_LEDGER_EMPTY

Real example — captured live from the demo company on staging:

request · POST /v1/proofs

{
  "snapshotType": "period",
  "scope": "2026-01",
  "finalize": false
}

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "contentHash": "2595af63a00516d29769e6a255b1d73b3091787e8435b799e77f4fb3921fb3ab",
    "createdAt": "2026-08-15T19:29:10.861988Z",
    "entryCount": 5,
    "finalizedAt": null,
    "id": "ebd83c6d-853a-42d2-b27d-e320135c584d",
    "rootHash": "b4b3ce02f39c047d180ae5ead606632c6daeb74479c6f0b4b43db6ca1fc889fd",
    "scope": "2026-01",
    "snapshotType": "period",
    "status": "provisional",
    "supersededById": null
  },
  "requestId": "08d3399e-a2ad-4a79-9024-598838fa8a44"
}
GET/v1/proofsread key

List the ledger's proofs, newest first.

No parameters.

returns (inside the envelope's data)

  • proofs[]proof objects (same shape as POST /v1/proofs)

Real example — captured live from the demo company on staging:

request · GET /v1/proofs

GET /v1/proofs
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "proofs": [
      {
        "contentHash": "2595af63a00516d29769e6a255b1d73b3091787e8435b799e77f4fb3921fb3ab",
        "createdAt": "2026-08-15T19:29:10.861988Z",
        "entryCount": 5,
        "finalizedAt": "2026-08-15T19:29:11.226589Z",
        "id": "ebd83c6d-853a-42d2-b27d-e320135c584d",
        "rootHash": "b4b3ce02f39c047d180ae5ead606632c6daeb74479c6f0b4b43db6ca1fc889fd",
        "scope": "2026-01",
        "snapshotType": "period",
        "status": "final",
        "supersededById": null
      }
    ]
  },
  "requestId": "c67a4e87-218a-4c8d-96b5-1f36a662d36e"
}
GET/v1/proofs/{id}read key

Read one proof.

parameterintyperequiredrestrictions
idpathstring (uuid)requiredledger-scoped — another tenant's id is simply not found

returns (inside the envelope's data)

  • (proof)proof object
Error codes this endpoint can return (1)
PROOF_NOT_FOUND

Real example — captured live from the demo company on staging:

request · GET /v1/proofs/ebd83c6d-853a-42d2-b27d-e320135c584d

GET /v1/proofs/ebd83c6d-853a-42d2-b27d-e320135c584d
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "contentHash": "2595af63a00516d29769e6a255b1d73b3091787e8435b799e77f4fb3921fb3ab",
    "createdAt": "2026-08-15T19:29:10.861988Z",
    "entryCount": 5,
    "finalizedAt": "2026-08-15T19:29:11.226589Z",
    "id": "ebd83c6d-853a-42d2-b27d-e320135c584d",
    "rootHash": "b4b3ce02f39c047d180ae5ead606632c6daeb74479c6f0b4b43db6ca1fc889fd",
    "scope": "2026-01",
    "snapshotType": "period",
    "status": "final",
    "supersededById": null
  },
  "requestId": "70f0a051-fcdf-424f-9ad6-a51c57b44fec"
}

Run “List proofs” first and copy an id.

POST/v1/proofs/{id}/finalizeadmin key

Promote a provisional proof to final — from then on it is trigger-frozen forever.

parameterintyperequiredrestrictions
idpathstring (uuid)requiredmust be provisional

returns (inside the envelope's data)

  • (proof)the updated proof object
Error codes this endpoint can return (2)
PROOF_NOT_FOUNDPROOF_NOT_PROVISIONAL

Real example — captured live from the demo company on staging:

request · POST /v1/proofs/ebd83c6d-853a-42d2-b27d-e320135c584d/finalize

POST /v1/proofs/ebd83c6d-853a-42d2-b27d-e320135c584d/finalize
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "contentHash": "2595af63a00516d29769e6a255b1d73b3091787e8435b799e77f4fb3921fb3ab",
    "createdAt": "2026-08-15T19:29:10.861988Z",
    "entryCount": 5,
    "finalizedAt": "2026-08-15T19:29:11.226589Z",
    "id": "ebd83c6d-853a-42d2-b27d-e320135c584d",
    "rootHash": "b4b3ce02f39c047d180ae5ead606632c6daeb74479c6f0b4b43db6ca1fc889fd",
    "scope": "2026-01",
    "snapshotType": "period",
    "status": "final",
    "supersededById": null
  },
  "requestId": "dc864913-d035-4c6b-a2f7-ff15a9e88936"
}
POST/v1/proofs/{id}/supersedeadmin key

Re-attest from current state: builds + finalizes a NEW proof over the same scope and demotes the old one. History is never rewritten.

parameterintyperequiredrestrictions
idpathstring (uuid)requiredmust be final

returns (inside the envelope's data)

  • (proof)the NEW proof object; the old row gains supersededById
Error codes this endpoint can return (2)
PROOF_NOT_FOUNDPROOF_NOT_FINAL

Real example — captured live from the demo company on staging:

request · POST /v1/proofs/ebd83c6d-853a-42d2-b27d-e320135c584d/supersede

POST /v1/proofs/ebd83c6d-853a-42d2-b27d-e320135c584d/supersede
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "contentHash": "2595af63a00516d29769e6a255b1d73b3091787e8435b799e77f4fb3921fb3ab",
    "createdAt": "2026-08-15T19:29:12.668459Z",
    "entryCount": 5,
    "finalizedAt": "2026-08-15T19:29:12.668459Z",
    "id": "81d4a3e9-f724-4434-b87a-ca2d54a2d3ef",
    "rootHash": "b4b3ce02f39c047d180ae5ead606632c6daeb74479c6f0b4b43db6ca1fc889fd",
    "scope": "2026-01",
    "snapshotType": "period",
    "status": "final",
    "supersededById": null
  },
  "requestId": "91dffe1a-0094-4df7-8647-2955fb9e990b"
}
GET/v1/proofs/{id}/verifyread key

Recompute a proof's content hash and (for period proofs) its root against the sealed period.

parameterintyperequiredrestrictions
idpathstring (uuid)requiredany proof id in this ledger

returns (inside the envelope's data)

  • contentOk / rootOk / okboolean — ok = contentOk AND rootOk
Error codes this endpoint can return (1)
PROOF_NOT_FOUND

Real example — captured live from the demo company on staging:

request · GET /v1/proofs/ebd83c6d-853a-42d2-b27d-e320135c584d/verify

GET /v1/proofs/ebd83c6d-853a-42d2-b27d-e320135c584d/verify
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "contentOk": true,
    "id": "ebd83c6d-853a-42d2-b27d-e320135c584d",
    "ok": true,
    "rootOk": true
  },
  "requestId": "6b8bc0bc-41cc-4105-b34d-f195cacbbce5"
}

Run “List proofs” first and copy an id.

Idempotency

GET/v1/idempotency-requests/{key}read key

Retry-safe status lookup for any wrapped write. Another tenant's key answers not_found — no existence oracle.

parameterintyperequiredrestrictions
keypathstringrequiredthe idempotency key, 1–255 chars

returns (inside the envelope's data)

  • status"not_found" | "processing" | "succeeded" | "failed"
  • actionpost_entry | reverse_entry | close_period | create_account | create_journal | create_entity
  • responseSnapshotobject | null — the stored 200 (succeeded only)
  • isStale / retryAllowedboolean (processing only; stale after 10 minutes)

Real example — captured live from the demo company on staging:

request · GET /v1/idempotency-requests/docs-example-entry-01

GET /v1/idempotency-requests/docs-example-entry-01
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "action": "post_entry",
    "createdAt": "2026-08-15T19:28:56.278560Z",
    "resourceId": "109a3464-4264-439c-9dbe-63f2dbf3a597",
    "resourceType": "journal_entry",
    "responseSnapshot": {
      "entryHash": "cf464ac54082192431c630a3a78ab41a1e89fb50f82bae5ff76cc1323bf383a0",
      "entryId": "109a3464-4264-439c-9dbe-63f2dbf3a597",
      "postedAt": "2026-08-15T19:28:56.302970Z",
      "previousHash": "7609282885456721b71647f5117644b72bee4471e2f64cc225ac09908df10dec",
      "replayed": false,
      "sequence": 8
    },
    "status": "succeeded",
    "updatedAt": "2026-08-15T19:28:56.374964Z"
  },
  "requestId": "5849d856-3a72-45b9-84fe-6476dfb2cffb"
}

Lifecycle (admin)

POST/v1/accounts/{code}/deactivateadmin key

Retire an account AND its whole subtree (soft — flag only).

parameterintyperequiredrestrictions
codepathstringrequiredan existing account code

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Refused while any subtree account holds a non-zero balance.

Error codes this endpoint can return (2)
ACCOUNT_NOT_FOUNDACCOUNT_HAS_BALANCE

Real example — captured live from the demo company on staging:

request · POST /v1/accounts/6100/deactivate

POST /v1/accounts/6100/deactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [
      "6100"
    ],
    "target": "6100"
  },
  "requestId": "c6af44e3-3e9b-4f51-87bd-3484fec89d1c"
}
POST/v1/accounts/{code}/reactivateadmin key

Reactivate one account (children stay retired — no automatic resurrection).

parameterintyperequiredrestrictions
codepathstringrequiredan existing account code

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Refused while the parent account is inactive.

Error codes this endpoint can return (2)
ACCOUNT_NOT_FOUNDREACTIVATE_PARENT_INACTIVE

Real example — captured live from the demo company on staging:

request · POST /v1/accounts/6100/reactivate

POST /v1/accounts/6100/reactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [
      "6100"
    ],
    "target": "6100"
  },
  "requestId": "c553129e-6b9d-40e6-b09e-1a63a5523295"
}
POST/v1/entities/{ref}/deactivateadmin key

Retire a party.

parameterintyperequiredrestrictions
refpathstringrequiredan existing entityRef

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Refused while the party holds a non-zero balance.

Error codes this endpoint can return (2)
ENTITY_NOT_FOUNDENTITY_HAS_BALANCE

Real example — captured live from the demo company on staging:

request · POST /v1/entities/CUST-02/deactivate

POST /v1/entities/CUST-02/deactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [
      "CUST-02"
    ],
    "target": "CUST-02"
  },
  "requestId": "a631ebb2-1c75-4c22-981a-a1ac61732cc6"
}
POST/v1/entities/{ref}/reactivateadmin key

Reactivate a party.

parameterintyperequiredrestrictions
refpathstringrequiredan existing entityRef

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Error codes this endpoint can return (1)
ENTITY_NOT_FOUND

Real example — captured live from the demo company on staging:

request · POST /v1/entities/CUST-02/reactivate

POST /v1/entities/CUST-02/reactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [
      "CUST-02"
    ],
    "target": "CUST-02"
  },
  "requestId": "6f08d5a0-afce-4c3c-b4c1-d977374c0398"
}
POST/v1/journals/{code}/deactivateadmin key

Retire a journal (existing entries keep it forever).

parameterintyperequiredrestrictions
codepathstringrequiredan existing journal code

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Error codes this endpoint can return (1)
JOURNAL_NOT_FOUND

Real example — captured live from the demo company on staging:

request · POST /v1/journals/docs/deactivate

POST /v1/journals/docs/deactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [
      "docs"
    ],
    "target": "docs"
  },
  "requestId": "fccdc53a-260b-405b-bbe2-28835bfbafec"
}
POST/v1/journals/{code}/reactivateadmin key

Reactivate a journal.

parameterintyperequiredrestrictions
codepathstringrequiredan existing journal code

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Error codes this endpoint can return (1)
JOURNAL_NOT_FOUND

Real example — captured live from the demo company on staging:

request · POST /v1/journals/docs/reactivate

POST /v1/journals/docs/reactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [
      "docs"
    ],
    "target": "docs"
  },
  "requestId": "f3ff9618-efe4-4f53-8b07-330a9a116e4a"
}
POST/v1/dimensions/{code}/deactivateadmin key

Retire an axis AND all its values.

parameterintyperequiredrestrictions
codepathstringrequiredan existing axis code

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Error codes this endpoint can return (1)
DIMENSION_NOT_FOUND

Real example — captured live from the demo company on staging:

request · POST /v1/dimensions/REGION/deactivate

POST /v1/dimensions/REGION/deactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [
      "REGION",
      "EU"
    ],
    "target": "REGION"
  },
  "requestId": "b6cbc0af-4d82-4c24-b277-9044569c56ae"
}
POST/v1/dimensions/{code}/reactivateadmin key

Reactivate an axis (its values stay retired).

parameterintyperequiredrestrictions
codepathstringrequiredan existing axis code

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Error codes this endpoint can return (1)
DIMENSION_NOT_FOUND

Real example — captured live from the demo company on staging:

request · POST /v1/dimensions/REGION/reactivate

POST /v1/dimensions/REGION/reactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [
      "REGION"
    ],
    "target": "REGION"
  },
  "requestId": "46b67153-470e-4106-8bdd-716b333329a3"
}
POST/v1/dimensions/{code}/values/{value}/deactivateadmin key

Retire one dimension value.

parameterintyperequiredrestrictions
codepathstringrequiredan existing axis code
valuepathstringrequiredan existing value code

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Error codes this endpoint can return (2)
DIMENSION_NOT_FOUNDDIMENSION_VALUE_NOT_FOUND

Real example — captured live from the demo company on staging:

request · POST /v1/dimensions/REGION/values/EU/deactivate

POST /v1/dimensions/REGION/values/EU/deactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [],
    "target": "REGION/EU"
  },
  "requestId": "d1ab0c34-58a2-4ee9-b413-baf97f7f056c"
}
POST/v1/dimensions/{code}/values/{value}/reactivateadmin key

Reactivate one dimension value (its axis must be active).

parameterintyperequiredrestrictions
codepathstringrequiredan existing axis code
valuepathstringrequiredan existing value code

returns (inside the envelope's data)

  • targetstring
  • affectedstring[] — every code the call actually flipped (cascades included)

Flag-only: never a delete, never a hash/seal change. Naturally idempotent — already-flagged rows are skipped.

Error codes this endpoint can return (3)
DIMENSION_NOT_FOUNDDIMENSION_VALUE_NOT_FOUNDREACTIVATE_PARENT_INACTIVE

Real example — captured live from the demo company on staging:

request · POST /v1/dimensions/REGION/values/EU/reactivate

POST /v1/dimensions/REGION/values/EU/reactivate
Authorization: Bearer lh_test_…
(no body)

response · HTTP 200 (captured live)

{
  "ok": true,
  "data": {
    "affected": [
      "EU"
    ],
    "target": "REGION/EU"
  },
  "requestId": "8a1c46c2-3ee2-46c0-a345-7bf09eb8b461"
}

Failure shapes

The same envelope carries every refusal. Five live-captured failures:

No Authorization header — uniform 401.

GET /v1/accounts → HTTP 401
{
  "ok": false,
  "errorCode": "UNAUTHORIZED",
  "message": "invalid or missing credential",
  "requestId": "5eac5230-3e49-4fef-ba72-876ab58a7c14"
}

A read key attempting an admin write — 403.

POST /v1/accounts → HTTP 403
{
  "ok": false,
  "errorCode": "FORBIDDEN",
  "message": "this key does not have permission for this operation",
  "requestId": "0e81e79f-2aff-4416-9430-0befc71ff4f6"
}

Creating an account code that already exists — 409.

POST /v1/accounts → HTTP 409
{
  "ok": false,
  "errorCode": "COA_CODE_TAKEN",
  "message": "account code 1000 already exists in this ledger",
  "requestId": "c920dc42-c5a0-4fa3-8610-d4f6147714b1"
}

Reading an entry id that does not exist — 404.

GET /v1/entries/00000000-0000-4000-8000-000000000000 → HTTP 404
{
  "ok": false,
  "errorCode": "ENTRY_NOT_FOUND",
  "message": "entry not found",
  "requestId": "ac4e3daa-ce9c-4ba8-a2fa-2ed413d7d8c1"
}

Posting into a sealed month — the period gate refuses.

POST /v1/entries → HTTP 400
{
  "ok": false,
  "errorCode": "PERIOD_CLOSED",
  "message": "the period for 2026-01-15 is closed — postings are refused",
  "requestId": "c02c4a80-4c88-4563-aeb1-fdc500b8f839"
}