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.

Prefer Apify? All three products are also available as Apify actors with pay-per-result billing: Menu Scraper, Business Enricher, Email Finder. This API reference is for direct customers.

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_....

Request Header
X-API-Key: msk_your_api_key_here
Keep your key secret. Do not expose it in client-side code, public repos, or share it with unauthorized parties. If your key is compromised, contact us to rotate it.

Base URL

All API endpoints are relative to:

https://menusapi.com/api

Products Overview

ProductEndpointUse When
Menu ScraperPOST /api/scrapeYou need a restaurant menu as structured JSON
Business EnricherPOST /api/enrich (mode: "full")You need a full business profile (emails, services, team, hours)
Email FinderPOST /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 — Menu extraction
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 — Business profile enrichment
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 — Email & contacts only
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"
  }'

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

POST /api/scrape

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

ParameterTypeDescription
urlstring requiredRestaurant URL to scrape (max 2048 chars)
locationstring optionalRestaurant location for disambiguation (e.g. "San Diego, CA")
max_age_daysinteger optionalCache freshness tier. One of: 1, 14, 60 (default), 180. See Freshness Tiers.
google_place_idstring optionalGoogle Place ID for more precise cache matching
restaurant_namestring optionalRestaurant name hint to aid extraction

Success Response 200

Response JSON
{
  "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

POST /api/scrape/batch

Submit up to 2,000 restaurants for asynchronous processing. Returns a batch ID that you poll for results.

Request Body

ParameterTypeDescription
restaurantsarray requiredArray of restaurant objects (max 2,000). Each must have url, and optionally location, restaurant_name, google_place_id.
max_age_daysinteger optionalCache freshness for the whole batch. One of: 1, 14, 60 (default), 180.
cURL
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

GET /api/scrape/batch/{id}

Poll for batch job progress and results. When status is "completed", all items have been processed.

Response JSON
{
  "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

GET /api/menus

Retrieve a paginated list of previously extracted menus, ordered by most recent.

Query ParamTypeDescription
per_pageinteger optionalResults per page. Default 20, max 100.

Get Menu by ID

GET /api/menus/{id}

Retrieve a specific menu by its ID. Returns the full menu data including metadata, completeness info, and extracted content.

Discover Sources

GET /api/sources?url={url}

See what menu sources have been discovered for a given restaurant URL. Useful for debugging or understanding which sources the pipeline found.

Query ParamTypeDescription
urlstring requiredThe 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

POST /api/enrich

Submit one URL (sync) or an array of up to 2,000 URLs (async). Works on any business website.

Request Body (single URL, sync)

ParameterTypeDescription
urlstring requiredBusiness website URL (max 2048 chars)
modestring optionalfull (default) or contacts_only. See Modes.
cURL — single URL
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

POST /api/enrich

Submit 2–2,000 URLs at once. Returns 202 Accepted immediately with a job ID.

Request Body (batch)

ParameterTypeDescription
urlsarray requiredArray of business website URLs (2–2,000)
modestring optionalfull (default) or contacts_only
cURL — batch
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

GET /api/enrich/{id}

Poll for job status. Results are only returned when status = "completed".

Response JSON (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

Fieldfullcontacts_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

GET /api/health

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.

ValueTierMax AgeBest For
180Long Cache180 daysBulk imports, seeding databases
60Medium (default)60 daysRegular updates, periodic refreshes
14Short Cache14 daysFrequently changing menus
1Fresh24 hoursReal-time accuracy, daily updates
If max_age_days is omitted, it defaults to 60 (Medium tier).

The menu object in the response follows this structure:

Menu JSON Schema
{
  "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

GradeMeaningBilled?
completeFull menu with prices and descriptionsYes
good_no_pricesFull menu content, but prices not found on the sourceYes
partial_core_missingIncomplete extraction, significant data missingNo
minimalVery little useful data extractedNo
not_a_menuThe content was not a restaurant menuNo

Source Types

The source_type field indicates where the menu data was extracted from:

  • WEBSITE — Restaurant's own website
  • PDF — Downloadable PDF menu
  • IMAGE — Image-based menu (OCR)
  • IFRAME — Embedded ordering system
  • DOORDASH, UBEREATS, GRUBHUB — Delivery platforms
  • YELP — Yelp menu listing
  • TOAST, SQUARE, CHOWNOW — POS/ordering platforms

Enrichment Schema

Fields returned by the enrichment endpoint. Missing fields are omitted from the response — we never fabricate data.

Enrichment Profile Schema
{
  "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

GradeMeaningBilled?
highRich profile with multiple fields populated, including contactsYes
mediumCore fields present, may be missing services or teamYes
lowVery little found — possibly a contact-form-only or mostly-image siteNo

Error Handling

HTTP StatusMeaning
200Success — menu data returned
202Accepted — batch job created, poll for results
400Bad request — missing or invalid parameters
401Unauthorized — missing or invalid API key
403Forbidden — API key deactivated
404Not found — resource does not exist
422Extraction failed — could not extract menu from URL
Cached failures: If a URL previously failed extraction, subsequent requests within the same freshness window will return the cached failure immediately (with "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 complete or good_no_prices.
  • Non-billable: Failed extractions and grades partial_core_missing, minimal, or not_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 high or medium.
  • Non-billable: low quality (insufficient data found) and failed extractions.
  • Pay-per-result: $15/1K for full mode (Business Enricher), $10/1K for contacts_only mode (Email Finder). Billed by Apify per successful dataset row.

See Pricing for full rates by product, plan, and freshness tier.