Search
GET /v1/search is the main read endpoint of the MealCP grocery product
catalogue API. A single query runs against every indexed retailer in scope and
returns normalized product records - name, brand, pack size, latest observed
price, unit-normalized price, category, tags, and the retailer’s own product
page. You need an API key, and each request costs 1 credit.
When to use it
Section titled “When to use it”- Price comparison: find the cheapest offer for one product across every
chain in a country, with
unit_price_*fields making different pack sizes directly comparable. - Basket and recipe cost estimates: search each ingredient once, then sum
the returned
latest_pricevalues. - Catalogue enrichment: pull structured names, brands, pack sizes, categories, and tags into your own food or inventory database.
- AI agents: give an LLM one tool call that answers “what does this cost, where, and in what size?” - one integration instead of per-retailer ones.
How it works
Section titled “How it works”Keyword search across the product catalog with faceted filtering. Requires an
API key. Queries match retailer-language product names - search what the
store prints on the shelf (Finnish for FI, Hungarian for HU, …):
curl -H "X-API-Key: $MEALCP_API_KEY" \ "https://api.mealcp.com/v1/search?q=hapanjuurileip%C3%A4&country=FI&sort=unit_price_asc"The query above finds sourdough bread in Finland and sorts it by price per kilogram, cheapest first. A few behaviors worth knowing before you build:
- Matching is against retailer names. MealCP stores the product name as
the retailer prints it, so a query should use the local word (
tejfor milk in Hungary, notmilk). - All filters combine with AND. Adding
country,retailer,brand,category,tag, and price bounds narrows the same query further. - An empty
qis browse mode. With no query text the endpoint returns the full filtered catalogue, sorted by your chosensort- useful for category listings or building paginated catalog views. - Facets come back with every result, so you can render filter options with counts without extra calls.
Parameters
Section titled “Parameters”| Param | Type | Default | Description |
|---|---|---|---|
q |
string | "" |
Free-text query; empty returns all (sorted) |
country |
string | - | ISO 3166-1 alpha-2, e.g. HU |
retailer |
string | - | Retailer slug |
chain |
string | - | Chain slug |
brand |
string | - | Brand name |
tag |
string | - | Tag slug (see Tags) |
category |
string | - | Category slug at any level (L1/L2/L3); matches the whole subtree |
city |
string | - | Store city |
currency |
string | - | ISO 4217, e.g. HUF |
min_price |
number | - | Lower price bound |
max_price |
number | - | Upper price bound |
sort |
string | relevance |
relevance, newest, oldest, price_asc, price_desc, unit_price_asc, unit_price_desc |
page |
int | 1 |
1-based page number |
page_size |
int | 24 |
1–100 |
Notes on the two sort families:
price_*sorts by the shelf price as printed, which only compares fairly when pack sizes match.unit_price_*sorts by normalized price per liter or kilogram, so a 0.5 l carton and a 1 l carton compete on equal terms. Prefer these for “cheapest X” queries.newest/oldestorder bylatest_observed_at, the freshness of the most recent price observation.
Pagination is page-based: request page=2&page_size=100 to keep walking the
result set. The found field tells you when to stop. Parameter values do not
change the cost - every search is 1 credit regardless of page_size (see
Credits & limits).
Response
Section titled “Response”Live response (hapanjuurileipä = sourdough bread in Finnish; trimmed to
two hits and one facet):
{ "query": "hapanjuurileipä", "found": 3, "page": 1, "page_size": 24, "hits": [ { "id": "rp_01a0145398d0776c802124defe1b2cac", "name": "Fazer Bistro Hapanjuurileipä 600g", "brand": "Fazer", "country_code": "FI", "retailer_slug": "s-kaupat-fi", "chain_slug": "s-ryhma", "quantity_value": 600.0, "quantity_unit": "g", "latest_price": 3.79, "latest_unit_price": 6.32, "latest_unit_price_uom": "kg", "currency": "EUR", "latest_observed_at": "2026-08-07T07:00:05Z", "tags": [], "category_slug": "bread/packaged", "category_path": ["bakery", "bread", "bread/packaged"], "store_cities": [], "url": "https://www.s-kaupat.fi/tuote/fazer-bistro-hapanjuurileipa-600g/6413467539305", "image_url": "https://cdn.s-cloud.fi/v1/w400h400@_q75/assets/dam-id/5gjL1TEn41c8-6Yz2XXisL.webp" }, { "id": "rp_01a0145398cf746e9f384a2da03daf81", "name": "Fazer Leipurit vaalea hapanjuurileipä 460 g", "brand": "Fazer Leipurit", "country_code": "FI", "retailer_slug": "s-kaupat-fi", "chain_slug": "s-ryhma", "quantity_value": 460.0, "quantity_unit": "g", "latest_price": 4.27, "latest_unit_price": 9.28, "latest_unit_price_uom": "kg", "currency": "EUR", "latest_observed_at": "2026-08-07T07:00:05Z", "tags": [], "category_slug": "bread/instore-fresh", "category_path": ["bakery", "bread", "bread/instore-fresh"], "store_cities": [], "url": "https://www.s-kaupat.fi/tuote/fazer-leipurit-vaalea-hapanjuurileipa-460-g/6413467458804", "image_url": "https://cdn.s-cloud.fi/v1/w400h400@_q75/assets/dam-id/AbdGNBehq6KA5nXbOJ9Cir.webp" } ], "facets": [ { "field": "brand", "counts": [ { "value": "Fria", "count": 1 }, { "value": "Fazer", "count": 1 }, { "value": "Fazer Leipurit", "count": 1 } ] } ]}found is the total match count across all pages - not the number of hits
returned. Each entry in facets carries one filterable field’s value counts
(retailer_slug, chain_slug, brand, category_slug, tags, …).
A common UI loop with facets: render the returned facet values as filter
chips with their counts, let the user pick one, and re-run the same search
with the chosen value as the matching parameter (?brand=Fazer). Counts
always reflect the current result set, so chips update as filters stack.
Hit fields
Section titled “Hit fields”| Field | Description |
|---|---|
id |
Opaque product id (rp_ + 32 hex) |
name, brand |
Product display name; brand, null if none |
country_code |
Country the hit belongs to |
retailer_slug, chain_slug |
Where the product is sold |
quantity_value, quantity_unit |
Pack size, e.g. 600 / g |
latest_price |
Most recent observed price, in currency |
latest_unit_price, latest_unit_price_uom |
Price per unit, e.g. 6.32 / kg |
latest_observed_at |
RFC 3339 timestamp of the latest observation |
tags |
Tag slugs (dietary, allergen, …) |
category_slug, category_path |
Deepest category; full path from L1 root |
store_cities |
Cities where the product has been observed |
url, image_url |
Retailer product page; product image |
Every latest_* field is a snapshot of the most recent observation for that
product - prices refresh from nightly retailer runs, so check
latest_observed_at when freshness matters. Pass a hit’s id to
price history when you need more than the latest value.
Product lookup
Section titled “Product lookup”curl -H "X-API-Key: $MEALCP_API_KEY" \ "https://api.mealcp.com/v1/products/rp_01a0145398d0776c802124defe1b2cac"GET /v1/products/{product_id} returns a single hit in the exact same shape
as hits[] above. The id must match rp_ + 32 hex chars - take it verbatim
from a search hit’s id.
Use the lookup when you already hold an id and need the full, current record: re-validating a stored product before showing it, refreshing a cached detail page, or hydrating a price-history view with the product’s name, pack size, and image. It costs the same 1 credit as a search and never accepts partially typed or hand-built ids.
Errors
Section titled “Errors”Search uses the shared error envelope - the HTTP status tells you what went wrong and what to do next:
400- semantic parameter error (e.g.min_priceexceedsmax_price)401- missing/invalid API key402- monthly credit cap exhausted422- invalid parameters, with anerror.detailsarray naming each issue:429- rate limit exceeded (seeRetry-Afterheader)503- search service temporarily unavailable
{ "error": { "code": "validation_error", "message": "request validation failed", "details": [ { "loc": ["query", "page"], "msg": "Input should be greater than or equal to 1", "type": "greater_than_equal" } ] }}For 429, wait the number of seconds given in Retry-After before retrying.
For 402, check window_resets_at via GET /v1/me and schedule
the next batch after the balance refills. Retry 503 with backoff - the
search service is temporarily unavailable, and other endpoints may still be
serving.