# 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 & 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 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.

| 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:

```sh
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):

```json
{
  "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](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(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:

```sh
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**:

```json
"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 |
| `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.

| Event | Fires when |
|---|---|
| `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… |
| `report.paid` | An approver marked a fully approved report paid; the payload carries the report and its totals |
| `extraction.failed` | A 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.
