API Documentation
Menus API hosts three extraction endpoints behind a single API key: the Menu Scraper, the Business Profile Enricher, and the Email Finder. Same auth, same base URL, same response conventions.
Authentication
All API requests (except health check) require an API key passed in the X-API-Key header.
Your API key is generated automatically when you subscribe and sent to your email. Keys use the format msk_....
X-API-Key: msk_your_api_key_here
Base URL
All API endpoints are relative to:
https://menusapi.com/api
Products Overview
| Product | Endpoint | Use When |
|---|---|---|
| Menu Scraper | POST /api/scrape | You need a restaurant menu as structured JSON |
| Business Enricher | POST /api/enrich (mode: "full") | You need a full business profile (emails, services, team, hours) |
| Email Finder | POST /api/enrich (mode: "contacts_only") | You need just emails, phones, and socials |
Quick Start
One-line examples for each product. All three follow the same auth pattern.
curl -X POST https://menusapi.com/api/scrape \ -H "X-API-Key: msk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "url": "https://www.example-restaurant.com", "location": "New York, NY", "max_age_days": 60 }'
curl -X POST https://menusapi.com/api/enrich \ -H "X-API-Key: msk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "url": "https://smithlawfirm.com", "mode": "full" }'
curl -X POST https://menusapi.com/api/enrich \ -H "X-API-Key: msk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "url": "https://smithlawfirm.com", "mode": "contacts_only" }'
Menu Scraper API
Extract structured restaurant menu data from any URL. The scraper automatically discovers menu sources across the site, handles PDFs and image menus, and returns clean JSON with categories, items, prices, and descriptions. Cache is controlled per-request via max_age_days.
Scrape a Menu
Submit a restaurant URL for menu extraction. Returns structured menu data as JSON. Results are cached based on the freshness tier you specify.
Request Body
| Parameter | Type | Description |
|---|---|---|
| url | string required | Restaurant URL to scrape (max 2048 chars) |
| location | string optional | Restaurant location for disambiguation (e.g. "San Diego, CA") |
| max_age_days | integer optional | Cache freshness tier. One of: 1, 14, 60 (default), 180. See Freshness Tiers. |
| google_place_id | string optional | Google Place ID for more precise cache matching |
| restaurant_name | string optional | Restaurant name hint to aid extraction |
Success Response 200
{
"success": true,
"data": {
"menu_id": "01jk...",
"restaurant_name": "Joe's Pizza",
"source_type": "WEBSITE",
"extraction_method": "HTML_PARSE",
"item_count": 47,
"extracted_at": "2026-02-01T12:00:00+00:00",
"menu": {
"restaurant_name": "Joe's Pizza",
"categories": [
{
"name": "Appetizers",
"items": [
{
"name": "Garlic Knots",
"description": "Fresh baked with garlic butter",
"price": "$6.99"
}
]
}
]
}
},
"extraction_confidence": { ... },
"processing_time_ms": 8420
}
Error Response 422
{
"success": false,
"error": {
"reason": "no_menu_found",
"message": "Could not locate a menu on this website"
},
"attempt_id": "01jk...",
"processing_time_ms": 15200
}
Batch Scrape
Submit up to 2,000 restaurants for asynchronous processing. Returns a batch ID that you poll for results.
Request Body
| Parameter | Type | Description |
|---|---|---|
| restaurants | array required | Array of restaurant objects (max 2,000). Each must have url, and optionally location, restaurant_name, google_place_id. |
| max_age_days | integer optional | Cache freshness for the whole batch. One of: 1, 14, 60 (default), 180. |
curl -X POST https://menusapi.com/api/scrape/batch \ -H "X-API-Key: msk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "restaurants": [ { "url": "https://restaurant-a.com", "location": "San Diego, CA" }, { "url": "https://restaurant-b.com", "location": "Los Angeles, CA" } ], "max_age_days": 14 }'
Response 202 Accepted
{
"batch_id": "01jk...",
"total": 2,
"status": "processing",
"poll_url": "/api/scrape/batch/01jk..."
}
Batch Status
Poll for batch job progress and results. When status is "completed", all items have been processed.
{
"batch_id": "01jk...",
"status": "completed",
"total": 2,
"completed": 2,
"failed": 0,
"pending": 0,
"results": [
{
"url": "https://restaurant-a.com",
"status": "completed",
"menu_id": "01jk...",
"restaurant_name": "Restaurant A",
"item_count": 35,
"processing_time_ms": 9800
}
]
}
List Menus
Retrieve a paginated list of previously extracted menus, ordered by most recent.
| Query Param | Type | Description |
|---|---|---|
| per_page | integer optional | Results per page. Default 20, max 100. |
Get Menu by ID
Retrieve a specific menu by its ID. Returns the full menu data including metadata, completeness info, and extracted content.
Discover Sources
See what menu sources have been discovered for a given restaurant URL. Useful for debugging or understanding which sources the pipeline found.
| Query Param | Type | Description |
|---|---|---|
| url | string required | The restaurant URL to look up sources for |
Enrichment API
One endpoint powers both the Business Profile Enricher and the Email Finder. The mode parameter controls which fields get extracted — full returns a complete profile, contacts_only returns just emails, phones, and socials at lower cost.
Single URLs return synchronously. Batches of 2 or more URLs are processed asynchronously — you receive a job ID and poll for completion.
Enrich a Business
Submit one URL (sync) or an array of up to 2,000 URLs (async). Works on any business website.
Request Body (single URL, sync)
| Parameter | Type | Description |
|---|---|---|
| url | string required | Business website URL (max 2048 chars) |
| mode | string optional | full (default) or contacts_only. See Modes. |
curl -X POST https://menusapi.com/api/enrich \ -H "X-API-Key: msk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "url": "https://smithlawfirm.com", "mode": "full" }'
Success Response (single URL) 200
{
"success": true,
"data": {
"business_name": "Smith Law Firm",
"emails": ["info@smithlaw.com", "jsmith@smithlaw.com"],
"phones": ["(555) 123-4567"],
"address": "123 Main St, Boston, MA",
"services": ["Personal Injury", "Workers' Comp"],
"team": [{ "name": "Jane Smith", "title": "Senior Partner" }],
"socials": { "linkedin": "...", "facebook": "..." },
"quality": "high",
"fields_populated": 12
}
}
Batch Enrichment
Submit 2–2,000 URLs at once. Returns 202 Accepted immediately with a job ID.
Request Body (batch)
| Parameter | Type | Description |
|---|---|---|
| urls | array required | Array of business website URLs (2–2,000) |
| mode | string optional | full (default) or contacts_only |
curl -X POST https://menusapi.com/api/enrich \ -H "X-API-Key: msk_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "urls": [ "https://smithlawfirm.com", "https://acmeroofing.com", "https://austindental.com" ], "mode": "contacts_only" }'
Response 202 Accepted
{
"job_id": "01jk...",
"total": 3,
"status": "processing",
"poll_url": "/api/enrich/01jk..."
}
Enrichment Job Status
Poll for job status. Results are only returned when status = "completed".
{
"job_id": "01jk...",
"status": "completed",
"total": 3,
"completed": 3,
"failed": 0,
"results": [
{
"url": "https://smithlawfirm.com",
"status": "completed",
"data": { /* full profile object */ }
}
]
}
Modes: full vs contacts_only
| Field | full | contacts_only |
|---|---|---|
| business_name | ✓ | ✓ |
| emails | ✓ | ✓ |
| phones | ✓ | ✓ |
| socials | ✓ | ✓ |
| address | ✓ | — |
| hours | ✓ | — |
| services (with pricing) | ✓ | — |
| team / owner | ✓ | — |
| certifications | ✓ | — |
| payment methods | ✓ | — |
Both modes crawl the same subpages and do the same amount of work. The difference is in the LLM extraction prompt — contacts_only uses a shorter prompt (cheaper) and narrower schema.
Pricing (Apify): full bills as Business Enricher at $15/1K. contacts_only bills as Email Finder at $10/1K.
Health Check
Public endpoint (no authentication required). Returns service status.
{
"status": "ok",
"timestamp": "2026-02-05T12:00:00+00:00"
}
Freshness Tiers
The max_age_days parameter controls how fresh the data must be. If a cached result exists within the age threshold, it's returned instantly. Otherwise, a fresh extraction is triggered.
| Value | Tier | Max Age | Best For |
|---|---|---|---|
| 180 | Long Cache | 180 days | Bulk imports, seeding databases |
| 60 | Medium (default) | 60 days | Regular updates, periodic refreshes |
| 14 | Short Cache | 14 days | Frequently changing menus |
| 1 | Fresh | 24 hours | Real-time accuracy, daily updates |
max_age_days is omitted, it defaults to 60 (Medium tier).
Menu Data Schema
The menu object in the response follows this structure:
{
"restaurant_name": "string",
"categories": [
{
"name": "string",
"description": "string | null",
"items": [
{
"name": "string",
"description": "string | null",
"price": "string | null",
"variants": [
{ "name": "string", "price": "string" }
],
"dietary_info": ["string"]
}
]
}
],
"metadata": {
"has_prices": true,
"has_descriptions": true,
"estimated_item_count": 47,
"detected_language": "en"
},
"completeness": {
"grade": "complete",
"flags": []
}
}
Completeness Grades
| Grade | Meaning | Billed? |
|---|---|---|
| complete | Full menu with prices and descriptions | Yes |
| good_no_prices | Full menu content, but prices not found on the source | Yes |
| partial_core_missing | Incomplete extraction, significant data missing | No |
| minimal | Very little useful data extracted | No |
| not_a_menu | The content was not a restaurant menu | No |
Source Types
The source_type field indicates where the menu data was extracted from:
WEBSITE— Restaurant's own websitePDF— Downloadable PDF menuIMAGE— Image-based menu (OCR)IFRAME— Embedded ordering systemDOORDASH,UBEREATS,GRUBHUB— Delivery platformsYELP— Yelp menu listingTOAST,SQUARE,CHOWNOW— POS/ordering platforms
Enrichment Schema
Fields returned by the enrichment endpoint. Missing fields are omitted from the response — we never fabricate data.
{
"business_name": "string",
"emails": ["string"],
"phones": ["string"],
"address": "string | null",
"hours": "string | null",
"services": [
{ "name": "string", "price": "string | null" }
],
"team": [
{ "name": "string", "title": "string | null" }
],
"certifications": ["string"],
"payment_methods": ["string"],
"socials": {
"facebook": "string | null",
"linkedin": "string | null",
"twitter": "string | null",
"instagram": "string | null"
},
"quality": "high | medium | low",
"fields_populated": 0
}
Quality Grades
| Grade | Meaning | Billed? |
|---|---|---|
| high | Rich profile with multiple fields populated, including contacts | Yes |
| medium | Core fields present, may be missing services or team | Yes |
| low | Very little found — possibly a contact-form-only or mostly-image site | No |
Error Handling
| HTTP Status | Meaning |
|---|---|
| 200 | Success — menu data returned |
| 202 | Accepted — batch job created, poll for results |
| 400 | Bad request — missing or invalid parameters |
| 401 | Unauthorized — missing or invalid API key |
| 403 | Forbidden — API key deactivated |
| 404 | Not found — resource does not exist |
| 422 | Extraction failed — could not extract menu from URL |
"cached": true) to avoid expensive re-processing. Wait for the cache to expire, or use a shorter max_age_days value.
Billing Rules
Menu Scraper (direct)
- Billable: Successful extractions with completeness grade
completeorgood_no_prices. - Non-billable: Failed extractions and grades
partial_core_missing,minimal, ornot_a_menu. - Cache hits are billable: You pay for the freshness tier, not the extraction time. A cache hit at the Fresh tier costs the same as a fresh extraction at the Fresh tier.
- Per-request metering: Usage reports to Stripe monthly alongside your plan's base fee.
Enrichment (Apify)
- Billable: Profiles with quality grade
highormedium. - Non-billable:
lowquality (insufficient data found) and failed extractions. - Pay-per-result: $15/1K for
fullmode (Business Enricher), $10/1K forcontacts_onlymode (Email Finder). Billed by Apify per successful dataset row.
See Pricing for full rates by product, plan, and freshness tier.