Menu Intelligence API — Engineer Onboarding Prompt

You are working with the Menu Intelligence API, a REST API that serves structured restaurant menu data for 180+ NYC restaurants with 14,000+ dishes. The data is scraped monthly and includes prices, ingredients, allergens, trends, and geographic data.

Base URL

https://api.cartewei.dev

CORS is enabled (Access-Control-Allow-Origin: *), so you can fetch directly from browser-based apps.


Quickstart

1. Get your API key

Your API key will be provided by the Cartewei team. It starts with cw_ and is used via the X-API-Key header on all /api/v1/ requests.

2. Accept Terms of Service

Before making data requests, you must accept the current terms:

# Check current terms
curl https://api.cartewei.dev/api/v1/terms/current

# Accept terms
curl -X POST https://api.cartewei.dev/api/v1/terms/accept \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"version": "2026-02-14-v1"}'

3. Make your first request

# List all restaurants
curl https://api.cartewei.dev/api/v1/restaurants \
  -H "X-API-Key: YOUR_API_KEY"

# Get allergen profile for a restaurant
curl https://api.cartewei.dev/api/v1/restaurants/RESTAURANT_ID/allergen-profile \
  -H "X-API-Key: YOUR_API_KEY"

# Get allergens for a specific dish
curl https://api.cartewei.dev/api/v1/dishes/DISH_ID/allergens \
  -H "X-API-Key: YOUR_API_KEY"

Authentication errors

Status Error Meaning
401 api_key_required Missing X-API-Key header
401 invalid_api_key Key not found or revoked
403 terms_not_accepted Call /api/v1/terms/accept first
403 terms_update_required New terms version — re-accept
429 Too many requests Rate limit: 100 requests/minute

Response format

Every response includes a meta block with legal and versioning info:

{
  "restaurants": [ ... ],
  "meta": {
    "api_version": "v1",
    "terms_version": "2026-02-14-v1",
    "terms_accepted_at": "2026-03-13T12:00:00Z",
    "data_classification": "informational_only",
    "legal_notice": "NOT_SAFETY_GUIDANCE"
  }
}

Data Overview


Endpoints Reference

Restaurants

GET /api/restaurants

Returns all restaurants with metadata.

{
  "restaurants": [
    {
      "id": "cmjxsfsfe02iz1knukuldkafc",
      "name": "Al Badawi",
      "url": "https://...",
      "neighborhood": "Yorkville",
      "cuisineType": "Middle Eastern",
      "menuCount": 1,
      "hasPrixFixe": false,
      "latestMenuDate": "2026-01-03T04:13:44.799Z"
    }
  ]
}
GET /api/restaurants/:id/menus
GET /api/restaurants/:id/menus/:type          # type: "food" or "beverage"
GET /api/restaurants/:id/menus/:type/history   # historical menu versions
GET /api/restaurants/:id/snapshots             # raw snapshot list
GET /api/restaurants/:id/scraping-rounds       # scraping metadata
GET /api/restaurants/:id/diff                  # menu changes between snapshots

Dishes

GET /api/dishes/popular

Cross-restaurant dish comparison with price ranges.

{
  "dishes": [
    { "canonicalName": "Tiramisu", "restaurantCount": 15, "avgPrice": 13.87, "priceRange": "$8-$28.95" },
    { "canonicalName": "Chicken Parmigiana", "restaurantCount": 12, "avgPrice": 24.59, "priceRange": "$4.99-$38.95" }
  ]
}
GET /api/dishes/compare?dish=margherita+pizza   # compare a specific dish across restaurants
GET /api/dishes/by-ingredient?ingredient=truffle # find dishes using a specific ingredient

Allergens (LENS-227)

GET /api/dishes/:id/allergens

Confidence-scored allergen data for a single dish (0.00–0.99 scale, never claims certainty).

{
  "dish_id": "...",
  "dish_name": "Caesar Salad",
  "allergens": [
    {
      "allergen": "dairy",
      "fda_category": "milk",
      "confidence": 0.70,
      "verification_tier": "high_confidence",
      "basis": "ingredient_match",
      "source_ingredient": "parmesan"
    }
  ],
  "allergen_summary": {
    "detected_allergens": ["dairy", "gluten"],
    "min_confidence": 0.45,
    "max_confidence": 0.70,
    "overall_verification_tier": "moderate",
    "recommendation": "INFORMATIONAL_ONLY"
  }
}
GET /api/restaurants/:id/allergen-profile     # all dishes' allergens for a restaurant

Important: Allergen data is informational_only and NOT_SAFETY_GUIDANCE. All API responses include a meta block with this classification.

Ingredients

GET /api/ingredients                          # list canonical ingredients
GET /api/ingredients/search?q=truffle         # search ingredients
GET /api/ingredients/categories               # ingredient categories (protein, vegetable, etc.)
GET /api/ingredients/dietary                   # dietary flags (vegan, gluten-free, etc.)
GET /api/ingredients/top                       # most common ingredients

Top ingredients response:

{
  "ingredients": [
    { "ingredient": "garlic", "dishCount": 367, "restaurantCount": 65, "avgPrice": 22.73, "priceRange": { "min": 7.35, "max": 99 } },
    { "ingredient": "tomato", "dishCount": 213, "restaurantCount": 48, "avgPrice": 24.69 }
  ]
}
GET /api/ingredients/heatmap                  # ingredient frequency heatmap data
GET /api/ingredients/trends/:ingredient       # usage over time for one ingredient
GET /api/ingredients/locations                 # ingredients grouped by neighborhood
GET /api/ingredients/by-location/:location    # ingredients for a specific area

Beverages

GET /api/beverages                            # all beverages
GET /api/beverages/compare                    # cross-restaurant beverage comparison
GET /api/beverages/by-restaurant/:id          # beverages for a restaurant
GET /api/restaurants/:id/beverage-stats       # beverage analytics for a restaurant

Analytics

GET /api/analytics/diversity                  # ingredient diversity per restaurant
GET /api/analytics/stability                  # menu stability index (how often menus change)
GET /api/analytics/by-borough                 # aggregate stats by NYC borough
GET /api/analytics/by-city                    # aggregate stats by city

Trends (Time Series)

GET /api/trends/overview
{
  "overview": {
    "totalRestaurants": 181,
    "totalDishes": 14375,
    "totalSnapshots": 409,
    "totalChanges": 3228,
    "cuisineBreakdown": [
      { "cuisine": "Italian", "count": 53 },
      { "cuisine": "American", "count": 11 }
    ]
  }
}
GET /api/trends/prices                        # price trends over time
GET /api/trends/changes                       # menu change frequency over time
GET /api/trends/dishes                        # dish appearance/disappearance trends
GET /api/trends/stability                     # stability metrics over time
GET /api/trends/cuisines                      # cuisine representation trends

Map / Geo Data

GET /api/map/restaurants                      # restaurants with lat/lng coordinates
GET /api/map/restaurants/:id/dishes           # dishes for a specific restaurant (map context)
GET /api/map/filters                          # available filter options
GET /api/map/export/kml                       # KML export for Google Earth

Filters response includes:

{
  "boroughs": ["Brooklyn", "Manhattan", "Queens", "The Bronx"],
  "neighborhoods": ["Alphabet City", "Carroll Gardens", "Chelsea", ...],
  "cuisineTypes": ["Italian", "American", "Mexican", ...]
}

Data Quality

GET /api/data-quality/issues                  # detected data quality issues
GET /api/data-quality/summary                 # issue counts by type/severity
GET /api/data-quality/restaurant/:id          # issues for a specific restaurant

Time Machine

GET /api/time-machine/restaurants             # restaurants with multiple snapshots for comparison

Usage from JavaScript

// Fetch all restaurants
const res = await fetch('http://localhost:3000/api/restaurants');
const { restaurants } = await res.json();

// Get popular dishes for a bar chart
const pop = await fetch('http://localhost:3000/api/dishes/popular');
const { dishes } = await pop.json();

// Get price trends for a line chart
const trends = await fetch('http://localhost:3000/api/trends/prices');
const priceData = await trends.json();

// Get geo data for a map
const geo = await fetch('http://localhost:3000/api/map/restaurants');
const { restaurants: geoRestaurants } = await geo.json();

// Get ingredient heatmap data
const heatmap = await fetch('http://localhost:3000/api/ingredients/heatmap');
const heatmapData = await heatmap.json();

// Get allergen profile for a restaurant
const allergens = await fetch(`http://localhost:3000/api/restaurants/${id}/allergen-profile`);
const allergenData = await allergens.json();

Dataviz Ideas

The dataset is well-suited for:

PDF & Image Access

Menu PDFs are stored in private Supabase Storage (bucket: menu-pdfs). Access them through the API using signed URLs.

Get Latest PDF for a Restaurant

GET /api/restaurants/:id/pdf-url
Header: x-pdf-password: <password>
{
  "url": "https://uhzewmdarxznwxecmexw.supabase.co/storage/v1/object/sign/menu-pdfs/...",
  "restaurantName": "Via Carota",
  "pdfInfo": {
    "pageCount": 2,
    "sizeBytes": 154320,
    "capturedAt": "2026-01-15T00:00:00.000Z"
  }
}

Important: Signed URLs expire after 1 hour. Download and cache the file itself, not the URL.

PDF Version History

GET /api/restaurants/:id/pdf-history
Header: x-pdf-password: <password>

Returns signed URLs for all archived PDF versions.

Snapshot PDF URL

GET /api/snapshots/:id/pdf-url

Returns signed URL for a specific snapshot's PDF (no password required).

Website Thumbnails (screenshots)

Live screenshots of restaurant websites, generated via Browserless and cached as PNGs:

GET /api/canvas/thumbnail?url=https://viacarota.com

KML Export

GET /api/map/export/kml

KML file of all geocoded restaurants for Google Maps / Google Earth.


Caching Strategy for Visualization Apps

Menu data updates monthly (scrapes run on the 1st). Recommended cache durations:

Data Type Cache Duration Notes
Restaurant list & coordinates 24 hours Changes rarely
Dish data 24 hours Only updates after monthly scrape
Analytics (diversity, stability) 1 hour Computed on demand
Thumbnails 24 hours Already cached server-side
Filter options 24 hours Stable dataset
PDF signed URLs Do NOT cache the URL Expires in 1 hour — cache the downloaded PDF file instead
Trends data 1 hour Aggregated from snapshots

Recommended pattern:

async function getCachedData(key: string, fetchFn: () => Promise<any>, ttlMs = 86400000) {
  const cached = localStorage.getItem(key);
  const cacheTime = localStorage.getItem(`${key}_ts`);

  if (cached && cacheTime && Date.now() - Number(cacheTime) < ttlMs) {
    return JSON.parse(cached);
  }

  const data = await fetchFn();
  localStorage.setItem(key, JSON.stringify(data));
  localStorage.setItem(`${key}_ts`, String(Date.now()));
  return data;
}

// Usage
const restaurants = await getCachedData('restaurants', async () => {
  const res = await fetch('http://localhost:3000/api/map/restaurants');
  return res.json();
});

For PDFs — download and cache the file, not the URL:

async function getCachedPdf(restaurantId: string): Promise<Blob> {
  // Check IndexedDB or your cache for existing file
  const cached = await pdfCache.get(restaurantId);
  if (cached && cached.age < 7 * 86400000) return cached.blob;

  // Get a fresh signed URL
  const res = await fetch(`http://localhost:3000/api/restaurants/${restaurantId}/pdf-url`, {
    headers: { 'x-pdf-password': '<password>' }
  });
  const { url } = await res.json();

  // Download the actual PDF and cache it locally
  const pdfRes = await fetch(url);
  const blob = await pdfRes.blob();
  await pdfCache.set(restaurantId, blob);
  return blob;
}

Authentication

All /api/v1/* data endpoints require an API key (X-API-Key header) and accepted Terms of Service. See the Quickstart section above.

Endpoint Auth Method
/api/v1/* X-API-Key header + terms acceptance
/api/v1/terms/current Public (no auth)
/api/v1/terms/accept X-API-Key only (no terms check)
/api/restaurants/:id/pdf-url x-pdf-password header
/api/restaurants/:id/pdf-history x-pdf-password header
/api/admin/* Admin auth (internal only)

Rate limit: 100 requests per minute per API key on all /api/v1/ endpoints.

Notes