🔧 Behind the Scenes🤖 For AI Agents

Dish Photo Matcher API — Agent Reference

Machine reference for the Restaurant Dish Photo Matcher Apify actor (managed and BYOK variants): a menu plus food photos in — one photo→dish assignment per photo out, matched by AI vision against your own label set. Input/output schemas, pricing, diagnostics.

Travel Eat TeamJuly 13, 20265 min read
apifygoogle-mapsphoto-matchingcomputer-visionmenu-dataapi-referenceai
🔧

🤖 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, two variants, same code and same output: give it a menu (list of items) plus food photos — either direct URLs or a Google Maps place whose guest "Food & drink" photos are fetched for you — and it returns one record per photo, labelled with the exact menu item it shows, or null when it shows nothing on that menu.

VariantActor IDAPI keysPrice (2026-07-13)
Managednomad-agent/dish-photo-matcher-managednone$0.03/run + $0.004/photo
BYOKnomad-agent/dish-photo-matcherGemini (free tier works), Outscraper optional$0.01/run + $0.002/photo

The label space is your menu, not a generic food taxonomy — that is the difference from an image-classification API, and from a raw Maps photo scraper that returns unlabelled URLs. A default run (27 photos) costs about $0.14 managed. The store page is authoritative on price; values here are a snapshot.

How it works

  1. Photos come from photoUrls if set, otherwise from the placeUrl's Google Maps "Food & drink" gallery. When both are set, photoUrls wins.
  2. Photos are matched in batches of 9 by visual appearance (Gemini Flash-Lite vision).
  3. Names the model returns that are not on your menu are filtered to null — hallucinated labels cannot reach the dataset.
  4. Every analyzed photo is billed, matched or not: confirming a non-match costs the same inference as a match.

On BYOK, set outscraperApiKey (free tier) when using placeUrl — signed-out Google hides the Maps photo gallery from plain browsers. The managed variant handles this on our keys. Background: /blog/google-maps-locked-photo-gallery-2026.

Input

FieldTypeRequiredDefaultNotes
menuItemsarrayyesObjects ({name, originalName?, category?, description?}) or plain strings. Extra fields improve match quality
photoUrlsstring[]one of theseDirect food/drink photo URLs
placeUrlstringone of theseGoogle Maps place URL, place ID, or name — its guest food photos are fetched
maxPhotosintegerno27Clamped to 1–100
geminiApiKeystringBYOK onlyaistudio.google.com/apikey
outscraperApiKeystringBYOK, recommendedNeeded for reliable placeUrl gallery access
geminiModelenumBYOK onlygemini-3.1-flash-liteVision matching does not need a reasoning model
proxyConfigurationobjectBYOK onlyStandard Apify proxy input

Invocation

Place mode — the actor fetches the photos itself (managed):

curl -X POST \
  "https://api.apify.com/v2/acts/nomad-agent~dish-photo-matcher-managed/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "placeUrl": "Katz'\''s Delicatessen New York",
    "menuItems": [
      { "name": "Pastrami on Rye", "category": "Sandwiches" },
      { "name": "Matzo Ball Soup", "category": "Soups" }
    ],
    "maxPhotos": 27
  }'

Photo mode — you already hold the image URLs, and BYOK:

{
  "photoUrls": ["https://example.com/photo-1.jpg", "https://example.com/photo-2.jpg"],
  "menuItems": ["Pastrami on Rye", "Tiramisu"],
  "geminiApiKey": "AIza…"
}

Any Apify invocation method works: REST, apify-client (JS/Python), apify call, MCP server, scheduled runs.

Output

One record per photo analyzed.

{
  "photoUrl": "https://lh3.googleusercontent.com/p/AF1…",
  "dishName": "Pastrami on Rye",
  "matched": true,
  "place": "Katz's Delicatessen New York"
}
FieldTypeNotes
photoUrlstringThe photo that was analyzed
dishNamestring | nullExact name of the matched menu item, or null when the photo matches nothing on the menu
matchedbooleanfalse is a real answer, not an error — galleries contain interiors, drinks, off-menu dishes
placestring | nullOnly present in place mode
diagnosticbooleanOnly on a non-billed diagnostic row
messagestringOnly on a diagnostic row

dishName is guaranteed to be a string that exists in your menuItems, so you can join on it without fuzzy matching:

by_dish = {}
for r in items:
    if r.get("diagnostic") or not r["matched"]:
        continue
    by_dish.setdefault(r["dishName"], []).append(r["photoUrl"])

Diagnostic rows

A failed or empty run pushes exactly one row with diagnostic: true and a message (no photos found for the place, photos undownloadable, missing key). These rows are never billed per-photo. Filter on diagnostic before consuming the dataset.

Limitations

  1. Matching is by appearance. Visually near-identical dishes on the same menu (two burgers, two red pastas) can be swapped.
  2. Guest photos are not menu photography: bad light, half-eaten plates and mislabelled uploads all reduce accuracy.
  3. Unmatched photos are billed. Cap cost with maxPhotos rather than by filtering afterwards.
  4. Tuned for food and drink. It generalises to any photo-against-known-label-list task, but nothing else is tested.

Chaining

Both menu actors emit records this actor accepts directly as menuItems:

  1. Run Google Maps Menu Scraper on a place (or AI Menu Parser on your photos) → structured dishes.
  2. Pass those dishes plus the same place into this actor.
  3. Join on dishName — every dish now carries a real guest photo.

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

T

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