Assetora API, as shipped in the download
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.disposedwebhooks 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:
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):
{
"type": "mcp",
"server_label": "assetora",
"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": "assetora", "name": "Assetora", "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: 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. methodis an enum. The register normalises an unknown method tostraight_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
memberkey does not even seedispose_assetintools/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.