Home›Expensa›API
Expensa · API

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.

Every write checks the key holder's role, exactly as the screen does: a viewer's key cannot create a report or add a line, and only an approver's key can mark a receipt reviewed (403 forbidden). A claimant's key also sees only that claimant's own reports, on the REST API and over MCP alike: another report or its receipts answer 404, a report it creates is its own, and a line it adds (or a receipt it corrects) waits for an approver, as on the screen.

/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/reportsNewest 50, each with its derived state and per-currency totals
POST /api/reportsCreate an empty report
GET /api/reports/{id}One report with its receipt lines
POST /api/reports/{id}/linesAdd a keyed-in expense line (no file)
GET /api/receipts/{id}One receipt (metadata only)
POST /api/receipts/{id}/statusMark a receipt reviewed
GET /api/categoriesConfigured categories + base currency
GET /api/openapi.jsonOpenAPI 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 normalization the receipt edit form uses. "12,50" becomes 1250 cents, "€" becomes EUR, "meals" resolves against your configured category list, and "visa" normalizes 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. Since 3.3.0 a date sent with its time (2026-03-04T08:10:00Z, 2026-03-04 12:34:56) is read as the date it shows; the time and zone are dropped.

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 yet
  • pending — at least one receipt nothing has read
  • extracted — everything read, something still unchecked by a person
  • reviewed — 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.

3.2.0 fields

Every receipt object now carries line_type (receipt, mileage or per_diem); for the last two, quantity (the distance or days, e.g. "42.5"), rate_cents and the note (route or purpose). Every report carries paid_at (a date, or null until paid), paid_ref and owner_user_id (the user who created it; 0 for reports made before 3.2.0 or through the API). Mileage and per-diem lines are added on the report screen from the owner's rates; the API's keyed-in line endpoint is unchanged.

Webhooks

Add receiver URLs in API & Webhooks. Events:

  • receipt.reviewed — a person checked one receipt
  • report.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)
  • report.paid (3.2.0) — an approver marked a fully approved report paid; carries the report with paid_at, paid_ref and its totals
  • 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.rejected and 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.

ToolWrites?What it does
list_reportsnoReports with state, receipt counts and per-currency totals
report_detailnoOne report: every receipt, the totals, and any flagged duplicates
create_reportYESCreates an empty report
receipt_updateYESCorrects a receipt's fields and marks it reviewed
category_totalsnoSpend 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 addresshttps://your-install/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 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"
}

**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://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 normalization 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

RoleCan
viewerRead and export reports and totals — change nothing
claimant (3.2.0)Their own reports only: create reports, add receipts and mileage / per-diem lines, correct their own lines. Never approves, marks paid or sees anyone else's reports
member…plus upload receipts, correct and approve them, mark reports paid, 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.

3.2.0 adds the two steps around that check, and still no chain: a claimant sees and adds to their own reports but never approves them, and once every line is approved, an approver can record that the claimant was paid (a date and an optional reference). A paid report's lines are closed until it is marked unpaid.

The webhook events are receipt.reviewed, report.reviewed, report.paid 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.

Connect it to Zapier, Make or n8n

Expensa 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 Expensa, 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://expensa.yourcompany.com.

When something happens in Expensa: 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 Expensa under API & Webhooks, and choose the event or events below.
  3. Make the event happen once in Expensa, so the other tool receives a sample and you can map its fields.
EventFires when
receipt.reviewedA person checked one receipt
report.reviewedThe last unreviewed receipt in a report was checked; the report is ready to file (carries the report with its per-currency totals, so a…
report.paidAn approver marked a fully approved report paid; the payload carries the report and its totals
extraction.failedA receipt could not be read (the one place this product genuinely gets stuck); carries the reason

Every delivery carries X-Expensa-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 Expensa

  • 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.

The endpoints that change something:

  • POST /api/reports: Create an empty report
  • POST /api/reports/{id}/lines: Add a keyed-in expense line (no file)
  • POST /api/receipts/{id}/status: Mark a receipt reviewed

Example recipes

  • receipt.reviewed → a row in Google Sheets: webhook trigger on receipt.reviewed (A person checked one receipt), then the Google Sheets "Create row" action with the fields you want.
  • report.reviewed → a message in Slack or Teams: webhook trigger on report.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…), then the Slack or Microsoft Teams "Send message" action.
  • A new form entry or row elsewhere → Expensa: the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling POST /api/reports (Create an empty report).

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

← Back to Expensa · Manual · Quickstart · Test run