menu item
On this page

menu-item API · v1

The search engine of dishes

The menu-item API is a read-only REST interface over the menu-item.com catalogue: Belgian hospitality venues, the dishes extracted from their menus and regional price statistics. Every response is JSON and every request carries a bearer token.

This page is generated from the same OpenAPI contract that validates the live API, so what you read here is exactly what the server returns. Point your integration at the base URL and go.

Every endpoint is a GET. Nothing here can change data, so you are free to explore.

Your first request
curl "https://menu-item.com/api/v1/dishes/stats?name=spareribs" \
  -H "Authorization: Bearer YOUR_TOKEN"

Authentication

Authenticate every request with a Laravel Sanctum personal access token, sent as a bearer token in the Authorization header. You can create and revoke tokens from your account dashboard.

Keep your token secret and use it only from server-side code. A missing or invalid token returns a 401 response.

Authorization header
curl "https://menu-item.com/api/v1/restaurants" \
  -H "Authorization: Bearer YOUR_TOKEN"
Response · 401
{
  "message": "Unauthenticated."
}

Base URL

All endpoint paths are relative to the base URL. Versioned resources live under /v1; the /user health check is intentionally unversioned.

Use the production host for live data. A local host is available when you run the API yourself.

Hosts
# Production
https://menu-item.com/api

# Local development
http://localhost:8000/api

Rate limits

The API is rate limited per token so it stays fast for everyone. If you exceed the limit you receive a 429 Too Many Requests response; wait a moment and retry.

Be a good citizen: cache responses where you can and avoid tight polling loops.

Price statistics and menus change slowly. Caching for a few minutes is usually plenty.

Errors

The API uses conventional HTTP status codes. Any 4xx or 5xx response carries a JSON body with a message, and validation errors add a field-keyed errors object.

  • 200 OK: the request succeeded
  • 401 Unauthorized: the bearer token is missing or invalid
  • 404 Not Found: no resource matched the request
  • 422 Unprocessable Entity: a parameter failed validation
  • 429 Too Many Requests: you are being rate limited
  • 500 Server Error: something went wrong on our end
Response · 422
{
  "message": "The name field is required.",
  "errors": {
    "name": [
      "The name field is required."
    ]
  }
}

Pagination

List endpoints are paginated. Control the window with per_page (1 to 100, default 25) and page. Each response carries a meta block with the current position and totals, and a links block with ready-made URLs for the first, last, previous and next pages.

To walk every page, follow links.next until it is null.

meta and links
{
  "links": {
    "first": "https://menu-item.com/api/v1/restaurants?page=1",
    "last": "https://menu-item.com/api/v1/restaurants?page=6",
    "prev": null,
    "next": "https://menu-item.com/api/v1/restaurants?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 6,
    "per_page": 25,
    "to": 25,
    "total": 146
  }
}

Restaurants

Venues discovered in the catalogue.

List restaurants

GET /api/v1/restaurants

Returns a paginated list of venues. Combine the filters below to narrow the catalogue; results default to alphabetical order. Each item carries the venue, its city and a menu_items_count.

Parameters

search string
Matches restaurant name or address (substring).
city string
City slug (e.g. "brugge").
type string
Google Places primary_type (e.g. "restaurant", "bar", "cafe").
has_menu boolean
Only venues with (true) or without (false) an extracted menu.
sort string
Sort field; prefix with "-" for descending. Allowed values: name, price_level, created_at. Default: name
per_page integer
Items per page (1–100). Default: 25
page integer
Default: 1
Request
curl "https://menu-item.com/api/v1/restaurants?city=brugge" \
  -H "Authorization: Bearer YOUR_TOKEN"
const params = new URLSearchParams({
  city: "brugge",
});

const response = await fetch(
  `https://menu-item.com/api/v1/restaurants?${params}`,
  {
    headers: { Authorization: "Bearer YOUR_TOKEN" },
  },
);

const data = await response.json();
use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://menu-item.com/api/',
]);

$response = $client->get('v1/restaurants', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'],
    'query' => [
        'city' => 'brugge',
    ],
]);

$data = json_decode((string) $response->getBody(), true);
import requests

response = requests.get(
    "https://menu-item.com/api/v1/restaurants",
    headers={"Authorization": "Bearer YOUR_TOKEN"},
    params={
        "city": "brugge",
    },
)

data = response.json()
Response · 200
{
  "data": [
    {
      "id": 42,
      "name": "De Gastro",
      "address": "Langestraat 15, 8000 Brugge",
      "latitude": 51.209,
      "longitude": 3.2247,
      "website": "https://degastro.be",
      "type": "restaurant",
      "price_level": 2,
      "city": {
        "name": "Brugge",
        "slug": "brugge",
        "country": "BE"
      },
      "menu_items_count": 48,
      "menu_document": {
        "id": 108,
        "source_url": "https://degastro.be/menukaart.pdf",
        "document_date": "2025-11-02",
        "status": "extracted",
        "extracted_at": "2025-11-04T09:12:00Z"
      }
    }
  ],
  "links": {
    "first": "https://menu-item.com/api/v1/restaurants?page=1",
    "last": "https://menu-item.com/api/v1/restaurants?page=6",
    "prev": null,
    "next": "https://menu-item.com/api/v1/restaurants?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 6,
    "per_page": 25,
    "to": 25,
    "total": 146
  }
}

Retrieve a restaurant

GET /api/v1/restaurants/{restaurant}

Returns a single venue with its full latest menu (menu), the source document and, for rated venues, a value-for-money score.

Parameters

restaurant integer required
Restaurant id.
Request
curl "https://menu-item.com/api/v1/restaurants/42" \
  -H "Authorization: Bearer YOUR_TOKEN"
const response = await fetch("https://menu-item.com/api/v1/restaurants/42", {
  headers: { Authorization: "Bearer YOUR_TOKEN" },
});

const data = await response.json();
use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://menu-item.com/api/',
]);

$response = $client->get('v1/restaurants/42', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'],
]);

$data = json_decode((string) $response->getBody(), true);
import requests

response = requests.get(
    "https://menu-item.com/api/v1/restaurants/42",
    headers={"Authorization": "Bearer YOUR_TOKEN"},
)

data = response.json()
Response · 200
{
  "data": {
    "id": 42,
    "name": "De Gastro",
    "address": "Langestraat 15, 8000 Brugge",
    "latitude": 51.209,
    "longitude": 3.2247,
    "website": "https://degastro.be",
    "type": "restaurant",
    "price_level": 2,
    "city": {
      "name": "Brugge",
      "slug": "brugge",
      "country": "BE"
    },
    "menu_document": {
      "id": 108,
      "source_url": "https://degastro.be/menukaart.pdf",
      "document_date": "2025-11-02",
      "status": "extracted",
      "extracted_at": "2025-11-04T09:12:00Z"
    },
    "score": {
      "quality_score": 4.32,
      "quality_basis": "bayesiaans gewogen op 300 reviews",
      "rating": 4.5,
      "ratings_count": 300,
      "avg_dish_price": 22.5,
      "value_index": 78,
      "value_label": "sterke prijs-kwaliteit",
      "price_position": "gemiddeld",
      "confidence": "hoog",
      "anomaly": null,
      "sample_size": 48,
      "disclaimer": "Indicatief en enkel gebaseerd op openbare Google-ratings en onze eigen menuprijzen. Geen oordeel over de echtheid van reviews."
    },
    "menu": [
      {
        "id": 5001,
        "section": "Voorgerechten",
        "name": "Tomatensoep met balletjes",
        "description": null,
        "price": 8.5,
        "currency": "EUR",
        "allergens": [
          "selderij"
        ],
        "dietary_tags": []
      },
      {
        "id": 5002,
        "section": "Hoofdgerechten",
        "name": "Vlaamse stoverij",
        "description": "met huisgemaakte friet",
        "price": 22,
        "currency": "EUR",
        "allergens": [
          "gluten"
        ],
        "dietary_tags": []
      }
    ]
  }
}

Dishes

Menu items across every restaurant; the search engine of dishes.

Search dishes

GET /api/v1/menu-items

Searches dishes across every restaurant: the search engine of dishes. Each hit carries the dish and a compact reference to its restaurant and city.

Parameters

dish string
Dish name (substring). Alias: name is also accepted.
name string
Alias for dish.
section string
Menu section (substring).
city string
City slug (e.g. "brugge").
min_price number
max_price number
dietary string
A single dietary tag the dish must carry (e.g. "vegetarian").
sort string
Allowed values: name, price, section. Default: name
per_page integer
Items per page (1–100). Default: 25
page integer
Default: 1
Request
curl "https://menu-item.com/api/v1/menu-items?dish=stoverij" \
  -H "Authorization: Bearer YOUR_TOKEN"
const params = new URLSearchParams({
  dish: "stoverij",
});

const response = await fetch(
  `https://menu-item.com/api/v1/menu-items?${params}`,
  {
    headers: { Authorization: "Bearer YOUR_TOKEN" },
  },
);

const data = await response.json();
use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://menu-item.com/api/',
]);

$response = $client->get('v1/menu-items', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'],
    'query' => [
        'dish' => 'stoverij',
    ],
]);

$data = json_decode((string) $response->getBody(), true);
import requests

response = requests.get(
    "https://menu-item.com/api/v1/menu-items",
    headers={"Authorization": "Bearer YOUR_TOKEN"},
    params={
        "dish": "stoverij",
    },
)

data = response.json()
Response · 200
{
  "data": [
    {
      "id": 5002,
      "section": "Hoofdgerechten",
      "name": "Vlaamse stoverij",
      "description": "met huisgemaakte friet",
      "price": 22,
      "currency": "EUR",
      "allergens": [
        "gluten"
      ],
      "dietary_tags": [],
      "restaurant": {
        "id": 42,
        "name": "De Gastro",
        "city": "brugge"
      }
    },
    {
      "id": 6110,
      "section": "Hoofdgerechten",
      "name": "Stoverij van 't huis",
      "description": null,
      "price": 24.5,
      "currency": "EUR",
      "allergens": [
        "gluten",
        "selderij"
      ],
      "dietary_tags": [],
      "restaurant": {
        "id": 57,
        "name": "Brasserie Nord",
        "city": "brugge"
      }
    }
  ],
  "links": {
    "first": "https://menu-item.com/api/v1/menu-items?page=1",
    "last": "https://menu-item.com/api/v1/menu-items?page=3",
    "prev": null,
    "next": "https://menu-item.com/api/v1/menu-items?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 3,
    "per_page": 25,
    "to": 25,
    "total": 61
  }
}

Dish price statistics

GET /api/v1/dishes/stats

Aggregates the price of a named dish across the region: sample size, average, min and max in EUR, plus up to ten of the cheapest examples. Scope it to a city with city.

Parameters

name string required
Dish name to aggregate on (2–100 chars).
city string
Optional city slug to scope the aggregate.
Request
curl "https://menu-item.com/api/v1/dishes/stats?name=spareribs&city=brugge" \
  -H "Authorization: Bearer YOUR_TOKEN"
const params = new URLSearchParams({
  name: "spareribs",
  city: "brugge",
});

const response = await fetch(
  `https://menu-item.com/api/v1/dishes/stats?${params}`,
  {
    headers: { Authorization: "Bearer YOUR_TOKEN" },
  },
);

const data = await response.json();
use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://menu-item.com/api/',
]);

$response = $client->get('v1/dishes/stats', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'],
    'query' => [
        'name' => 'spareribs',
        'city' => 'brugge',
    ],
]);

$data = json_decode((string) $response->getBody(), true);
import requests

response = requests.get(
    "https://menu-item.com/api/v1/dishes/stats",
    headers={"Authorization": "Bearer YOUR_TOKEN"},
    params={
        "name": "spareribs",
        "city": "brugge",
    },
)

data = response.json()
Response · 200
{
  "query": {
    "dish": "spareribs",
    "city": "brugge"
  },
  "sample_size": 11,
  "currency": "EUR",
  "average_price": 24.8,
  "min_price": 19.5,
  "max_price": 31,
  "examples": [
    {
      "name": "Spareribs",
      "price": 19.5,
      "restaurant": "'t Ribhuis",
      "city": "brugge"
    },
    {
      "name": "Spareribs (baby back)",
      "price": 22,
      "restaurant": "Brasserie Nord",
      "city": "brugge"
    }
  ]
}

Account

The authenticated token holder.

Current token holder

GET /api/user

Returns the account the bearer token belongs to. Handy as a health check to confirm a token is valid. Note this endpoint is unversioned: it sits at /api/user, not under /v1.

Request
curl "https://menu-item.com/api/user" \
  -H "Authorization: Bearer YOUR_TOKEN"
const response = await fetch("https://menu-item.com/api/user", {
  headers: { Authorization: "Bearer YOUR_TOKEN" },
});

const data = await response.json();
use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://menu-item.com/api/',
]);

$response = $client->get('user', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'],
]);

$data = json_decode((string) $response->getBody(), true);
import requests

response = requests.get(
    "https://menu-item.com/api/user",
    headers={"Authorization": "Bearer YOUR_TOKEN"},
)

data = response.json()
Response · 200
{
  "id": 1,
  "name": "Ada Lovelace",
  "email": "[email protected]"
}

Prefer a machine-readable contract? The full API is described in an OpenAPI 3.0 spec you can import into Postman, Insomnia or your code generator. Questions? Email [email protected].

← Back to menu item