API Documentation
Menus API hosts three extraction endpoints behind a single API key: the Menu Scraper, the Business Profile Enricher, and the Contact 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) |
| Contact 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 Contact 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). Equivalent to passing urls with a single entry. A bare domain such as example.com is accepted and normalised to https://. |
| mode | string optional | full (default) or contacts_only. See Modes. |
urls (array) and extraction_profile (full or contacts) are the canonical parameters — that is what you will see in our Apify actors. url and mode are accepted as equivalents, and contacts_only is accepted for contacts. Pass whichever pair you prefer; if you send both, urls and extraction_profile win.
Google Maps dataset input
Instead of urls, pass dataset_id to enrich every business website in an Apify Google Maps Scraper dataset. We read the website field from each record, skip rows without one, and cap the run at the first 2,000 URLs (you get a warning in the response if the dataset was larger).
| Parameter | Type | Description |
|---|---|---|
| dataset_id | string required without urls | Apify dataset ID from a Google Maps Scraper run (max 255 chars). Always processed asynchronously. |
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,
"url": "https://smithlawfirm.com",
"business_name": "Smith Law Firm",
"processing_time_ms": 8421,
"data": {
"business_name": "Smith Law Firm",
"description": "Boston trial firm handling personal injury...",
"contacts": {
"emails": ["info@smithlaw.com", "jsmith@smithlaw.com"],
"phones": ["(555) 123-4567"],
"social_links": {
"facebook": "https://facebook.com/...", "instagram": null,
"twitter": null, "linkedin": "https://linkedin.com/...",
"youtube": null, "tiktok": null,
"yelp": null, "pinterest": null
}
},
"people": [
{
"name": "Jane Smith",
"title": "Senior Partner",
"linked_email": "jsmith@smithlaw.com"
}
],
"business_details": {
"services": ["Personal Injury", "Workers' Comp"],
"year_established": 1998,
"certifications": ["MA Bar #12345"],
"service_area": ["Boston", "Cambridge"],
"hours_of_operation": "Mon-Fri 9-5",
"address": "123 Main St, Boston, MA",
"languages": ["English", "Spanish"],
"payment_methods": ["Contingency"]
},
"metadata": {
"extraction_confidence": "high",
"fields_found": 12,
"pages_analyzed": 3
}
}
}
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,
"pending": 0,
"created_at": "2026-08-20T09:14:02.000000Z",
"completed_at": "2026-08-20T09:15:37.000000Z",
"results": [
{
"url": "https://smithlawfirm.com",
"status": "completed",
"business_name": "Smith Law Firm",
"enrichment_data": { /* the profile object — see schema below */ },
"processing_time_ms": 8421
},
{
"url": "https://unreachable.com",
"status": "failed",
"failure_reason": "dns_failure",
"processing_time_ms": 1204
}
]
}
data; this asynchronous response nests it under results[].enrichment_data. The object itself is identical. results is present only once status is completed — while a job is running you get the counters alone.
Modes: full vs contacts_only
| Field | full | contacts_only |
|---|---|---|
business_name | ✓ | ✓ |
contacts.emails | ✓ | ✓ |
contacts.phones | ✓ | ✓ |
contacts.social_links | ✓ | ✓ |
metadata | ✓ | ✓ |
| address | ✓ under business_details | ✓ top level |
contact_page_url | — | ✓ |
description | ✓ | — |
people (name, title, linked_email) | ✓ | — |
business_details.services | ✓ | — |
business_details.hours_of_operation | ✓ | — |
business_details.year_established | ✓ | — |
business_details.certifications | ✓ | — |
business_details.service_area | ✓ | — |
business_details.languages | ✓ | — |
business_details.payment_methods | ✓ | — |
Note that address sits in a different place in each mode: under business_details in full, and at the top level in contacts_only. Services are a flat list of names; we do not return service pricing.
The modes also differ in how much of the site they read. full follows up to four high-value subpages — about, contact, team and services — and sends up to 20,000 characters to the model. contacts_only reads the homepage and the contact page only, with a 10,000 character budget and a shorter prompt. That is why it is cheaper, and also why it surfaces proportionally more general info@-style addresses: the contact page is where those live.
Pricing (Apify): full bills as Business Enricher at $15/1K. contacts_only bills as Contact 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. Unfound values come back as null or an empty array — we never fabricate data.
full mode{
"business_name": "string | null",
"description": "string | null",
"contacts": {
"emails": ["string"],
"phones": ["string"],
"social_links": {
"facebook": "string | null", "instagram": "string | null",
"twitter": "string | null", "linkedin": "string | null",
"youtube": "string | null", "tiktok": "string | null",
"yelp": "string | null", "pinterest": "string | null"
}
},
"people": [
{
"name": "string",
"title": "string | null",
"linked_email": "string | null"
}
],
"business_details": {
"services": ["string"],
"year_established": "integer | null",
"certifications": ["string"],
"service_area": ["string"],
"hours_of_operation": "string | null",
"address": "string | null",
"languages": ["string"],
"payment_methods": ["string"]
},
"metadata": {
"extraction_confidence": "high | medium | low",
"fields_found": 0,
"pages_analyzed": 0
}
}
contacts_only mode returns a narrower object: business_name, contacts, address, contact_page_url and metadata. It has no people array and no business_details.
About linked_email
Set only when the site prints an address inside that person's own entry — their bio block, staff card, or row in a staff directory. We never construct an address from someone's name and your domain, and an address that appears nowhere in contacts.emails is discarded before the result is stored.
null is the normal value. Most businesses do not publish per-person addresses; roughly 40% of profiles that contain people have any attributable address at all. Where a site lists one shared address against several staff, each of them carries that same value — that is what the page states.
Extraction Confidence
metadata.extraction_confidence is a summary of how much was found, derived from fields_found. It is descriptive only — it is not what decides billing.
| Confidence | fields_found | Meaning |
|---|---|---|
| high | 8 or more | Rich profile — contacts plus services, people, hours and similar |
| medium | 4 to 7 | Core fields present, likely missing services or people |
| low | 1 to 3 | Very little found — often a contact-form-only or mostly-image site |
What gets billed on Apify
Pay-per-result billing is decided per row, on whether the row carries anything useful — not on the confidence grade:
| Product | A row is billed when |
|---|---|
Business Enricher (full) | fields_found is 3 or more |
Contact Finder (contacts_only) | it has at least one email, phone, or social link |
Rows below those thresholds are not written to the dataset, so you are not charged for them. Failed URLs are never charged.
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 (Contact Finder). Billed by Apify per successful dataset row.
See Pricing for full rates by product, plan, and freshness tier.