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 artifact 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 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
{"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)
curl -H "Authorization: Bearer apk_xxxx" "https://safora.example.com/api/trace?q=L2345"
{"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
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 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)>
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:
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"
}
**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://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 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
- 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 Safora under Settings → API & webhooks, and choose the event or events below.
- 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.