Ownware
Home›Cashora›API
Cashora · API

Cashora API, as shipped in the download

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 & pathWhat it does
GET /api/pingVerify a key
GET /api/boxesEvery box with its live balance, variance and status
GET /api/boxes/{id}/entriesNewest 50 entries. `?direction=in\out, ?from=, ?to=`
POST /api/boxes/{id}/entriesRecord a disbursement (out) or a replenishment (in)
GET /api/reportPeriod report for a box. ?box=, ?month=YYYY-MM
GET /api/openapi.jsonOpenAPI 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 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 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):

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

Tools

ToolWrites?What it does
list_boxesnoEvery cash box with its live balance, float status and custodian
box_detailnoOne box's entries — replenishments and disbursements, newest first
record_entryYESRecords a disbursement or a replenishment against a box
month_reportnoThe 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:

{"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.

← Back to Cashora · Manual · Quickstart · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →