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.
🤖 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.
| Variant | Actor ID | API keys | Price (2026-07-13) |
|---|---|---|---|
| Managed | nomad-agent/dish-photo-matcher-managed | none | $0.03/run + $0.004/photo |
| BYOK | nomad-agent/dish-photo-matcher | Gemini (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
- Photos come from
photoUrlsif set, otherwise from theplaceUrl's Google Maps "Food & drink" gallery. When both are set,photoUrlswins. - Photos are matched in batches of 9 by visual appearance (Gemini Flash-Lite vision).
- Names the model returns that are not on your menu are filtered to
null— hallucinated labels cannot reach the dataset. - 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
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
menuItems | array | yes | — | Objects ({name, originalName?, category?, description?}) or plain strings. Extra fields improve match quality |
photoUrls | string[] | one of these | — | Direct food/drink photo URLs |
placeUrl | string | one of these | — | Google Maps place URL, place ID, or name — its guest food photos are fetched |
maxPhotos | integer | no | 27 | Clamped to 1–100 |
geminiApiKey | string | BYOK only | — | aistudio.google.com/apikey |
outscraperApiKey | string | BYOK, recommended | — | Needed for reliable placeUrl gallery access |
geminiModel | enum | BYOK only | gemini-3.1-flash-lite | Vision matching does not need a reasoning model |
proxyConfiguration | object | BYOK only | — | Standard 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"
}
| Field | Type | Notes |
|---|---|---|
photoUrl | string | The photo that was analyzed |
dishName | string | null | Exact name of the matched menu item, or null when the photo matches nothing on the menu |
matched | boolean | false is a real answer, not an error — galleries contain interiors, drinks, off-menu dishes |
place | string | null | Only present in place mode |
diagnostic | boolean | Only on a non-billed diagnostic row |
message | string | Only 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
- Matching is by appearance. Visually near-identical dishes on the same menu (two burgers, two red pastas) can be swapped.
- Guest photos are not menu photography: bad light, half-eaten plates and mislabelled uploads all reduce accuracy.
- Unmatched photos are billed. Cap cost with
maxPhotosrather than by filtering afterwards. - 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:
- Run Google Maps Menu Scraper on a place (or AI Menu Parser on your photos) → structured dishes.
- Pass those dishes plus the same place into this actor.
- Join on
dishName— every dish now carries a real guest photo.
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