Ownware
Home›Ledgira›API
Ledgira · API

Ledgira API, as shipped in the download

Ledgira REST API & Webhooks (v2.0)

Ledgira ships a read-only JSON REST API and signed webhooks, so the reconciliation state of every statement you hold can be read by whatever you already use to watch your books — a dashboard, a nightly check, a Slack alert when something stops adding up.

Money is integer cents and SIGNED: a debit is negative. That is the whole model — it is what lets a single sum answer "does this statement balance?".

Authentication

Create a key in API & Webhooks. A key belongs to a user and can do exactly what that user can do in the app. Send it on every call:

Authorization: Bearer ldk_...

The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.

/api/* is CSRF-exempt by design — the key is the credential, and browsers never send it implicitly.

Endpoints

Method & pathWhat it does
GET /api/pingVerify a key
GET /api/statementsNewest 50 with reconciliation. ?status=, ?recon=, ?bank=
GET /api/statements/{id}One statement with transactions and the per-row audit
GET /api/reportPortfolio view: what reconciles, what does not, and by how much
GET /api/openapi.jsonOpenAPI 3 spec (no auth)
GET /healthz{"ok":true,"app":"ledgira","version":"2.0.0"} (no auth)

Why the API is read-only

A statement is a record of what a bank said. It arrives as a PDF or an image so the original document stays attached to the figures, and Ledgira's job is to check the arithmetic against it. An endpoint that let a script post transactions into a statement would let the record drift from the document it claims to reconcile — which is the one thing this product exists to prevent.

Transactions still arrive two ways, both with the document in hand: extraction from the uploaded file, and the CSV import (Settings → Import transactions), which appends a bank's own CSV export to a statement you pick and immediately re-checks the reconciliation.

Reconciliation

Every figure comes from Statement::reconcile() — the same function behind the screen, the flash message and the exports. The API does not add up transactions itself.

"reconciliation": {"status": "reconciled", "ok": true,
                   "opening_cents": 100000, "closing_cents": 81325,
                   "sum_cents": -18675, "computed_cents": 81325,
                   "delta_cents": 0, "transaction_count": 3}

status is one of:

  • reconciled — opening + Σ(amounts) == closing, exactly
  • discrepant — they differ; delta_cents is by how much, signed
  • incomplete — an opening or closing balance is missing, so there is nothing to check

incomplete is never reported as reconciled. A statement with no stated closing balance has not been proved right; it has only not been proved wrong.

One cent is a discrepancy. There is no tolerance by default, deliberately — a penny out is usually a transposed digit somewhere, and rounding it away is how it stays lost.

The per-row audit — the part totals hide

GET /api/statements/{id} also returns running_balance_flags, straight from Statement::runningBalanceCheck(). A statement can reconcile end to end while an individual row's stated running balance is impossible — two errors cancelling, a row entered twice and one deleted. The ends match, so the eye moves on.

"running_balance_flags": [
  {"sort": 1, "index": 1, "expected_cents": 82550, "stated_cents": 83550, "delta_cents": 1000}
]

Empty array means every stated balance is consistent with the amounts. The check runs in absolute mode when an opening balance is known, and in delta mode (each row's balance must move by that row's amount) when it is not.

Portfolio view

GET /api/report
{"statement_count": 12,
 "counts": {"reconciled": 9, "discrepant": 2, "incomplete": 1},
 "unexplained_by_currency": {"GBP": 4200, "EUR": 150},
 "discrepant": [{"id": 7, "name": "March", "bank": "Testbank",
                 "currency": "GBP", "delta_cents": -4200, "transaction_count": 41}]}

Differences are totalled per currency. Ledgira holds no exchange rates, and one combined "total discrepancy" across GBP and EUR would be a number nobody could act on.

Webhooks

Add receiver URLs in API & Webhooks. Events:

  • statement.reconciled — a statement that did not balance now does
  • statement.discrepant — a statement that balanced (or was incomplete) now does not

Both carry the full statement with its reconciliation, so a receiver needs no second call.

They fire only on a CHANGE. Reconciliation is derived on every read, so announcing it on every save would mean a "still discrepant" message per keystroke-level edit. A transition into incomplete is never announced — removing a closing balance is not news. A change is announced wherever it happens: an extraction, a save of the statement, an edit or deletion of one line, a CSV import, or a correction made through /mcp.

Every delivery is signed:

X-Ledgira-Event: statement.discrepant
X-Ledgira-Signature: sha256=<hmac_sha256(body, your endpoint secret)>

Verify by recomputing the HMAC over the raw body with the endpoint's secret. Deliveries are logged (newest 200 per endpoint) with the response code. Delivery is best-effort with a short timeout: a slow receiver must never block a save.

Email notifications

Bring your own SMTP (Settings → Email notifications). Leave the host blank and Ledgira sends nothing — no external service is contacted. Two toggles, matching the two webhooks:

  • Statement reconciles (on by default)
  • Statement does NOT reconcile (off by default — you will usually see it on screen as you work)

CSV import (transactions)

Bank exports come in two shapes and both work:

  • a single signed amount column (-12.50), or
  • separate money out / money in columns

Either way the row lands as one signed integer, via Statement::signedCents(). A row carrying both a debit and a credit is refused — one transaction cannot be both. Rows are appended to the statement you pick, after anything already on it, and the reconciliation is recomputed immediately, so you find out in the same click whether the statement now balances.

Ambiguous dates follow your day-first setting (Settings). Send YYYY-MM-DD and the question never arises.

Nothing is written until you confirm the dry-run report.

Documented limits

  • Read-only API, for the reason above.
  • No POST /api/statements — a statement needs its source document.
  • Extraction is not triggerable over HTTP. It spends money at your LLM provider; that stays a deliberate click.
  • The API never returns a statement's source file. The document is evidence, not a payload for a bearer token.
  • No cross-currency totals, anywhere.

MCP — the agent endpoint (new in 3.0)

Ledgira speaks MCP (Model Context Protocol) on one route, so an assistant can read statements, correct transactions and pull category totals.

POST /mcp
Authorization: Bearer apk_...          ← the SAME revocable key the REST API uses
Content-Type: application/json

GET /mcp returns 405 with Allow: POST, by design.

ToolWrites?What it does
list_statementsnoStatements with bank, period, status and transaction count.
statement_detailnoOne statement plus its reconciliation result and duplicate count.
list_transactionsnoFilter by statement, category, date range, or duplicates only.
transaction_updateYESCorrect a transaction.
category_totalsnoTotals by category, with uncategorised reported separately.
export_rowsnoCSV / QIF / JSON from the product's own exporters.

Connect it to an assistant

Mint the key in the app first: Settings → API keys. Choose Read only when the assistant should answer questions but never change anything — the endpoint then lists only the read tools and refuses the rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

The addresshttps://ledgira.example.com/mcp
The keyheader Authorization: Bearer apk_xxxx
The transportMCP over streamable HTTP, stateless

Claude — one command, or the same URL and header as a custom connector in the desktop and web apps:

claude mcp add --transport http ledgira https://ledgira.example.com/mcp \
  --header "Authorization: Bearer apk_xxxx"

ChatGPT and the OpenAI API — one entry in the Responses API's tools array (in ChatGPT itself, the same URL and key go in as a connector):

{
  "type": "mcp",
  "server_label": "ledgira",
  "server_url": "https://ledgira.example.com/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}

Own Your AI reads a list of servers in this shape:

{
  "mcpServers": [
    { "id": "ledgira", "name": "Ledgira", "url": "https://ledgira.example.com/mcp",
      "token": "apk_xxxx", "enabled": true }
  ]
}

**Every other client spells the same three facts differently — copy the shape from its own documentation, not from here.** VS Code is the clearest example of why: its configuration reference (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"* — not an mcpServers array. Pasted as-is, the block above will not load there. The id, the URL and the token are what travel; the JSON around them belongs to whichever client you are configuring.

A local model, n8n, or your own code — n8n's MCP Client node takes the URL and the same Authorization: Bearer header; a model running on your own machine reaches it through any MCP client, so nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

curl -X POST https://ledgira.example.com/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet: Ledgira implements the protocol, not an integration with a particular vendor.

There is deliberately no extraction tool

Running an extraction calls your LLM provider with your API key and spends your credits. An agent that can trigger it can spend money in a loop, on a schedule, with nobody watching — so extraction stays a human action in the browser. This is the same rule specta applies, and it is a decision rather than an oversight.

If you want an agent to process a new statement, the honest workflow is: a person uploads and extracts, then the agent reviews, categorises and exports.

Direction is derived, never set

amount is signed — debits negative — and direction comes from Statement::directionOf(). You cannot send an amount of -12.50 with direction: "credit" and have both stick, because there is only one source of truth for which way the money went.

Categorising through MCP behaves exactly like categorising in the browser

Setting category marks it as a human decision, which means no rule will ever overwrite it, and Ledgira learns a payee rule from it for future statements. The response includes rule_learned so the caller can see that happened.

Every MCP write lands in the audit trail with via: mcp.

← Back to Ledgira · Manual · Quickstart · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →