Menura API, as shipped in the download
Menura REST API, Webhooks & MCP
Menura ships a JSON REST API and signed webhooks so a digitized menu can reach your POS, your website, a price-management sheet, Power Automate, Zapier, n8n, or your own code — without anyone retyping a menu.
The important part: the exports this API serves are the exports the screen serves. They come from the same three writers (toJson, toNestedCsv, toPosCsv). A POS import built against a second, nearly identical CSV writer is exactly how a menu ends up on a till with the wrong prices — so there isn't one.
Authentication
Create a key in API & Webhooks. A key belongs to a user and can do what a signed-in user can do. Send it on every call:
Authorization: Bearer apk_...
The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.
Prices are integer cents, and may be null — a genuinely priceless line ("Market Price") is different from a free one, and Menura keeps that distinction.
Endpoints
| Method & path | What it does | ||||
|---|---|---|---|---|---|
GET /healthz | Liveness check — no auth. {"ok":true,"app":"menura","version":"3.3.0"} | ||||
GET /api/ping | Verify a key | ||||
GET /api/menus | List menus with item / section / priced-item counts. ?status= · `?reviewed=0\ | 1 · ?q= · ?limit=` | |||
GET /api/menus/{id}/items | One menu's items, in menu order, with its section list | ||||
POST /api/items/{id} | Edit an item — typically a price update | ||||
GET /api/exports | Export for a POS. `?format=json\ | csv\ | csv-pos · ?scope=all\ | reviewed\ | <menu id>` |
GET /api/openapi.json | OpenAPI 3 spec — no auth |
A key acts with the role of the person it belongs to, as the MCP tools do: a viewer's key reads, and POST /api/items/{id} answers it 403 with the permission it lacks (item.edit). A read-only key cannot write whoever owns it.
A menu's items
GET /api/menus/2/items
{
"menu": {"id": 2, "name": "Bella Cucina — Dinner", "status": "reviewed",
"item_count": 12, "section_count": 4, "priced_item_count": 11},
"sections": ["Antipasti", "Primi", "Secondi", "Dolci"],
"items": [
{"id": 1, "section": "Antipasti", "name": "Bruschetta al Pomodoro",
"description": "Grilled sourdough, vine tomatoes, basil, garlic",
"price_cents": 950, "modifiers": ["Add buffalo mozzarella"],
"allergens": ["gluten"], "dietary": ["vegetarian"], "sort": 1}
]
}
Sections come back in first-appearance order — the order the menu itself reads in, not alphabetical. The modifiers / allergens / dietary columns are decoded for you, so nothing downstream has to parse JSON twice.
priced_item_count is worth watching: the gap between it and item_count is how many lines the extraction could not find a price for.
Update an item (the price-change path)
curl -X POST https://your-install/api/items/1 \
-H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
-d '{"price_cents": 1975}'
Only the fields you send change. Send "price_cents": null to clear a price (for a "Market Price" line). modifiers, allergens and dietary take arrays of strings, or null to clear. An empty body is refused rather than silently doing nothing:
400 {"error": "bad_request", "detail": "Send at least one field to change."}
Every edit fires item.updated carrying both the before and the after, so a price change is auditable at the receiving end without it having to keep its own copy.
Exports — the POS handoff
GET /api/exports?format=csv-pos&scope=reviewed
SKU,Name,Category,Price,Description
antipasti-bruschetta-al-pomodoro,Bruschetta al Pomodoro,Antipasti,9.50,"Grilled sourdough, ..."
format=json returns the canonical export document verbatim; csv is the nested menu/section/item sheet; csv-pos is the flat SKU/Name/Category/Price sheet most tills import. scope=reviewed is the safe default for anything automated — it exports only menus a human has checked.
The output is byte-for-byte what the Export screen downloads.
Webhooks
Add receiver URLs in API & Webhooks. Events:
menu.processed— a menu finished digitizing, carrying every extracted itemitem.updated— an item changed (through the API, MCP, a price update or the review screen), carrying the before and the after
Each delivery is an HTTP POST with a JSON body and:
X-Menura-Event: menu.processed
X-Menura-Signature: sha256=<hex HMAC-SHA256 of the RAW body, keyed with the endpoint's secret>
Verify the signature over the raw body before trusting a payload:
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $YOUR_SECRET);
if (!hash_equals($expected, $_SERVER['HTTP_X_MENURA_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
One attempt, 5-second timeout; the Recent deliveries log shows every attempt and response code. Design receivers to be idempotent.
Getting a menu IN
Two routes, and neither is an API endpoint:
- Upload + AI extraction (the Upload screen) — a photo or PDF becomes structured items.
- CSV import (the Import CSV screen, new in 2.0) —
name, price, section, description. If your menu is already in a spreadsheet you do not need the AI pass at all, and you should not pay for one. The importer runs a dry run first: it shows exactly which rows would land and why each of the rest would not, and writes nothing until you confirm. Import into an existing menu or let it create a new one. A hand-typed menu is marked reviewed on import — you wrote it.
There is no upload endpoint. Uploading is multipart, and the extraction behind it is long-running and spends your LLM credit per call, behind file-type checks and a demo limiter that live on the upload screen. Exposing that as JSON would have meant duplicating those guards or quietly weakening them — and an endpoint that can burn an operator's API budget deserves the deliberation a browser step provides. Everything after the menu exists — reading, editing, repricing, exporting — is fully available here.
Limits, honestly
- Menura has one user type, so a key is not scoped more narrowly than a login.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
GET /api/menuscaps at 500 rows.- There is no bulk price endpoint; loop
POST /api/items/{id}. Menus are tens of items, not thousands, and one call per item means oneitem.updatedper change, which is what an audit trail wants. - Extraction quality is your model's, not Menura's. The review screen exists for that reason, and
?scope=reviewedexists so automation can refuse to export anything unchecked.
MCP — the agent endpoint (new in 3.0)
POST /mcp
Authorization: Bearer apk_... ← the SAME revocable key the REST API uses
| Tool | Writes? | What it does |
|---|---|---|
list_menus | no | Digitized menus with status and item counts. |
menu_detail | no | Every item: section, name, description, price in cents, modifiers, allergens, dietary. |
item_update | YES | Corrects one extracted item, through the same rule the review screen uses. |
export_rows | no | POS CSV, nested CSV or JSON — the product's own exporters. |
allergen_report | no | Items with no allergen data. Gaps only. |
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://your-install/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 menura https://your-install/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": "menura",
"server_url": "https://your-install/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://your-install/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: Menura implements the protocol, not an integration with a particular vendor.
Why there is no extract tool
Extraction sends your menu to a vision model using your own API key and spends **your own credits**. A tool that lets an agent trigger that on a whim is a bill, not a feature — so extraction stays a button in the app. Agents work on data that already exists.
Refusals
- Arguments must be single values. Only
modifiers,allergensanddietarymay be arrays — the same allowlistApi::body()enforces on the REST API. - Unknown arguments are refused by name; out-of-enum values list the valid options.
price_centsis integer cents and cannot be negative — the shared edit rule says so, not a second copy of it.- Roles apply exactly as in the browser: a viewer's key is refused
item_update.
allergen_report never infers an allergen from a dish name. A missing allergen is reported as missing, because a confidently wrong allergen claim is a safety problem, not a UX one.
Connect it to Zapier, Make or n8n
Menura 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 Menura, open 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://menura.yourcompany.com.
When something happens in Menura: 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 Menura under API & Webhooks, and choose the event or events below.
- Make the event happen once in Menura, so the other tool receives a sample and you can map its fields.
| Event | Fires when |
|---|---|
menu.processed | A menu finished digitizing, carrying every extracted item |
item.updated | An item changed (through the API, MCP, a price update or the review screen), carrying the before and the after |
Every delivery carries X-Menura-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 Menura
- 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/items/{id}: Edit an item — typically a price update
Example recipes
- menu.processed → a row in Google Sheets: webhook trigger on
menu.processed(A menu finished digitizing, carrying every extracted item), then the Google Sheets "Create row" action with the fields you want. - item.updated → a message in Slack or Teams: webhook trigger on
item.updated(An item changed (through the API, MCP, a price update or the review screen), carrying the before and the after), then the Slack or Microsoft Teams "Send message" action.
These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Menura's own automated tests check its API.