Ownware
Home›Fixora›API
Fixora · API

Fixora API, as shipped in the download

Fixora API (v2.0)

Connect your sensors: POST meter readings

Fixora runs no background jobs — nothing polls, nothing phones home. That is a deliberate design choice, and the API is how you feed it live data anyway:

curl -X POST https://fixora.example.com/api/assets/12/readings \
  -H "Authorization: Bearer apk_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"reading": 4821, "read_date": "2026-08-06"}'

Point a PLC, a telematics box, a Node-RED flow or a nightly script at that endpoint and your hour meters, odometers and cycle counters stay current on their own. Usage-based preventive maintenance (every N hours) then becomes genuinely automatic: the PM list recomputes on every page view from the readings your machines just posted.

The monotonic guard applies to sensors exactly as it does to humans. A reading below the meter's current value is refused with 409 — meters only move forward. When a meter is genuinely replaced or a value was mistyped, resend with "correction": true; Fixora winds the meter back and rebases the PM baseline so the usage trigger keeps working.

{"error":"conflict","detail":"Reading 100 is below the current 4821 hours — meters only move forward. Tick \"correction\" if the old value was wrong."}

Authentication

Create a key in Settings → API & webhooks. It is shown once.

Authorization: Bearer apk_<40 hex chars>

A key acts as the user it belongs to and can do exactly what that user can do in the browser — the same state machine, the same guards, and the same role: opening or advancing a work order and posting a meter reading need a member or admin, so a viewer's key is refused with 403 (from 3.1.4; before, only the key's read/write scope was checked here). /api/* is CSRF-exempt by design: the key is the credential and browsers never send it implicitly.

Verify a key:

curl -H "Authorization: Bearer apk_xxxx" https://fixora.example.com/api/ping
# {"ok":true,"user":"Sam","app":"fixora","version":"2.0.0"}

Endpoints

MethodPathNotes
GET/api/pingVerify a key
GET/api/work-ordersNewest 200. `?status=open\in-progress\done\cancelled`
POST/api/work-ordersOpen a work order
GET/api/work-orders/{id}One work order
POST/api/work-orders/{id}/statusMove it. done requires a resolution note
GET/api/assetsAssets + their meters. `?status=operational\needs-attention\down\retired`
POST/api/assets/{id}/readingsMeter ingest (above)
GET/api/pm-duePM due list, computed on view. `?status=overdue\due-soon\ok`
GET/api/partsSpare parts with low-stock flags
GET/api/openapi.jsonOpenAPI 3.0.3 — imports as a custom connector
GET/healthzUnauthenticated {"ok":true,"app":"fixora","version":"2.0.0"}

Open a work order

curl -X POST https://fixora.example.com/api/work-orders \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"asset_id": 12, "description": "Bearing noise on drive end",
       "priority": "high", "wo_type": "corrective", "assigned_to": "Sam"}'

asset_id and description are required. wo_type (corrective/preventive), priority (low/medium/high/urgent), labor_minutes, parts_cost and downtime_minutes are optional and are normalised by the same code the form uses. A priority outside those four is refused with 422 rather than filed as medium (critical is read as urgent).

Complete it

curl -X POST https://fixora.example.com/api/work-orders/48/status \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"status": "done", "resolution": "Replaced drive-end bearing, greased"}'

The transition runs through Fixora's state machine, so the API cannot do what the UI forbids:

  • open → in-progress → done, and done → open to reopen
  • cancelled is terminal — a cancelled work order is never reopened
  • done without a resolution note is refused (422) — your maintenance history never acquires a closed job that nobody described
{"error":"validation","detail":"A resolution note is required to mark a work order done."}

PM due

curl -H "Authorization: Bearer apk_xxxx" "https://fixora.example.com/api/pm-due?status=overdue"

Each row carries next_due, days_until, due_status, and — when a usage trigger is set — usage_remaining plus due_source (days, usage or both), telling you which trigger fired. This is the same computation the dashboard badge uses, not a parallel implementation.

MCP — the agent endpoint (new in 3.0)

Fixora speaks MCP (Model Context Protocol) on one route, so an assistant can watch the maintenance backlog, raise jobs and log meters without anyone building a bridge first.

POST /mcp
Authorization: Bearer apk_...          ← the SAME revocable key the REST API uses
Content-Type: application/json

Stateless streamable-HTTP: one JSON-RPC message per request. A GET gets 405 with Allow: POST, unauthenticated, so a probing client learns you speak MCP before it has a key.

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://fixora.example.com/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 fixora https://fixora.example.com/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": "fixora",
  "server_url": "https://fixora.example.com/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}

Own Your AI reads a list of servers in this shape:

{
  "mcpServers": [
    { "id": "fixora", "name": "Fixora", "url": "https://fixora.example.com/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://fixora.example.com/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: Fixora implements the protocol, not an integration with a particular vendor.

The tools

ToolWrites?What it does
list_workordersnoJobs by status, priority, asset or assignee, with cost.
workorder_detailnoOne job with its parts, photos and cost breakdown.
create_workorderYESRaises a job against an asset.
advance_statusYESMoves a job along the workflow.
record_meterYESLogs a meter reading.
pm_duenoPreventive maintenance due by date or by usage.

Status uses a hyphen: open, in-progress, done, cancelled — the same four words the Work orders screen uses.

Meters only move forward, and that holds here

record_meter calls the same Maintenance::addMeterReading() the meter form calls, so the guard is enforced once for every caller: a reading below the current one is refused, an equal reading is fine (the meter did not move), and a higher one is recorded.

There is deliberately no correction argument. Winding a meter back means the device was swapped or the number was mistyped, and correcting it rebases the PM baselines that decide when the next service fires. That is a judgement about a physical machine, so it stays with a person in the app. Sending correction: true is refused as an unknown argument.

What else the endpoint refuses, and why

  • Closing a job without a resolution is refused — a completed work order with no record of what was done is worse than an open one.
  • Illegal transitions are refused by the product's own workflow, not by a copy of it here; a done job cannot be dragged back to in-progress.
  • No cost is recomputed. total_cost_cents comes from Maintenance::woCostCents(), the same function the screens use.
  • Arguments must be single values, unknown arguments are refused by name, and out-of-enum values are refused with the valid options listed.
  • Roles apply exactly as they do in the browser. A viewer's key is refused every write.

Every MCP write lands in the audit trail beside the browser and API writes, naming the key's user as the actor and recording via: mcp.

Example

curl -X POST https://your-install/mcp \
  -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"pm_due","arguments":{"overdue_only":true}}}'

Webhooks

Add a receiver in Settings → API & webhooks. Events:

EventFires when
workorder.createdA work order is opened — in the browser, over the API or by an agent
workorder.completedA work order moves to done — from its page, the board, a bulk change, the API or an agent
reading.recordedA meter reading is accepted — in the browser, over the API or from an agent

Every door fires the same event from one place (from 3.1.4; before, the agent tools and the bulk change fired nothing).

Each delivery is a JSON POST signed with your per-endpoint secret:

X-Fixora-Event: workorder.completed
X-Fixora-Signature: sha256=<hmac_sha256(raw_body, secret)>

Verify in your receiver (Python):

import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Fixora-Signature"]):
    abort(401)

One attempt per event with a 5-second timeout — a slow receiver must never block a technician closing a job. The last 200 deliveries per endpoint are logged with their response code, so "did it fire?" is answerable from the admin page. Re-send manually if a receiver was down.

Errors

CodeMeaning
401 unauthorizedMissing, malformed, revoked or unknown key
403 forbiddenThe key is read-only, or its holder's role may not do this (a viewer cannot write)
404 not_foundNo such work order / asset
409 conflictMeter reading below current (send correction: true)
422 validationRefused by a guard — detail says which, in plain language

← Back to Fixora · 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 →