# 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:

```bash
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.

```json
{"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:

```bash
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

```bash
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

```bash
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 canceled work order is never reopened
- **`done` without a resolution note is refused** (`422`) — your maintenance history never
  acquires a closed job that nobody described

```json
{"error":"validation","detail":"A resolution note is required to mark a work order done."}
```

### PM due

```bash
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:

```sh
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):

```json
{
  "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](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://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
  `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:

| 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):

```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

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 Fixora under **Settings → API & webhooks**, and choose the event or events below.
3. 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 order
- `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}`: "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`
- `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 to `done` — 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.
