Safora API, as shipped in the download
Safora REST API, Webhooks & MCP
Connect your fridge sensors
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:
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:
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"}'
{
"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 artefact you would least want an inspector to read.
Send the reading with what you did about it and it saves:
-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 colour for an inspection |
| POST | /api/signoff | Manager signs a day off |
| GET | /api/openapi.json | OpenAPI 3.0.3 — imports as a custom connector |
| GET | /healthz | Unauthenticated {"ok":true,"app":"safora","version":"3.1.6"} |
Equipment bounds
{"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.
Compliance for an inspection
curl -H "Authorization: Bearer apk_xxxx" \
"https://safora.example.com/api/compliance?month=2026-08"
{"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 colour 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)>
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. |
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:
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):
{
"type": "mcp",
"server_label": "safora",
"server_url": "https://safora.example.com/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "safora", "name": "Safora", "url": "https://safora.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://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:
{"content":[{"type":"text","text":"This reading is out of the safe range — a corrective action is
required before it can be saved. (reading normalised 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 normalised 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.