Expensa API, as shipped in the download
Expensa REST API & Webhooks (v3.0)
Expensa ships a JSON REST API and signed webhooks so expense data can arrive from the systems that already hold it — a card feed, an accounting export, a travel tool — and so a finished report can be pulled into your books without retyping.
Money is integer cents, and per currency. Expensa holds no exchange rates and will not invent one, so totals are never summed across currencies. See Multi-currency below.
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 exk_...
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 & path | What it does |
|---|---|
GET /api/ping | Verify a key |
GET /api/reports | Newest 50, each with its derived state and per-currency totals |
POST /api/reports | Create an empty report |
GET /api/reports/{id} | One report with its receipt lines |
POST /api/reports/{id}/lines | Add a keyed-in expense line (no file) |
GET /api/receipts/{id} | One receipt (metadata only) |
POST /api/receipts/{id}/status | Mark a receipt reviewed |
GET /api/categories | Configured categories + base currency |
GET /api/openapi.json | OpenAPI 3 spec (no auth) |
GET /healthz | {"ok":true,"app":"expensa","version":"2.0.0"} (no auth) |
Add an expense line
curl -X POST https://your-install/api/reports/4/lines \
-H "Authorization: Bearer exk_..." -H "Content-Type: application/json" \
-d '{"vendor": "Blue Cafe", "amount": "12,50", "currency": "€",
"txn_date": "04/03/2026", "category": "meals", "payment_method": "visa"}'
Every one of those awkward values is handled, because the API runs the request through _receipt_fields_from() — the same normalisation the receipt edit form uses. "12,50" becomes 1250 cents, "€" becomes EUR, "meals" resolves against your configured category list, and "visa" normalises to a payment method. A field cannot mean one thing in the UI and another over HTTP.
A keyed-in line is born reviewed: a person typed it, so there is no image for Expensa to read. vendor and amount are required — an expense with no payee or no amount is not an expense.
Ambiguous dates follow your day_first setting (Settings → Day-first dates). With it off, 04/03/2026 is 3 April; with it on, 4 March. Send YYYY-MM-DD and the question never arises — recommended for any integration.
The review state machine
A receipt climbs a three-rung ladder: pending → extracted → reviewed.
POST /api/receipts/{id}/status reuses Extraction::markReviewed(), which **refuses to promote a receipt that was never extracted**. That is the whole point of the rung: "reviewed" has to mean a person checked figures that something had actually read.
{"error": "conflict",
"detail": "This receipt is still \"pending\" — run extraction (or fill its fields) before
marking it reviewed."}
reviewed is the only transition the API offers. extracted is produced by running extraction; pending is where a receipt starts.
A report's state is derived, never stored
reports has no status column. A report's state is computed from the weakest receipt in it, using the same ladder:
empty— no receipts yetpending— at least one receipt nothing has readextracted— everything read, something still unchecked by a personreviewed— every line checked
Because it is derived, it cannot go stale and cannot be hand-edited into a lie.
Multi-currency: totals are never summed
"multi_currency": true,
"totals": {"EUR": {"amount_cents": 11150, "tax_cents": 200, "count": 2},
"USD": {"amount_cents": 20000, "tax_cents": 0, "count": 1}},
"priced": 3, "unpriced": 1
There is no grand total, deliberately. Expensa holds no exchange rates; producing one number from EUR and USD would require inventing a rate and quietly putting it in your accounts.
A receipt with no amount is counted in unpriced and contributes nothing — it is not treated as zero, because "we don't know yet" and "it cost nothing" are different facts.
Webhooks
Add receiver URLs in API & Webhooks. Events:
receipt.reviewed— a person checked one receiptreport.reviewed— the last unreviewed receipt in a report was checked; the report is ready to file (carries the report with its per-currency totals, so a receiver needs no second call)extraction.failed— a receipt could not be read (the one place this product genuinely gets stuck); carries the reason
Every delivery is signed:
X-Expensa-Event: report.reviewed
X-Expensa-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 review.
Email notifications
Bring your own SMTP (Settings → Email notifications). Leave the host blank and Expensa sends nothing — no external service is contacted. Two toggles:
- Report fully reviewed (on by default) — one message with the per-currency totals
- A receipt could not be read (off by default) — the extraction failure, with the reason
Documented limits
- No receipt upload over the API, in 2.0. Uploading is a multipart flow with type and size gates, virus-surface considerations and a storage path; it stays in the UI. `POST /api/reports/{id}/lines` covers the case an integration actually has — a line you already have the data for.
- The API never returns a receipt's file or its stored path. A receipt image is evidence; it is not handed out on a bearer token.
GET /api/receipts/{id}returns metadata only. - Extraction is not triggerable over the API. It spends money at your LLM provider; that stays a deliberate click.
- Receipts are create-and-read plus the one status transition. Editing and deleting stay in the UI.
- **The original spec for this product named
report.submitted/report.approved/report.rejectedand a report-level status endpoint.** Expensa has no approval workflow and no report status column — its state machine lives on receipts. Rather than invent an approver role the product does not have, 2.0 exposes the state machine that exists and derives the report's state from it. See the tab log for the full reasoning.
Own It 3.0 — the agent endpoint and the rest
MCP — Expensa for an AI assistant
Expensa speaks MCP (Model Context Protocol) on one route, so an assistant can triage a pile of reports, correct extracted figures and pull totals — without anyone writing an integration.
POST /mcp
Authorization: Bearer <your API key> ← the SAME revocable key the REST API uses
Content-Type: application/json
The transport is stateless streamable-HTTP: one JSON-RPC request per POST. GET /mcp answers 405 with Allow: POST, so a misconfigured client is told what to do rather than left guessing at a 404.
| Tool | Writes? | What it does |
|---|---|---|
list_reports | no | Reports with state, receipt counts and per-currency totals |
report_detail | no | One report: every receipt, the totals, and any flagged duplicates |
create_report | YES | Creates an empty report |
receipt_update | YES | Corrects a receipt's fields and marks it reviewed |
category_totals | no | Spend by category per currency, for one report or all |
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 address | https://your-install/mcp |
| The key | header Authorization: Bearer apk_xxxx |
| The transport | MCP 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 expensa https://your-install/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": "expensa",
"server_url": "https://your-install/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "expensa", "name": "Expensa", "url": "https://your-install/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://your-install/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: Expensa implements the protocol, not an integration with a particular vendor.
Receipt files cannot be uploaded through this endpoint
That is deliberate, not missing. MCP is a JSON transport: shipping a receipt photo through it means base64 inside a tool argument, which breaks the scalar-argument guard every other tool relies on and puts a 10 MB blob where an audit entry has to live. Uploading stays in the browser. The agent's job here is the paperwork around the receipts, not carrying them.
Nothing here does its own arithmetic
report_detail and category_totals both call Extraction::aggregate() — the same function behind the totals on screen and in the PDF. A second adder that agrees today is a defect that disagrees the day someone changes rounding or adds a currency.
receipt_update goes through the same _apply_receipt_update() the edit screen uses, so the money parsing, the install's date-order setting (04/03/2026 means different things in different offices), the category normalisation and the review-status rule are identical whichever surface writes. A partial update leaves untouched fields alone rather than blanking them.
Duplicates are advisory, over the API too
report_detail returns a possible_duplicates array alongside the receipts — and the flagged receipts are still in the list and still in the totals:
"possible_duplicates": [
{"receipt_id": 42, "duplicate_of_id": 17, "vendor": "Uber", "txn_date": "2026-03-04",
"amount_cents": 1250, "note": "Flagged only. It is still in the report and in the totals."}
]
An agent should raise it with a human, not act on it. The match is vendor + date + amount, across every report; a receipt missing an amount or a date is never a duplicate of anything.
Roles
| Role | Can |
|---|---|
viewer | Read and export reports and totals — change nothing |
member | …plus upload receipts, correct them, save views |
admin | …plus settings, branding, restore, the team |
An API key can do exactly what its owner can do. A viewer key is refused on create_report and receipt_update, in those words.
There is no approval workflow
Worth stating plainly, because expense tools usually have one. Expensa has no approve, no reject, no routing to a manager and no state a report has to be "passed" through. reviewed means a person has checked the figures the AI extracted. If you need an approval chain, this is not that product.
The webhook events are receipt.reviewed, report.reviewed and extraction.failed — that is the complete list.
Subject data export
GET /gdpr/export.json?q=<vendor name>
A receipt's vendor is often a person — a driver, a sole trader, a contractor. This returns every receipt naming them, with the report it belongs to. The **search term is never written to the audit log**; only that an export ran and how many receipts it matched. Logging who was looked up would create exactly the record the request was about.
There is deliberately no erasure endpoint. A receipt is a business record backing a tax deduction. Blanking the vendor would not protect that person — the amount, the date and the tax line have to stay, because that is what the record is for — it would only make the books unauditable. Erasing accounting records is a retention decision between the business and its tax authority, on their timetable, not a button in an expense tool.
The report PDF
GET /reports/{id}/export.pdf ← a real PDF (3.0)
GET /reports/{id}/print ← the print-friendly HTML view
Until 3.0 the first URL returned HTML and relied on the browser's "Save as PDF", so a script that trusted the extension got a web page. It now renders a genuine PDF; the print view kept its usefulness and got an honest URL.
Scheduled backup
GET /backup/run?t=<token>
Mint the token in Backup & restore. Fourteen files are kept and older ones pruned. Both JSON backups — the scheduled one and the download — strip the same credentials, including your **AI provider key**, which is a live billable credential. The .sqlite download is the whole install.