Quick Start

From nothing to a posted, balanced transaction.

Step 1: Get credentials

Settings → API Keys. You need both the key and the secret — they go in two separate headers, not an Authorization header.

export LEDGER_URL=https://ledger.finopsbricks.com
export KEY=your_key
export SECRET=your_secret

Step 2: Look at the chart of accounts

Every organization starts with a skeleton of system group accounts. Nothing can post to those — you post to accounts you hang beneath them.

curl -s "$LEDGER_URL/api/v1/accounts/tree" \
  -H "api-key: $KEY" -H "api-secret: $SECRET"

Step 3: Create two postable accounts

# Find the id of the seeded CASH group
CASH=$(curl -s "$LEDGER_URL/api/v1/accounts?source=system&source_ref=CASH" \
  -H "api-key: $KEY" -H "api-secret: $SECRET" | jq -r '.data[0].id')

# A bank account beneath it
curl -s -X POST "$LEDGER_URL/api/v1/accounts" \
  -H "api-key: $KEY" -H "api-secret: $SECRET" \
  -H "content-type: application/json" \
  -d "{\"name\": \"HDFC Current A/c\", \"type\": \"asset\", \"parent\": \"$CASH\"}"

# An expense account
curl -s -X POST "$LEDGER_URL/api/v1/accounts" \
  -H "api-key: $KEY" -H "api-secret: $SECRET" \
  -H "content-type: application/json" \
  -d '{"name": "Rent Expense", "type": "expense"}'

A parent must be a group of the same type — an expense account cannot hang under an asset group, because a group's total is the sum of what sits beneath it.

Step 4: Post a transaction

Amounts are integers in minor units. 5000000 is ₹50,000.00.

curl -s -X POST "$LEDGER_URL/api/v1/transactions" \
  -H "api-key: $KEY" -H "api-secret: $SECRET" \
  -H "content-type: application/json" \
  -d '{
    "date": "2026-08-05",
    "narration": "Rent paid — Aug 2026",
    "type": "vendor_paid",
    "entries": [
      { "account": "RENT_ACCOUNT_ID", "debit": 5000000, "credit": 0 },
      { "account": "BANK_ACCOUNT_ID", "debit": 0, "credit": 5000000 }
    ]
  }'

Debits must equal credits. If they do not, the response tells you by how much:

{
  "error": {
    "code": "UNBALANCED",
    "message": "Debits and credits must be equal. Debits total 5000000, credits total 4500000, a difference of 500000.",
    "details": { "total_debit": 5000000, "total_credit": 4500000, "difference": 500000 }
  }
}

The difference is usually the line you left out.

Step 5: Read it back

# The transaction with its entries
curl -s "$LEDGER_URL/api/v1/transactions?date_from=2026-08-01&date_to=2026-08-31" \
  -H "api-key: $KEY" -H "api-secret: $SECRET"

# Just the lines that hit one account
curl -s "$LEDGER_URL/api/v1/entries?account=BANK_ACCOUNT_ID" \
  -H "api-key: $KEY" -H "api-secret: $SECRET"

If you are importing from another system

Send source and source_ref on everything:

{ "source": "sap", "source_ref": "4900001234", "...": "..." }

Together they are the row's identity. Re-sending returns the existing row with created: false instead of duplicating it, so an interrupted sync can simply be re-run. Without them, a retry doubles your ledger.

If you are mirroring two systems at once

A granular subsystem feeding a summarised one — millions of charging sessions invoiced by one service, one summary document per state per month reaching SAP — can be held here at both levels. source keeps the namespaces apart.

The trap is double-counting, and the fix is to post the two layers against different accounts, with the summary layer hitting a clearing account. That account must return to zero at every summary posting date. When it does not, the two layers disagree and something upstream went wrong — the ledger becomes a reconciliation point rather than a place where the error hides.

Drafts

Post directly unless you need to build a transaction up over several calls.

{ "status": "draft", "...": "..." }

A draft may be unbalanced and is excluded from reports. Post it with PATCH /api/v1/transactions/:id and {"status": "posted"} — the balance check runs then.

Once posted, a transaction is immutable. Correct one by voiding it and posting a replacement.