# Assetora REST API & Webhooks (v2.0)

Assetora ships a JSON REST API and signed webhooks so your fixed-asset register can feed a
spreadsheet, an accounting package, or your own code — and so depreciation figures can be pulled
on a schedule instead of exported by hand.

## Authentication

Create a key in **API & Webhooks** (admin). 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 ask_...

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/assets` | Newest 50. `?status=active\|disposed`, `?category_id=`, `?as_of=YYYY-MM-DD` |
| `POST /api/assets` | Create an asset |
| `GET  /api/assets/{id}` | One asset, with book value and accumulated depreciation. `?as_of=` |
| `GET  /api/categories` | List categories |
| `GET  /api/report` | Depreciation for a period (read-only). `?year=`, `?month=1-12`, `?cat=` |
| `GET  /api/openapi.json` | OpenAPI 3 spec (no auth) |
| `GET  /healthz` | `{"ok":true,"app":"assetora","version":"2.0.0"}` (no auth) |

### Create an asset

    curl -X POST https://your-install/api/assets \
      -H "Authorization: Bearer ask_..." -H "Content-Type: application/json" \
      -d '{"name": "CNC lathe", "tag": "PLANT-014", "cost": "12000.00",
           "acquisition_date": "2026-01-15", "useful_life_months": 60,
           "method": "straight_line"}'

Money is **decimal in, integer cents out**. Validation is not re-implemented in the API: the
request is passed through `_asset_from_post()` — the exact function the browser form uses — so a
rule can never apply in the UI and not in the API. A validation failure returns **422** with the
same sentence the form would have shown:

    {"error": "unprocessable", "detail": "Enter the useful life in months (e.g. 60 for five years)."}

### Depreciation figures

Every `accumulated_cents` / `book_value_cents` in a response comes from `Depreciation::accumFor()`
and `Depreciation::bookFor()` — the same core the register, the schedule page and the CSV export
use. `/api/report` calls the report's own `_report_params()` + `_report_data()`, so the API, the
HTML report and the CSV cannot disagree.

Book values are computed **as of a date**. Omit `as_of` and you get today in the business
timezone; pass it to value the register at a period end.

Disposed assets additionally carry `disposal_date`, `disposal_proceeds_cents`,
`book_at_disposal_cents` and `gain_loss_cents`.

## Webhooks

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

- `asset.created` — an asset is added to the register (the form, the API or the AI tool)
- `asset.disposed` — an asset is disposed (with proceeds and the gain/loss)

A CSV import records its rows without sending an event per row.

Every delivery is signed:

    X-Assetora-Event: asset.created
    X-Assetora-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 a register edit.
One attempt per event; the delivery log covers the rest.

## Documented limits

- **The API is create-and-read for assets.** Editing and disposal stay in the UI in 2.0 — a
  disposal rewrites the gain/loss basis and is deliberately a deliberate act.
- **No notification emails.** Assetora has no natural per-event recipient (an asset register is
  not a workflow), so 2.0 ships the API, 2FA, import, backup, rate limiting and dark mode, and no
  SMTP block. The `asset.created` / `asset.disposed` webhooks are the integration point.
- **Report is read-only** and always reflects the live register.

---

# Own It 3.0

## MCP — the agent endpoint

Assetora speaks **MCP (Model Context Protocol)** at `POST /mcp`, authenticated with the same
revocable API keys the REST API uses. Stateless streamable-HTTP: one JSON-RPC message per request,
no session, no SSE.

`GET /mcp` answers **405** with `Allow: POST` — deliberately **before** the API-key check. A client
probing for MCP support with no credentials must learn that the transport is here and takes POST;
answering `401` would tell it we have no MCP endpoint at all, which is false. The unauthenticated
hint names no tools.

    POST /mcp
    Authorization: Bearer <api key>
    Content-Type: application/json

| Tool | Writes? | What it does |
|---|---|---|
| `list_assets` | no | The register with accumulated depreciation and book value as of a date. Filter by status, category or a name/tag search. |
| `asset_detail` | no | One asset's full position, its photo count, and its disposal-review date. |
| `create_asset` | **YES** | Adds an asset. Validation is the register form's own, so the refusals are the sentences you would see on screen. |
| `dispose_asset` | **YES** | Disposes an asset permanently, records the gain or loss, and fires `asset.disposed`. |
| `depreciation_report` | no | The by-category roll-up the report screen and the PDF use. |

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

### What the endpoint refuses, and why

- **Depreciation is never recomputed.** Every figure comes from the same `Depreciation::` engine the
  screens use. A tool that re-derived a book value would be a defect even when the number agreed.
- **`method` is an enum.** The register normalizes an unknown method to `straight_line`, which is
  correct behind a `<select>` that cannot offer a wrong value and wrong for an agent — it would be
  told it succeeded while its choice was discarded. The tool refuses and names the allowed values.
- **Disposal is its own permission.** A `member` key does not even see `dispose_asset` in
  `tools/list`, and is refused if it calls anyway.
- **An already-disposed asset is refused**, not re-disposed with a new date. A recorded disposal is
  a recorded gain or loss; silently rewriting it would rewrite the accounts.

Every MCP write lands in the audit trail alongside the browser and API writes.

## Webhooks — `asset.disposed`

`asset.disposed` was declared, documented and subscribable in 2.0, but no code path ever fired it:
the only disposal route in the product never called the dispatcher. **It fires now**, from every
door — the disposal screen, the bulk action and the MCP tool all go through one writer. If you
subscribed to it in 2.0 and saw nothing, that is why, and nothing needs changing at your end.

Payload is the standard asset envelope, including `disposal_date`, `disposal_proceeds_cents`,
`book_at_disposal_cents` and `gain_loss_cents`.

## Scheduled backup

    GET /backup/scheduled?t=<token>

A token-guarded URL for a cron job. No session — cron has no browser — so the token **is** the
credential: minted once in **Backup & restore**, shown once, stored only as a SHA-256 hash, revoked
by minting another. It writes `data/backups/assetora-backup-<UTC>.json`, keeps the newest N and
deletes older ones.

**What is blanked in that file:** password hashes, API-key hashes, webhook signing secrets, the
OIDC client secret, the backup and calendar tokens, and 2FA seeds — the same list `/backup.json`
uses, because the two must never drift. That makes this the copy you can hand to a bookkeeper.
`/backup.sqlite` is the whole install and is not redacted. Both browser downloads (`/backup.json`
and `/backup.sqlite`) take an administrator's sign-in (`settings.write`) from 3.1.4.

## Disposal-review calendar

    GET /feed/reviews.ics?t=<token>

An iCalendar feed of every active asset's disposal-review date — the end of the month in which it
becomes fully depreciated, read from that asset's own depreciation schedule. All-day events with
stable UIDs, so refreshing does not duplicate them. The token is the credential and grants this one
feed; a calendar client cannot log in.

## Where it is and who has it (new in 3.2)

Every asset in the API and the MCP tools now carries `serial_number`, `location` (its home location) and
`checked_out_to`: `{"kind": "person" | "department" | "location", "name", "location", "since", "due_back"}`
while it is checked out, `null` when it is at home. Checking out and returning are done on the asset's page;
no API route or MCP tool changes custody.

## New in 3.2 (pass B)

- `POST /api/assets` now checks the key holder's **role** as well as the key's scope: a key held by a viewer is
  answered `403 {"error": "forbidden", "detail": "Your role (viewer) cannot add assets. Ask an administrator."}` and
  nothing is written. The MCP tools already left out the write tools for a role that lacks them.
- Maintenance, alerts, reports and the Excel export are browser features; none has an API route. The stock-take's
  camera posts to the same `/scan/sight` form route (signed-in, CSRF), asking for a JSON answer.
- Assets carry `warranty_until` (`YYYY-MM-DD` or `null`); `POST /api/assets` also takes `warranty_until`, `serial_number` and `location`.


## Connect it to Zapier, Make or n8n

Assetora 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 Assetora, 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://assetora.yourcompany.com`.

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

| Event | Fires when |
|---|---|
| `asset.created` | An asset is added to the register (the form, the API or the AI tool) |
| `asset.disposed` | An asset is disposed (with proceeds and the gain/loss) |

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

- **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/assets`: Create an asset

### Example recipes

- **asset.created → a row in Google Sheets:** webhook trigger on `asset.created` (An asset is added to the register (the form, the API or the AI tool)), then the Google Sheets "Create row" action with the fields you want.
- **asset.disposed → a message in Slack or Teams:** webhook trigger on `asset.disposed` (An asset is disposed (with proceeds and the gain/loss)), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Assetora:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/assets` (Create an asset).

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