# Safora REST API, Webhooks & MCP

## Connect your fridge sensors

```bash
curl -X POST https://safora.example.com/api/readings \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"equipment_id": 3, "temp": 4.1, "staff_name": "Wireless sensor"}'
```

A wireless probe, a Bluetooth thermometer bridge, a phone app on the pass — anything that can
POST JSON writes straight into the diary. Send `"unit": "F"` and Fahrenheit is converted for
you; the stored value is always °C.

## A sensor's out-of-range reading is stored as an open breach

Add `"source": "sensor"` when a probe posts on its own. A probe cannot know what was done about a
warm fridge, so its out-of-range reading is **stored** — not refused — as an open breach:

```bash
curl -X POST https://safora.example.com/api/readings \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"equipment_id": 3, "temp": 11.5, "source": "sensor", "staff_name": "Probe 3"}'
```

The response (201) carries `"awaiting_corrective_action": true`. The day turns red, the
`reading.out_of_range` webhook and email fire, the breach chase picks it up, and a manager cannot
verify it until a person records the corrective action on the Corrective actions screen. An
unexplained failure therefore stays visibly open until someone explains it. `staff_name` may be
left out for a sensor; it is recorded as "Sensor".

## The rule for people

**An out-of-range reading sent without `source` and without a corrective action is refused.**
Not warned about — refused, with nothing written:

```bash
curl -X POST https://safora.example.com/api/readings \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"equipment_id": 3, "temp": 11.5, "staff_name": "Sensor"}'
```

```json
{
  "error": "corrective_action_required",
  "detail": "This reading is out of the safe range — a corrective action is required before it can be saved.",
  "in_range": false,
  "temp_c": 11.5,
  "range": {"min": null, "max": 5}
}
```

This is deliberate and it is the whole point of the product. A HACCP diary full of unexplained
failing temperatures is worse than no diary at all — it is documentary evidence that you saw the
problem and did nothing. The form has always enforced this; the API enforces it by calling the
same `FoodSafety::validateEntry()`, so integrating a sensor cannot quietly create the one
artifact you would least want an inspector to read.

Send the reading with what you did about it and it saves:

```bash
  -d '{"equipment_id": 3, "temp": 11.5, "staff_name": "Sensor",
       "corrective_action": "Stock moved to walk-in; engineer called 14:20"}'
```

The response echoes the effective `range` so an automated caller can log *why* it was refused
without hardcoding your thresholds.

## Authentication

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

```
Authorization: Bearer apk_<40 hex chars>
```

`/api/*` is CSRF-exempt by design: the key is the credential and browsers never send it
implicitly.

## Endpoints

| Method | Path | Notes |
|---|---|---|
| GET | `/api/ping` | Verify a key |
| GET | `/api/equipment` | Each item with the bounds **actually enforced** for it |
| POST | `/api/readings` | Record a temperature (the rule above applies) |
| GET | `/api/checks` | `?date=YYYY-MM-DD` — a day's entries plus its sign-off |
| GET | `/api/compliance` | `?month=YYYY-MM` — per-day color for an inspection |
| POST | `/api/signoff` | Manager signs a day off |
| GET | `/api/trace` | `?q=` supplier, product or batch (part of one, any case) — matching deliveries with batch, use-by and who checked them, plus the recall log (3.2.0) |
| GET | `/api/allergens` | The allergen matrix, the kitchen's own record; `?allergen=milk` lists only the dishes that contain or may contain it; an unknown allergen is a 422 (3.2.0) |
| GET | `/api/openapi.json` | OpenAPI 3.0.3 — imports as a custom connector |
| GET | `/healthz` | Unauthenticated `{"ok":true,"app":"safora","version":"3.2.0"}` |

### Equipment bounds

```json
{"id": 3, "name": "Walk-in", "type": "fridge",
 "min_c": null, "max_c": null,
 "effective_min_c": null, "effective_max_c": 5}
```

`min_c`/`max_c` are what is stored; `effective_*` are what a reading is actually judged against
after the type defaults from Settings are applied. A sensor integration should read the
effective values — otherwise a fridge with no explicit maximum looks unbounded when it is not.

### A recall notice: which deliveries brought the batch in (3.2.0)

```bash
curl -H "Authorization: Bearer apk_xxxx" "https://safora.example.com/api/trace?q=L2345"
```

```json
{"q": "L2345", "count": 1,
 "deliveries": [{"entry_id": 812, "date": "2026-09-20", "supplier": "Metro Foodservice",
                 "product": "Chicken thighs, 5 kg", "batch_code": "L2345-B", "use_by": "2026-09-25",
                 "temp_c": 4, "accepted": true, "checked_by": "Sam"}],
 "recalls": [{"id": 3, "raised_on": "2026-09-25", "product": "Chicken thighs, 5 kg",
              "batch_code": "L2345-B", "reason": "Supplier notice", "closed_on": null, ...}]}
```

A delivery recorded before 3.2.0 has no product, batch or use-by; its `supplier` is read from the
delivery label. Allergen codes in `/api/allergens`: `celery`, `gluten`, `crustaceans`, `eggs`, `fish`,
`lupin`, `milk`, `molluscs`, `mustard`, `nuts` (tree nuts), `peanuts`, `sesame`, `soya`, `sulphites`.

### Compliance for an inspection

```bash
curl -H "Authorization: Bearer apk_xxxx" \
  "https://safora.example.com/api/compliance?month=2026-08"
```

```json
{"month": "2026-08",
 "days": [{"date": "2026-08-01", "status": "green", "entries": 6, "out_of_range": 0,
           "signed_off": true, "manager": "A. Okafor"}, ...],
 "counts": {"green": 18, "amber": 2, "red": 0, "none": 11}}
```

`status` is the same color the in-app calendar shows, from the same `FoodSafety::dayStatus()`:

- **green** — everything recorded was in range
- **amber** — out-of-range readings that carry a corrective action
- **red** — an out-of-range reading with no corrective action
- **none** — nothing recorded

## Webhooks

| Event | Fires when |
|---|---|
| `reading.recorded` | Any reading is saved (browser **or** API) |
| `reading.out_of_range` | …and specifically when that reading failed |
| `day.signed_off` | A manager signs a day |

`reading.out_of_range` is the one to wire to a phone. It fires **in addition to**
`reading.recorded`, from a single shared emitter used by all four in-app logging screens and the
API alike — there is no logging route that stays quiet.

```
X-Safora-Event: reading.out_of_range
X-Safora-Signature: sha256=<hmac_sha256(raw_body, secret)>
```

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

One attempt per event with a 5-second timeout — a slow receiver must never block someone
recording a temperature mid-service. The last 200 deliveries per endpoint are logged.

## Errors

| Code | Meaning |
|---|---|
| `401 unauthorized` | Missing, malformed, revoked or unknown key |
| `422 corrective_action_required` | Out of range with no corrective action and no `"source": "sensor"` — nothing was written |
| `422 validation` | Missing/invalid `equipment_id`, `temp`, `staff_name`, `date` or `month` |

## MCP — the agent endpoint (new in 3.0)

Safora speaks **MCP (Model Context Protocol)** on one route, so an assistant can read the diary,
record a check and chase outstanding corrective actions.

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

`GET /mcp` returns **405** with `Allow: POST`, by design.

| Tool | Writes? | What it does |
|---|---|---|
| `list_checkpoints` | no | Monitored equipment with its safe range and latest reading. |
| `record_reading` | **YES** | Records a temperature check. |
| `open_actions` | no | Out-of-range readings whose corrective action nobody has verified yet. |
| `close_action` | **YES** | Verifies (closes) a corrective action. |
| `compliance_report` | no | Per-day green/amber/red over a date range, as the audit pack prints it. |
| `trace_deliveries` | no | Deliveries matching a supplier, product or batch, with use-by and who checked them, and the recall log (3.2.0). |
| `allergen_matrix` | no | Each dish with what it contains and may contain; `allergen` filters to one (3.2.0). |

### 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://safora.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 safora https://safora.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": "safora",
  "server_url": "https://safora.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://safora.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:
Safora implements the protocol, not an integration with a particular vendor.

### The rule an agent cannot route around

**An out-of-range reading cannot be saved without a corrective action.** `record_reading` calls the
product's own `FoodSafety::validateEntry()`, so the agent gets the same sentence a cook gets at the
kitchen terminal:

```json
{"content":[{"type":"text","text":"This reading is out of the safe range — a corrective action is
 required before it can be saved. (reading normalized to 12 °C)"}],"isError":true}
```

That refusal is the product working. A diary full of unexplained failures is worse than useless at
an inspection, so there is no quiet door into it — not the web form, not the REST API, not MCP.

### Fahrenheit is converted, not trusted

Send `unit:"F"` and the value is converted by `FoodSafety::parseTempToC()` before anything else
happens. **53.6 °F is 12 °C**, which is over a 5 °C fridge limit — sending it in Fahrenheit does not
make it look acceptable, and the refusal quotes the normalized Celsius value back so the mistake is
obvious. The MCP layer contains no temperature arithmetic of its own; a test asserts that.

### Verification is once-only

`close_action` refuses an entry that is already verified, naming who signed it off, and refuses an
in-range reading outright — there is nothing to verify. Roles apply exactly as in the browser: a
member records readings, only an admin verifies them.

Every MCP write lands in the **audit trail** with `via: mcp`.

## Checks-due calendar feed (new in 3.0)

`/calendar/checks.ics?t=<token>` publishes the next month of scheduled checks. Token-guarded (mint
it on the Scheduled checks page), session-free because calendar clients cannot log in, and it
carries **no staff names**.


## Connect it to Zapier, Make or n8n

Safora 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 Safora, 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://safora.yourcompany.com`.

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

| Event | Fires when |
|---|---|
| `reading.recorded` | Any reading is saved (browser or API) |
| `reading.out_of_range` | …and specifically when that reading failed |
| `day.signed_off` | A manager signs a day |

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

- **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/readings`: Record a temperature (the rule above applies)
- `POST /api/signoff`: Manager signs a day off

### Example recipes

- **reading.recorded → a row in Google Sheets:** webhook trigger on `reading.recorded` (Any reading is saved (browser or API)), then the Google Sheets "Create row" action with the fields you want.
- **reading.out_of_range → a message in Slack or Teams:** webhook trigger on `reading.out_of_range` (…and specifically when that reading failed), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Safora:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/readings` (Record a temperature (the rule above applies)).

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