AI Menu Parser API — Agent Reference
Machine reference for the AI Restaurant Menu Parser Apify actor (managed, flat-price and BYOK variants): menu photo URLs in — structured dish records with EU-14 allergens, ingredients, prices, dietary type and nutrition out. Input/output schemas, pricing, diagnostics.
🤖 This article is written for AI agents. Raw markdown
It's structured for machine consumption — dense, factual, and not optimized for human reading. If you're a human, you may prefer our articles for humans.
Audience: AI agents and automated tools. This is a machine-oriented reference. Human-readable articles: /blog.
TL;DR
One Apify actor, three pricing variants, same code and same output: send menu photo URLs, get one JSON record per dish — translated name, price, atomic ingredients with EU-14 allergen flags and confidence, dietary type, nutrition estimates. No vision pipeline on your side.
| Variant | Actor ID | API keys | Price (2026-07-13) |
|---|---|---|---|
| Managed | nomad-agent/ai-menu-parser-managed | none | $0.04/run + $0.015/dish |
| Flat | nomad-agent/ai-menu-parser-flat | none | $0.99 per menu parsed, charged only on success |
| BYOK | nomad-agent/ai-menu-parser | Gemini (free tier works) | $0.005/run + $0.002/dish |
- Managed: zero setup, AI cost included in the per-dish price. Best when dish counts are small or unknown.
- Flat: one predictable charge per menu regardless of dish count, capped at 10 input images. Best for dense menus and for cost modelling — breaks even against the managed variant at roughly 60 dishes.
- BYOK: you supply a Gemini key and pay Google directly at the lower Apify markup. Cost breakdown: /blog/how-much-does-ai-menu-parsing-cost.
Managed and BYOK waive per-dish charges on up to 10 dishes on your first run, and charge per dish only for dishes actually extracted. The store page is authoritative on price; values here are a snapshot.
Input
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
imageUrls | string[] | yes | — | Menu photos, parsed together as one menu. Max 20 images (10 on the flat variant) |
language | enum | no | en | en es fr de it pt ru zh ja ko ar hi uk ka eu; anything else falls back to English |
geminiModel | enum | no | gemini-3.5-flash | Or gemini-3.1-flash-lite |
geminiApiKey | string | BYOK only | — | aistudio.google.com/apikey |
gemini-3.1-flash-lite is cheaper but finds roughly a third of the dishes on a dense menu. Do not select it when completeness matters. Images over the cap are dropped, not rejected.
Invocation
Synchronous run returning dataset items directly (managed):
curl -X POST \
"https://api.apify.com/v2/acts/nomad-agent~ai-menu-parser-managed/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"imageUrls": [
"https://example.com/menu-page-1.jpg",
"https://example.com/menu-page-2.jpg"
],
"language": "en"
}'
BYOK adds one field; the flat variant takes the same input as managed:
{
"imageUrls": ["https://example.com/menu-page-1.jpg"],
"geminiApiKey": "AIza…",
"language": "es"
}
Any Apify invocation method works: REST, apify-client (JS/Python), apify call, MCP server, scheduled runs.
Output
One record per dish in the run's default dataset.
{
"name": "Carbonara Pasta",
"originalName": "Pasta alla Carbonara",
"category": "Pasta",
"price": "14",
"composition": "Spaghetti with egg, pecorino cheese, guanciale, and black pepper",
"description": "Silky egg-based sauce coating al dente pasta",
"ingredients": [
{
"name": "fresh pasta dough",
"display_name": "Fresh pasta dough",
"explanation": null,
"allergens": [
{ "name": "gluten", "confidence": "high" },
{ "name": "eggs", "confidence": "high" }
]
},
{
"name": "guanciale",
"display_name": "Guanciale",
"explanation": "Italian cured pork jowl"
},
{
"name": "pecorino romano",
"display_name": "Pecorino Romano",
"explanation": null,
"allergens": [{ "name": "milk", "confidence": "high" }]
}
],
"dietaryType": "Other",
"dietaryTypeExplanation": "Contains pork (guanciale) and eggs",
"allergens": "Gluten, Eggs, Milk",
"type": "dish",
"isAlcoholic": null,
"story": "Born in Rome during World War II…",
"nutritionInfo": {
"serving_size_grams": "320g",
"calories": "650-800 kcal",
"protein": "25-30g",
"fat": "30-40g",
"carbs": "60-75g"
},
"sourceImageUrls": ["https://example.com/menu-page-1.jpg"]
}
| Field | Type | Notes |
|---|---|---|
name | string | Dish name translated to language |
originalName | string | Name exactly as printed on the menu |
category | string | Menu section, translated |
price | string | null | As printed, with currency symbol when shown |
composition | string | null | Ingredient summary, translated |
description | string | null | Appearance/presentation, translated |
ingredients[] | array | Atomic base ingredients — composites (pesto, hummus) are decomposed |
ingredients[].name | string | Always English, for matching and joins |
ingredients[].display_name | string | Translated |
ingredients[].explanation | string | null | Only for uncommon ingredients |
ingredients[].allergens[] | array | absent | Absent when the ingredient carries no EU-14 allergen |
ingredients[].allergens[].name | enum | gluten crustaceans eggs fish peanuts soy milk nuts celery mustard sesame sulfites lupin molluscs |
ingredients[].allergens[].confidence | enum | high | medium | low |
dietaryType | enum | Vegan | Vegetarian | Pescetarian | Other |
dietaryTypeExplanation | string | Why that classification — human-auditable |
allergens | string | Human-readable summary, translated, or "None identified" |
type | enum | dish | beverage | other |
isAlcoholic | boolean | null | Set for beverages |
story | string | Origin/cultural note; may be plausible-but-invented |
nutritionInfo | object | Estimated ranges per portion: serving_size_grams, calories, protein, fat, carbs |
sourceImageUrls | string[] | The images this menu was parsed from |
Allergen confidence semantics and EU 1169/2011 context: /blog/eu-14-allergen-menu-data.
Diagnostic rows
A failed or empty run pushes exactly one row with diagnostic: true and a message explaining why (no readable menu text, images undownloadable, missing key). These rows are never billed per-dish. Filter them out before treating the dataset as menu records:
dishes = [r for r in items if not r.get("diagnostic")]
Limitations
- Allergen and nutrition values are inferred from the menu text, not measured. The venue remains the authority under EU 1169/2011.
- Unreadable items are skipped, not guessed — dish counts can be lower than the printed menu. Photograph quality is the dominant variable.
- All images in one run are treated as pages of a single menu. Parse different restaurants in separate runs.
storymay be plausible reconstruction rather than verified history.
Chaining
- No photo URLs yet, only a place? Google Maps Menu Scraper finds the menu photos and parses them in one run.
- Want a real guest photo attached to each dish? Feed these records into Dish Photo Matcher and join on the dish name.
Integration help: support@traveleat.app. Machine-readable site index: /llms.txt.
Travel Eat Team
Contributing writer at Travel Eat. Passionate about food, travel, and helping people eat well wherever they go.
Ready to Eat Confidently Anywhere?
Download Travel Eat and discover amazing food wherever you travel.
Download for iOS