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.1.6"} | ||||
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 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"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "menura", "name": "Menura", "url": "https://your-install/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://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.