# Cashora REST API & Webhooks (v2.0)

Cashora ships a JSON REST API and signed webhooks so a petty-cash tin can be read and topped up
from your own code — a kiosk that records a payout, a nightly job that pulls every box balance
into a spreadsheet, a Slack notice when the float runs low.

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

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/boxes` | Every box with its live balance, variance and status |
| `GET  /api/boxes/{id}/entries` | Newest 50 entries. `?direction=in\|out`, `?from=`, `?to=` |
| `POST /api/boxes/{id}/entries` | Record a disbursement (`out`) or a replenishment (`in`) |
| `GET  /api/report` | Period report for a box. `?box=`, `?month=YYYY-MM` |
| `GET  /api/openapi.json` | OpenAPI 3 spec (no auth) |
| `GET  /healthz` | `{"ok":true,"app":"cashora","version":"2.0.0"}` (no auth) |

### Record a payout

    curl -X POST https://your-install/api/boxes/1/entries \
      -H "Authorization: Bearer csk_..." -H "Content-Type: application/json" \
      -d '{"direction": "out", "amount": "12.50", "payee": "Stationers",
           "date": "2026-02-03", "category_id": 4, "receipt_ref": "R-118"}'

Money is **decimal in, integer cents out**.

### The one rule that matters

A tin cannot hold less than nothing. A payout larger than the box balance is refused — by the API
and by the form, using the *same function*: `disbursement_refusal()`. It is defined once in
`controllers/app.php`; the API calls it rather than re-deriving the comparison, so the rule can
never apply on screen and not over HTTP. A refusal returns **422** with the sentence the form
would have shown:

    {"error": "unprocessable",
     "detail": "This box holds €430.00 — a payout of €500.00 would take it below zero, which a
                cash box cannot do. If the cash was there, record the replenishment that topped
                the box up first, then this disbursement."}

Balances are never recomputed in the API layer either: every `balance_cents`, `variance_cents` and
`status` comes from `_box_snapshot()` + `Cash::balance()` / `Cash::variance()` /
`Cash::statusLabel()` — the same core the dashboard and the box page render. `/api/report` calls
the report's own `_report_params()` + `_report_data()`, so the API, the HTML report and the CSV
cannot disagree.

## Webhooks

Add receiver URLs in **API & Webhooks**. Event:

- `entry.recorded` — a disbursement or replenishment is recorded, on the form, through the API or
  by the AI tool (carries the entry *and* the box's balance after it, so a receiver can act on a
  low float without a second call). A CSV import records its rows without sending an event per row.

Every delivery is signed:

    X-Cashora-Event: entry.recorded
    X-Cashora-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, so "did it fire?" is answerable from the admin
page.

Delivery is best-effort with a short timeout: a slow receiver must never block someone recording
a payout at the tin.


## MCP — the agent surface (new in 3.0)

Cashora speaks **MCP** (Model Context Protocol) at `POST /mcp`, so an AI assistant can read your
floats and record movements without anyone writing an integration. Same server, same data, same
rules — an agent is just another client.

**Authentication is the API key you already have.** Send it exactly as you would to `/api/*`:

    Authorization: Bearer <your api key>

The transport is stateless streamable-HTTP: one JSON-RPC request per POST, no session to keep
alive. `GET /mcp` answers **405** with `Allow: POST` so a misconfigured client is told what to do
rather than left guessing.

### 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 cashora 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": "cashora",
  "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:
Cashora implements the protocol, not an integration with a particular vendor.

### Tools

| Tool | Writes? | What it does |
|---|---|---|
| `list_boxes` | no | Every cash box with its live balance, float status and custodian |
| `box_detail` | no | One box's entries — replenishments and disbursements, newest first |
| `record_entry` | **YES** | Records a disbursement or a replenishment against a box |
| `month_report` | no | The month-end figures: opening, in, out, closing and category totals |

Every description in `tools/list` states plainly whether the tool writes, because that is what an
agent needs in order to decide whether it is allowed to call it.

### The one rule does not relax for agents

`record_entry` calls **the same `disbursement_refusal()` the payout screen calls**. A payout of one
cent more than the box holds comes back as a tool error carrying the same sentence a person at the
tin would read:

```json
{"content":[{"type":"text","text":"This box holds \u20ac267.10 \u2014 a payout of \u20ac267.11 would take it below zero, which a cash box cannot do. If the cash was there, record the replenishment that topped the box up first, then this disbursement."}],
 "isError":true}
```

That is not a second copy of the rule phrased for machines. It is the rule, and the reason it
carries the remedy is that an agent — like a person — can act on "record the replenishment first"
and cannot act on "422 unprocessable".

Paying out **exactly** the balance succeeds and leaves the box at zero. The rule is *never below
zero*, not *never to zero*: an emptied tin is a normal Friday.

Argument hygiene mirrors the REST API: every argument must be a single value, an unknown argument is
refused **by name**, and an out-of-enum `kind` lists the valid options
(`kind must be one of: disbursement, replenishment.`). Roles apply exactly as in the browser — a
viewer's key is refused `record_entry`.

Domain refusals come back as tool errors (`isError: true`) rather than protocol errors, because
those are the ones a model can read and correct. Protocol mistakes — bad JSON, an unknown tool —
are JSON-RPC errors.

### Quick check

    curl -s -X POST http://your-install/mcp \
      -H "Authorization: Bearer <key>" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

### There is no `close_month` tool

Closing a month is a decision, not a calculation — it is the moment someone says the count on the
table matches the count on the screen. `month_report` gives an agent every figure needed to prepare
that decision. Making it is still yours.

## Month-end pack (new in 3.0)

`GET /report/export.pdf?month=YYYY-MM` renders the same month-end figures as the Report screen and
the CSV export, as a PDF a bookkeeper can file. Requires a signed-in session or an API key.

## Documented limits

- **Entries are create-and-read.** Deleting an entry stays in the UI — a cash ledger correction
  should be a deliberate act with a person's eyes on the resulting balance.
- **Boxes and categories are read-only over the API.** Creating a box sets a float, which is an
  accounting decision, not an integration one.
- **No notification emails.** Cashora has no per-event recipient and no in-product low-float
  threshold to fire on, so 2.0 ships the API, 2FA, import, backup, rate limiting and dark mode,
  and no SMTP block. The `entry.recorded` webhook — which carries the post-entry balance — is the
  integration point for a low-float alert.
- **Report is read-only** and always reflects the live ledger.

## Closed months (new in 3.2)

A box's month can be counted and closed on the box page. Once it is, `POST /api/boxes/{id}/entries`
refuses an entry dated in that month with `422` and the sentence the screen shows ("<Month> is closed for
this box … Record a correction in an open month …"); the MCP `record_entry` tool refuses it the same way.
The MCP `month_report` gives each box a `closed` object for the month (`closed_at`, `counted_cents`,
`variance_cents`, `custodian`, `reviewer`), or `null` while it is open. Closing and reopening are browser
actions only.

## Approval limit (new in 3.2, pass B)

With an approval limit set (Settings), `POST /api/boxes/{id}/entries` with `"direction": "out"` above the limit, by a key
whose owner is not an administrator, records nothing in the ledger and answers **`202 Accepted`**:

```json
{ "pending": { "request_id": 7, "status": "pending", "amount_cents": 12000, "limit_cents": 7500 },
  "message": "Payouts above $75.00 wait for an administrator: this one ($120.00) is recorded as a request. Pay it out once it is approved." }
```

A payout the box could not pay now is still `422` with the same sentence as the screen. The MCP tool `record_entry` answers
`{ "pending_request_id": …, "status": "pending", "amount_cents": …, "message": … }` in the same case. Approving or declining is a
browser action for administrators only; no API route or MCP tool decides a request.

The journal export (`/report/journal.csv`) and receipt reading are browser features; neither has an API route.


## Connect it to Zapier, Make or n8n

Cashora 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 Cashora, 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://cashora.yourcompany.com`.

### When something happens in Cashora: 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 Cashora under **API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Cashora, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `entry.recorded` | A disbursement or replenishment is recorded, on the form, through the API or by the AI tool (carries the entry *and* the box's balance… |

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

- **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/boxes/{id}/entries`: Record a disbursement (`out`) or a replenishment (`in`)

### Example recipes

- **entry.recorded → a row in Google Sheets:** webhook trigger on `entry.recorded` (A disbursement or replenishment is recorded, on the form, through the API or by the AI tool (carries the entry *and* the box's balance…), then the Google Sheets "Create row" action with the fields you want.
- **entry.recorded → a message in Slack or Teams:** webhook trigger on `entry.recorded` (A disbursement or replenishment is recorded, on the form, through the API or by the AI tool (carries the entry *and* the box's balance…), 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; Cashora's own automated tests check its API.
