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":"3.2.0"}
Endpoints
| Method | Path | Notes | |||
|---|---|---|---|---|---|
| GET | /api/ping | Verify a key | |||
| GET | /api/work-orders | Newest 200. `?status=open\ | in-progress\ | done\ | cancelled` |
| POST | /api/work-orders | Open a work order | |||
| GET | /api/work-orders/{id} | One work order, with its checklist (3.2): each step's id, label, state (open, pass, fail, na), note and raised_work_order_id | |||
| POST | /api/work-orders/{id}/status | Move it. done requires a resolution note, and (3.2) no unanswered checklist step | |||
| POST | /api/checklist-steps/{id} | (3.2) Answer one step: `{"state": "pass"\ | "fail"\ | "na"\ | "open", "note": "…"}. A member's key or above (a viewer's key is refused). A failed step raises one corrective work order and returns its raised_work_order_id` |
| GET | /api/assets | Assets + their meters. `?status=operational\ | needs-attention\ | down\ | retired` |
| POST | /api/assets/{id}/readings | Meter ingest (above) | |||
| GET | /api/pm-due | PM due list, computed on view. `?status=overdue\ | due-soon\ | ok` | |
| GET | /api/parts | Spare parts with low-stock flags | |||
| GET | /api/requests | (3.2) Requests from the request form, newest 200. `?status=new\ | accepted\ | declined`. Read-only: accepting one is a person's decision in the app | |
| GET | /api/openapi.json | OpenAPI 3.0.3 — imports as a custom connector | |||
| GET | /healthz | Unauthenticated {"ok":true,"app":"fixora","version":"3.2.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 normalized 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, anddone → opento reopencancelledis terminal — a canceled work order is never reopeneddonewithout 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 address | https://fixora.example.com/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 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"
}
**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"*. 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:
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
| Tool | Writes? | What it does |
|---|---|---|
list_workorders | no | Jobs by status, priority, asset or assignee, with cost. |
workorder_detail | no | One job with its parts, photos and cost breakdown. |
create_workorder | YES | Raises a job against an asset. |
advance_status | YES | Moves a job along the workflow. A work order with an unanswered checklist step is refused done (3.2). |
record_meter | YES | Logs a meter reading. |
pm_due | no | Preventive 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 judgment 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
donejob cannot be dragged back toin-progress. - No cost is recomputed.
total_cost_centscomes fromMaintenance::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:
| Event | Fires when |
|---|---|
workorder.created | A work order is opened — in the browser, over the API or by an agent |
workorder.completed | A work order moves to done — from its page, the board, a bulk change, the API or an agent |
reading.recorded | A meter reading is accepted — in the browser, over the API or from an agent |
request.created | (3.2) Somebody without a login reports a fault through the request form or a QR tag |
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
| Code | Meaning |
|---|---|
401 unauthorized | Missing, malformed, revoked or unknown key |
403 forbidden | The key is read-only, or its holder's role may not do this (a viewer cannot write) |
404 not_found | No such work order / asset |
409 conflict | Meter reading below current (send correction: true) |
422 validation | Refused by a guard — detail says which, in plain language |
Connect it to Zapier, Make or n8n
Fixora 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 Fixora, open Settings → 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://fixora.yourcompany.com.
When something happens in Fixora: send it to another tool
- 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.
- Copy that URL into Fixora under Settings → API & webhooks, and choose the event or events below.
- Make the event happen once in Fixora, so the other tool receives a sample and you can map its fields.
| Event | Fires when |
|---|---|
workorder.created | A work order is opened — in the browser, over the API or by an agent |
workorder.completed | A work order moves to done — from its page, the board, a bulk change, the API or an agent |
reading.recorded | A meter reading is accepted — in the browser, over the API or from an agent |
request.created | Somebody without a login reports a fault through the request form or a QR tag |
Every delivery carries X-Fixora-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 Fixora
- 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/work-orders: Open a work orderPOST /api/work-orders/{id}/status: Move it.donerequires a resolution note, and (3.2) no unanswered checklist stepPOST /api/checklist-steps/{id}: "open", "note": "…"}. A member's key or above (a viewer's key is refused). A failed step raises one corrective work order and returns itsraised_work_order_id`POST /api/assets/{id}/readings: Meter ingest (above)
Example recipes
- workorder.created → a row in Google Sheets: webhook trigger on
workorder.created(A work order is opened — in the browser, over the API or by an agent), then the Google Sheets "Create row" action with the fields you want. - workorder.completed → a message in Slack or Teams: webhook trigger on
workorder.completed(A work order moves todone— from its page, the board, a bulk change, the API or an agent), then the Slack or Microsoft Teams "Send message" action. - A new form entry or row elsewhere → Fixora: the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling
POST /api/work-orders(Open a work order).
These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Fixora's own automated tests check its API.