Documentation · Developers

Your ledger, programmable.

The same books the app keeps, through a REST API and an MCP server: read every account and transaction, post entries, refile and set things aside, from a script or from an AI agent. 220 operations across 46 resources today, and growing.

Quickstart

In the app, open Settings → API & MCP and make a token. A read token can look at everything; a write token (editors and owners only) can also change things. The token is shown once, and it belongs to one ledger.

List your accounts
export LUMALEDGER_TOKEN="ll_…"
curl "https://api.lumaledger.app/v1/accounts" \
  -H "Authorization: Bearer $LUMALEDGER_TOKEN"
Record a cash purchase
curl -X POST "https://api.lumaledger.app/v1/transactions" \
  -H "Authorization: Bearer $LUMALEDGER_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "date": "2026-09-01",
    "description": "Farmers market",
    "lines": [
      {"account_id": 5010, "direction": "debit",  "amount": "24.00"},
      {"account_id": 1090, "direction": "credit", "amount": "24.00"}
    ]
  }'

Conventions

Money is never a float. Every amount is {"amount": "1234.50", "currency": "USD"}, a decimal string with its currency, and amounts you send are decimal strings too. The schema has no JSON numbers at all.

Lists are pages. Every list answers {"data": [...], "has_more": true, "next_cursor": "…"}. Pass cursor to get the next page and limit (1–200) to size it.

Errors have a stable code. Every error is {"error": {"type", "code", "message", "param", "request_id"}}. Branch on code; message follows your Accept-Language and is for people. Quote request_id (also the X-Request-Id header) when you contact us.

Writes happen once. Send an Idempotency-Key with every create: a retry with the same key gets the first answer back (with Idempotent-Replayed: true) instead of a second entry. Keys last 24 hours.

Rate limits. 120 requests a minute per token. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; over the limit is a 429 with Retry-After.

Versioning. The version is the URL. /v1 only ever grows: new fields, operations and error codes, never a renamed or removed one, so ignore fields you do not know.

Webhooks and the change feed

Instead of polling every list, follow the ledger's changes: transaction.created, transaction.updated, transaction.deleted, account.balance_updated, budget.exceeded, import.completed and job.completed. A change carries ids, not records (fetch the transaction for its current state), and it is written in the same transaction as what it describes, so it exists exactly when that happened.

Poll the feed. GET /v1/changes lists the last 30 days, oldest first. Keep the next_cursor it answers with, even on the last page, and pass it as cursor next time: you get exactly what happened since, in order, never twice.

Follow the change feed
curl "https://api.lumaledger.app/v1/changes?cursor=$CURSOR" \
  -H "Authorization: Bearer $LUMALEDGER_TOKEN"

Or have them pushed. Register an https endpoint with the change types it wants (* for all). The answer carries its signing secret, whsec_…, shown once.

Register an endpoint
curl -X POST "https://api.lumaledger.app/v1/webhook-endpoints" \
  -H "Authorization: Bearer $LUMALEDGER_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/lumaledger",
       "change_types": ["transaction.created", "import.completed"]}'

Each change is POSTed to it as JSON, the same object the feed serves, with a signature. Answer with any 2xx within 10 seconds. Redirects are not followed. The URL must reach the public internet: hosts that resolve to private, loopback or link-local addresses are refused, when you save the endpoint and again before every delivery.

A delivery
POST /hooks/lumaledger HTTP/1.1
Content-Type: application/json
LumaLedger-Signature: t=1790000000,v1=5f2b9c…
LumaLedger-Change-Id: 4812
LumaLedger-Change-Type: transaction.created

{"id":4812,"type":"transaction.created","created_at":"2026-09-24T12:00:03+00:00",
 "data":{"transaction_id":90211}}

Verify every delivery. v1 is the hex HMAC-SHA256 of <t>.<raw body> under your secret. Compare it in constant time, and refuse a t more than five minutes from your clock, so a captured delivery cannot be replayed. For a day after webhook_endpoints.rotate_secret the header carries two v1 values, one per secret; accept either.

Verify a signature (Python)
import hashlib, hmac, time

def verify(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    """raw: the request body exactly as received, before parsing it."""
    fields = [part.split("=", 1) for part in header.split(",")]
    t = next((v for k, v in fields if k == "t"), "")
    signatures = [v for k, v in fields if k == "v1"]
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False  # too old: a replay
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)
Verify a signature (Node.js)
import crypto from "node:crypto";

// raw: the request body exactly as received (a Buffer), before parsing it.
export function verify(raw, header, secret, toleranceSeconds = 300) {
  const fields = header.split(",").map((part) => part.split("="));
  const t = Number(fields.find(([k]) => k === "t")?.[1]);
  const signatures = fields.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(raw).digest("hex");
  return signatures.some((s) => s.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

Retries. A delivery that is not accepted is tried again after a minute, then three times longer each time, up to 12 hours apart: twelve tries over about three days. Delivery is at least once, so dedupe on the change id (also in LumaLedger-Change-Id). An endpoint that accepts nothing for three days is disabled and the ledger's owners get an email; turn it back on with webhook_endpoints.update, and catch up on what it missed from the feed.

TypeScript SDK

@lumaledger/sdk is generated from the same schema as this reference, so every operation is a typed method named after it: transactions.list is client.transactions.list(). It has no dependencies beyond fetch, and runs on Node 20+ and in browsers.

TypeScript
import { LumaLedger, Money } from "@lumaledger/sdk";

const client = new LumaLedger({ token: process.env.LUMALEDGER_TOKEN });

// Every transaction since September, page after page
for await (const txn of client.transactions.listAll({ since: "2026-09-01" })) {
  console.log(txn.date, txn.description, Money.format(txn.amount, "en-US"));
}

// Safe to retry: creates carry an Idempotency-Key automatically
await client.transactions.create({
  date: "2026-09-01",
  description: "Farmers market",
  lines: [
    { account_id: 5010, direction: "debit", amount: "24.00" },
    { account_id: 1090, direction: "credit", amount: "24.00" },
  ],
});

It retries rate limits (honoring Retry-After) and server errors wherever a retry cannot do the work twice, throws typed errors carrying code, param and request_id, and does money math on exact decimals with Money, never floats. It is not on npm yet; until it is, call the API directly as in the quickstart.

Connect an AI agent (MCP)

LumaLedger is an MCP server. Every operation in the reference below is also a tool (transactions.list is transactions_list), next to the assistant's own read tools. A read token shows an agent the read tools only, and a write token adds the writes, each marked read-only, idempotent or destructive so your client can ask before it acts.

Claude Code
claude mcp add --transport http lumaledger https://api.lumaledger.app/api/mcp \
  --header "Authorization: Bearer $LUMALEDGER_TOKEN"

In Claude Desktop and other clients, add a custom connector with the URL https://api.lumaledger.app/api/mcp and the header Authorization: Bearer ll_….

Coming soon: sign in instead of pasting a token. The server speaks the MCP authorization spec (OAuth 2.1 with PKCE), so a client that supports it will only need the URL: it opens a sign-in window, you pick the ledger it may read, and it never sees a token you could leak. Apps connected this way read one ledger and cannot change anything, and you can disconnect them in Settings. It is switched on once our authorization server leaves beta; until then, use a token.

Reference

Generated from the API's own schema, so it is never out of date. The same schema is at openapi.json for SDK generators, and the whole reference as plain text is at llms.txt for language models.

Account Types

  • get/account-types

Accounts

  • get/accounts
  • post/accounts
  • delete/accounts/{account_id}
  • get/accounts/{account_id}
  • patch/accounts/{account_id}
  • put/accounts/{account_id}/balance
  • post/accounts/{account_id}/close
  • get/accounts/{account_id}/ledger
  • post/accounts/{account_id}/purge

Alternatives

  • get/alternatives
  • post/alternatives
  • delete/alternatives/{investment_id}
  • get/alternatives/{investment_id}
  • patch/alternatives/{investment_id}
  • post/alternatives/{investment_id}/documents
  • delete/alternatives/{investment_id}/documents/{investment_document_id}

Budgets

  • get/budget-categories
  • get/budget-cycle
  • put/budget-cycle
  • get/budgets
  • post/budgets
  • delete/budgets/{budget_id}
  • get/budgets/{budget_id}
  • patch/budgets/{budget_id}
  • put/budgets/{budget_id}/period-amount
  • post/budgets/fill
  • get/budgets/summary

Cards

  • get/cards

Categories

  • post/categories

Categorization

  • post/categorization/run
  • post/journals/generate
  • get/merchant-mappings
  • post/merchant-mappings
  • get/posting-rules

Changes

  • get/changes
  • get/changes/{change_id}

Counterparties

  • get/counterparties
  • get/counterparties/{counterparty_key}
  • patch/counterparties/{counterparty_key}
  • get/counterparties/{counterparty_key}/about
  • post/counterparties/{counterparty_key}/category
  • post/counterparties/{counterparty_key}/identify
  • delete/counterparties/{counterparty_key}/merge
  • post/counterparties/{counterparty_key}/merge
  • post/counterparties/{counterparty_key}/rules
  • delete/counterparties/{counterparty_key}/rules/{counterparty_rule_id}

Documents

  • get/documents
  • post/documents
  • delete/documents/{document_id}
  • get/documents/{document_id}
  • get/documents/{document_id}/download
  • post/documents/statements/sync

Equity Grants

  • get/equity-grants
  • post/equity-grants
  • delete/equity-grants/{equity_grant_id}
  • get/equity-grants/{equity_grant_id}

Events

  • get/events
  • get/events/{event_id}
  • post/events/{event_id}/categorize
  • delete/events/{event_id}/splits
  • get/events/{event_id}/splits
  • put/events/{event_id}/splits

Files

  • post/files
  • delete/files/{file_id}
  • get/files/{file_id}

Holdings

  • get/holdings

Imports

  • get/imports
  • post/imports
  • post/imports/preview
  • post/imports/rocket-money
  • post/imports/rocket-money/preview

Insights

  • get/insights

Institutions

  • get/institutions
  • post/institutions
  • delete/institutions/{institution_id}
  • get/institutions/{institution_id}
  • patch/institutions/{institution_id}
  • get/institutions/{institution_id}/about
  • post/institutions/{institution_id}/accounts
  • post/institutions/{institution_id}/identify
  • post/institutions/{institution_id}/purge

Jobs

  • get/jobs/{job_id}

Loans

  • get/loans
  • post/loans
  • delete/loans/{loan_id}
  • get/loans/{loan_id}
  • patch/loans/{loan_id}
  • post/loans/{loan_id}/status
  • get/loans/payoff-plan

Me

  • get/me

Net Worth

  • get/net-worth
  • get/net-worth/breakdown
  • get/net-worth/cash-real-value
  • get/net-worth/history

Notifications

  • get/notifications
  • get/notifications/preferences
  • patch/notifications/preferences
  • post/notifications/read

Paystubs

  • get/paystubs
  • post/paystubs
  • delete/paystubs/{paystub_id}
  • get/paystubs/{paystub_id}
  • get/paystubs/{paystub_id}/download
  • post/paystubs/{paystub_id}/reprocess
  • get/paystubs/breakdown

Plaid Items

  • get/plaid-items
  • post/plaid-items/sync

Plan

  • get/plan
  • patch/plan
  • get/plan/milestones
  • post/plan/milestones
  • delete/plan/milestones/{milestone_id}
  • get/plan/milestones/{milestone_id}
  • patch/plan/milestones/{milestone_id}
  • get/plan/projection

Portfolio

  • get/portfolio
  • get/portfolio/accounts/{account_id}
  • get/portfolio/activity
  • get/portfolio/positions
  • get/portfolio/positions/{symbol}

Project Budgets

  • get/project-budgets
  • post/project-budgets
  • delete/project-budgets/{project_budget_id}
  • get/project-budgets/{project_budget_id}
  • patch/project-budgets/{project_budget_id}

Properties

  • get/properties
  • post/properties
  • delete/properties/{property_id}
  • get/properties/{property_id}
  • patch/properties/{property_id}
  • get/properties/affordability

Pulse

  • get/pulse

Receipts

  • get/receipts
  • post/receipts
  • delete/receipts/{receipt_id}
  • get/receipts/{receipt_id}
  • post/receipts/{receipt_id}/attach
  • post/receipts/{receipt_id}/detach
  • get/receipts/{receipt_id}/download
  • post/receipts/{receipt_id}/not-duplicate
  • post/receipts/{receipt_id}/reprocess

Reconciliation

  • get/reconciliation/exceptions
  • post/reconciliation/exceptions/{exception_id}/resolve
  • get/reconciliation/review-queue
  • post/reconciliation/run
  • post/reconciliation/statements

Recurring

  • get/recurring
  • get/recurring/calendar
  • post/recurring/declare
  • get/recurring/forecast
  • post/recurring/status

Reports

  • get/reports/annual
  • get/reports/monthly
  • get/reports/recap

Rules

  • get/rules
  • post/rules
  • delete/rules/{rule_id}
  • get/rules/{rule_id}
  • put/rules/{rule_id}
  • post/rules/{rule_id}/run
  • post/rules/preview
  • post/rules/run-all
  • get/rules/schema

Savings Goals

  • get/savings-goals
  • post/savings-goals
  • delete/savings-goals/{savings_goal_id}
  • get/savings-goals/{savings_goal_id}
  • patch/savings-goals/{savings_goal_id}

Schedules

  • get/schedules
  • post/schedules
  • delete/schedules/{schedule_id}
  • get/schedules/{schedule_id}
  • patch/schedules/{schedule_id}
  • get/schedules/{schedule_id}/runs
  • post/schedules/run

Stakes

  • get/stakes
  • delete/stakes/{stake_id}
  • get/stakes/{stake_id}
  • patch/stakes/{stake_id}
  • post/stakes/{stake_id}/revalue

Statements

  • get/statements/balance-sheet
  • get/statements/cash-flow
  • get/statements/income
  • get/statements/income/breakdown

Tags

  • get/tags
  • post/tags
  • delete/tags/{tag_id}
  • get/tags/{tag_id}
  • patch/tags/{tag_id}
  • put/transactions/{transaction_id}/tags

Tasks

  • get/tasks
  • post/tasks/dismiss
  • post/tasks/restore
  • post/tasks/snooze
  • post/tasks/unsnooze

Transactions

  • get/transactions
  • post/transactions
  • delete/transactions/{transaction_id}
  • get/transactions/{transaction_id}
  • patch/transactions/{transaction_id}
  • post/transactions/categorize

Transfer Accounts

  • get/transfer-accounts
  • post/transfer-accounts

Transfer Suggestions

  • get/transfer-suggestions
  • post/transfer-suggestions/{account_id}/accept
  • delete/transfer-suggestions/{account_id}/decision
  • post/transfer-suggestions/{account_id}/dismiss

Unlinked Institutions

  • get/unlinked-institutions

Vehicles

  • get/vehicles
  • post/vehicles
  • delete/vehicles/{vehicle_id}
  • get/vehicles/{vehicle_id}
  • patch/vehicles/{vehicle_id}

Webhook Endpoints

  • get/webhook-endpoints
  • post/webhook-endpoints
  • delete/webhook-endpoints/{webhook_endpoint_id}
  • get/webhook-endpoints/{webhook_endpoint_id}
  • patch/webhook-endpoints/{webhook_endpoint_id}
  • post/webhook-endpoints/{webhook_endpoint_id}/rotate-secret