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 canceling, 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 totaled 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. Since 3.3.0 a date sent with its time (2026-09-10T08:15:02Z, 2026-09-10 08:15:02) is read as the date it shows; the time and zone are dropped.

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 uncategorized 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"
}

**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"*. Pasted as-is, a block written for another client 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, categorizes 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.

Categorizing through MCP behaves exactly like categorizing 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.

Connect it to Zapier, Make or n8n

Ledgira talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

Before you start

  • In Ledgira, open API & Webhooks. Mint an API key (choose Read only if the other tool only reads), and add a webhook when you have its URL (below).
  • Use your install's full address, for example https://ledgira.yourcompany.com.

When something happens in Ledgira: send it to another tool

  1. In the other tool, create the trigger that gives you a URL to receive webhooks: - Zapier: the "Webhooks by Zapier" app, trigger Catch Hook (Webhooks by Zapier needs a paid Zapier plan). - Make: module Webhooks → Custom webhook. - n8n: the Webhook node.
  2. Copy that URL into Ledgira under API & Webhooks, and choose the event or events below.
  3. Make the event happen once in Ledgira, so the other tool receives a sample and you can map its fields.
EventFires when
statement.reconciledA statement that did not balance now does
statement.discrepantA statement that balanced (or was incomplete) now does not

Every delivery carries X-Ledgira-Signature, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

When something happens elsewhere: act in Ledgira

  • Zapier: "Webhooks by Zapier", action POST (or Custom Request).
  • Make: module HTTP → Make a request.
  • n8n: the HTTP Request node.

Send the request to your install's address plus the path from the Endpoints table above, with the header Authorization: Bearer <your API key> and a JSON body. A read-only key cannot write.

Example recipes

  • statement.reconciled → a row in Google Sheets: webhook trigger on statement.reconciled (A statement that did not balance now does), then the Google Sheets "Create row" action with the fields you want.
  • statement.discrepant → a message in Slack or Teams: webhook trigger on statement.discrepant (A statement that balanced (or was incomplete) now does not), then the Slack or Microsoft Teams "Send message" action.

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Ledgira's own automated tests check its API.

← Back to Ledgira · Manual · Quickstart · Test run