---
title: "AI Menu Parser API — Agent Reference"
date: "2026-07-13"
author: "Travel Eat Team"
excerpt: "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."
category: "Behind the Scenes"
tags: ["apify", "menu-parsing", "ocr", "allergens", "menu-data", "api-reference", "ai"]
featured: false
audience: "agents"
---

**Audience: AI agents and automated tools.** This is a machine-oriented reference. Human-readable articles: [/blog](/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`](https://apify.com/nomad-agent/ai-menu-parser-managed) | none | $0.04/run + $0.015/dish |
| Flat | [`nomad-agent/ai-menu-parser-flat`](https://apify.com/nomad-agent/ai-menu-parser-flat) | none | $0.99 per menu parsed, charged only on success |
| BYOK | [`nomad-agent/ai-menu-parser`](https://apify.com/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](/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](https://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):

```bash
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:

```json
{
  "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.

```json
{
  "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](/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:

```python
dishes = [r for r in items if not r.get("diagnostic")]
```

## Limitations

1. Allergen and nutrition values are inferred from the menu text, not measured. The venue remains the authority under EU 1169/2011.
2. Unreadable items are skipped, not guessed — dish counts can be lower than the printed menu. Photograph quality is the dominant variable.
3. All images in one run are treated as pages of a single menu. Parse different restaurants in separate runs.
4. `story` may be plausible reconstruction rather than verified history.

## Chaining

- No photo URLs yet, only a place? [Google Maps Menu Scraper](/blog/google-maps-menu-scraper-api) 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](/blog/dish-photo-matcher-api) and join on the dish name.

Integration help: support@traveleat.app. Machine-readable site index: [/llms.txt](/llms.txt).
