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.

Prefer Apify? All three products are also available as Apify actors with pay-per-result billing: Menu Scraper, Business Enricher, Contact 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)
Contact 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 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

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). Equivalent to passing urls with a single entry. A bare domain such as example.com is accepted and normalised to https://.
modestring optionalfull (default) or contacts_only. See Modes.
Key names. 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).

ParameterTypeDescription
dataset_idstring required without urlsApify dataset ID from a Google Maps Scraper run (max 255 chars). Always processed asynchronously.
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,
  "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

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,
  "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
    }
  ]
}
The profile sits under a different key on each endpoint. The synchronous single-URL response nests it under 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

Fieldfullcontacts_only
business_name
contacts.emails
contacts.phones
contacts.social_links
metadata
addressunder business_detailstop 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

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. Unfound values come back as null or an empty array — we never fabricate data.

Enrichment Profile Schema — 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.

Confidencefields_foundMeaning
high8 or moreRich profile — contacts plus services, people, hours and similar
medium4 to 7Core fields present, likely missing services or people
low1 to 3Very 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:

ProductA 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 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 (Contact Finder). Billed by Apify per successful dataset row.

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