Introduction
Local retail, in one API
Every night we read the websites of Madrid's independent stores and join the same product even when each store names it differently. The API gives you that clean catalogue: products with all their stores and prices, each store's profile and where every brand is sold.
What a search returns, in short
{
"products": [{
"name": "Muga Crianza",
"stores_count": 8,
"price": { "min": 16.11, "median": 19.7, "max": 21.5 }
}],
"results": [{
"store": { "name": "La Caja de Vinos",
"distance_km": 0.4 },
"price_eur": 19.5,
"listed_in_stock": true,
"captured_at": "2026-10-03",
"source_url": "https://lacajadevinos.com/…"
}, …]
}Getting started
Quickstart
- 1
Get a key
Request one on Request access. We review every request by hand and email you the key. The trial is free: 100 calls a month, no card. - 2
Keep it on your server
Put it in an environment variable such asLOVEO_KEY. Calls go from your backend: the key must never reach the browser. - 3
Make your first call
Search for a product near Puerta del Sol. Every result carries the store, the price, the date we read it (captured_at) and the source page (source_url).
export LOVEO_KEY="ld_…"curl -G https://data.loveoo.app/v1/products/search \
-H "Authorization: Bearer $LOVEO_KEY" \
--data-urlencode "q=muga crianza" \
-d lat=40.4169 \
-d lng=-3.7035 \
-d radius_km=5Getting started
Authentication
Send your key in the Authorization: Bearer <key> header or, if your tool prefers, in X-API-Key. Keys start with ld_.
You can create and revoke keys in your account. We show each key once, when you create it: we store its hash and cannot recover it. A request without a valid key returns 401.
GET /v1/products/prd_rf5v0qjf15 HTTP/1.1
Host: data.loveoo.app
Authorization: Bearer ld_…
# or
X-API-Key: ld_…Getting started
Pagination
Lists are paginated with a cursor. Every response carries next_cursor: pass it as cursor to get the next page. When it is null, there is nothing more.
limit sets results per page (10 by default, up to your plan's cap). A search returns at most 10 pages; to walk a whole catalogue, use the store catalogue or the CSV exports.
// A store's whole catalogue, page by page
const BASE = "https://data.loveoo.app";
const key = process.env.LOVEO_KEY;
const offers = [];
let cursor = null;
do {
const url = new URL("/v1/stores/str_6gqx689yzr/products", BASE);
url.searchParams.set("limit", "50");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
});
const page = await res.json();
offers.push(...page.results);
cursor = page.next_cursor;
} while (cursor);Getting started
Limits and plans
Each plan sets how many calls you can make per month and per minute, how many results each call returns and how many distinct stores you can query per month. The X-Quota-Limit and X-Quota-Remaining headers tell you what's left.
| Trial | Starter | Basic | Professional | |
|---|---|---|---|---|
| Calls / month | 100 | 10,000 | 50,000 | 250,000 |
| Calls / minute | 5 | 20 | 60 | 300 |
| Results / call | 10 | 25 | 50 | 50 |
| Stores / month | 50 | 300 | 2,000 | Unlimited |
| CSV rows / month | — | — | — | 50,000 |
| Tracked brands | — | — | — | 10 |
Stores / month: distinct stores that can appear in your responses each month. Enterprise: custom limits.
HTTP/1.1 200 OK
X-Quota-Limit: 10000
X-Quota-Remaining: 9874HTTP/1.1 429 Too Many Requests
Retry-After: 12
{ "error": { "code": "rate_limited",
"message": "too many calls per minute" } }Getting started
Errors
Errors use standard HTTP status codes and a JSON body with a stable code, meant for your code, and a readable message, meant for you.
| code | HTTP | When | What to do |
|---|---|---|---|
| invalid_query | 400 | A parameter is missing or out of range: no text or category, a radius over 5 km, text under 3 characters. | Read message: it says what is wrong. |
| unauthorized | 401 | The key is missing or invalid. | Check the header or create a new key. |
| forbidden | 403 | The account is suspended or the endpoint is not in your plan. | Write to us or change plan. |
| not_found | 404 | The ID does not exist or the store was removed. | Search for it again. |
| rate_limited | 429 | Too many calls per minute. | Wait the seconds in Retry-After. |
| quota_exceeded | 429 | You used up this month's calls. | Wait for next month or change plan. |
| store_limit_reached | 429 | You queried this month's maximum of distinct stores. | Wait for next month or change plan. |
HTTP/1.1 400 Bad Request
{
"error": {
"code": "invalid_query",
"message": "radius_km must be 5 or less"
}
}Getting started
Data and freshness
captured_atdate- The day we read that price on the store's website. We re-read each store every 7 to 30 days and publish changes every night.
source_urlstring- The product page on the store's own site: anyone can check the value.
price_eurnumber- In euros with VAT, as the store publishes it; the sale price when there is one.
listed_in_stockboolean- What the store lists as available, never live availability.
stores_countinteger- Distinct retailers selling the product: a chain's locations count once.
locations_countcounts physical stores. match_methodstring- How we joined the stores' listings, by the weakest link:
gtin(same barcode),mpn(brand and manufacturer part number),name(brand, title and format),llm(titles of one brand confirmed by an AI review),manual(reviewed by hand) orsingle(one store). Treat an unknown value likename. comparablebooleanfalsewhen shops sell different sizes or variants under one name (prices more than 1.6x apart): the product has no median and no price comparison.price_outlierboolean- On an offer: same barcode but priced as another format (a miniature, a case). Listed, but left out of the price stats.
idstring- Opaque, stable and never sequential, prefixed by type:
prd_,ofr_,str_,brd_. Do not parse them. name{ en, es }- Categories and other labels come in English and Spanish. The category may be
null: many stores publish none.
Getting started
Objects
Three objects appear in every response. A product groups offers from several stores; an offer is one store's listing.
idstring- Product ID (
prd_…). namestring- Product name.
brandobject | null- Brand
idandname. categoryobject | nullslugandnamein English and Spanish.editionstring | null- Edition or format when it sets it apart from other products.
stores_countinteger- Retailers selling it.
locations_countinteger- Physical stores selling it.
priceobjectmin,medianandmaxin euros: one price per retailer, its cheapest.medianisnullwhen the product is not comparable.comparableboolean- Whether the shops sell the same thing and its price can be compared.
{
"id": "prd_rf5v0qjf15",
"name": "Muga Crianza",
"brand": { "id": "brd_58jhqp8pj6", "name": "Muga" },
"category": { "slug": "food-drink",
"name": { "en": "Food & Drink", "es": "Comida y bebidas" } },
"edition": null,
"image_url": "https://…",
"stores_count": 8,
"locations_count": 13,
"price": { "min": 16.11, "median": 19.7, "max": 21.5 }
}Products
Search products
/v1/products/searchOffers matching a text near a point, sorted by relevance and then distance, plus the products they belong to. Every search needs a text or a category: no endpoint lists everything.
Query parameters
qstring- Text searched in the name and brand, 3 characters or more. Required unless you pass
category. latnumber- Latitude of the search centre. With
lng; or usecity. lngnumber- Longitude of the search centre.
radius_kmnumber- Radius in km around the point, up to 5. Defaults to 5.
citystring- City slug. Defaults to
madrid(metro area included). categorystring- Category slug, e.g.
food-drink. max_pricenumber- Maximum price in euros, VAT included, as published.
in_stockboolean- Only offers the store lists as available.
limitnumber- Results per page. 10 by default, up to your plan's cap.
cursorstring- The
next_cursorfrom the previous response.
curl -G https://data.loveoo.app/v1/products/search \
-H "Authorization: Bearer $LOVEO_KEY" \
--data-urlencode "q=muga crianza" \
-d lat=40.4169 \
-d lng=-3.7035 \
-d radius_km=5{
"products": [
{
"id": "prd_rf5v0qjf15",
"name": "Muga Crianza",
"brand": { "id": "brd_58jhqp8pj6", "name": "Muga" },
"category": { "slug": "food-drink",
"name": { "en": "Food & Drink", "es": "Comida y bebidas" } },
"edition": null,
"image_url": "https://vinopremier.com/…/1544760342.jpeg",
"stores_count": 8,
"locations_count": 13,
"price": { "min": 16.11, "median": 19.7, "max": 21.5 }
},
…
],
"results": [
{
"id": "ofr_0f8brqssfw",
"product_id": "prd_rf5v0qjf15",
"store": {
"id": "str_6gqx689yzr", "name": "La Caja de Vinos",
"district": "Centro", "city": "Madrid",
"lat": 40.4164, "lng": -3.7082, "distance_km": 0.4
},
"title": "Muga Crianza",
"price_eur": 19.5,
"listed_in_stock": true,
"captured_at": "2026-10-03",
"source_url": "https://lacajadevinos.com/?post_type=product&p=8514"
},
…
],
"total": 210,
"outside_radius": { "count": 3, "min_price": 18.5 },
"next_cursor": "eyJvIjogMTB9"
}Products
Retrieve a product
/v1/products/{product_id}A product across all stores: brand, category, edition, how many retailers sell it and its price range. match_method says how the stores' listings were joined, and other_editions links other sizes and vintages.
Path parameters
product_idstringrequired- The product ID, e.g.
prd_rf5v0qjf15.
curl https://data.loveoo.app/v1/products/prd_rf5v0qjf15 \
-H "Authorization: Bearer $LOVEO_KEY"{
"product": {
"id": "prd_rf5v0qjf15",
"name": "Muga Crianza",
"brand": { "id": "brd_58jhqp8pj6", "name": "Muga" },
"category": { "slug": "food-drink", "name": { … } },
"edition": null,
"image_url": "https://vinopremier.com/…/1544760342.jpeg",
"stores_count": 8,
"locations_count": 13,
"price": { "min": 16.11, "median": 19.7, "max": 21.5 }
},
"offers_count": 8,
"other_editions": [
{ "id": "prd_zmtrse0yhw", "name": "Muga Crianza 37,5 cl" },
…
],
"match_method": "name"
}Products
Compare prices
/v1/products/{product_id}/pricesEvery retailer selling a product in a city, cheapest first: one offer per retailer (its cheapest location) with its other locations, plus the min, median, max and spread in percent.
Path parameters
product_idstringrequired- The product ID, e.g.
prd_rf5v0qjf15.
Query parameters
citystring- City slug. Defaults to
madrid(metro area included).
curl -G https://data.loveoo.app/v1/products/prd_rf5v0qjf15/prices \
-H "Authorization: Bearer $LOVEO_KEY" \
-d city=madrid{
"product": { "id": "prd_rf5v0qjf15", "name": "Muga Crianza", … },
"summary": {
"stores": 8, "locations": 13,
"min": 16.11, "median": 19.7, "max": 21.5,
"spread_pct": 33, "listed_in_stock": 8,
"min_store": "Bodega Santa Cecilia",
"max_store": "Barolo Spirits"
},
"offers": [
{
"id": "ofr_y22q5dhsny",
"product_id": "prd_rf5v0qjf15",
"store": { "id": "str_k5ty877qjj", "name": "Bodega Santa Cecilia",
"district": "Chamberí", "city": "Madrid", … },
"title": "MUGA CRIANZA 2022",
"price_eur": 16.11,
"listed_in_stock": true,
"captured_at": "2026-10-02",
"source_url": "https://santacecilia.es/?post_type=product&p=99169",
"other_locations": []
},
…
]
}Products
Price history
/v1/products/{product_id}/historyA product's price over time at every retailer that sells it: one series per retailer and a point for each day we read a new price. Available for products sold by two or more retailers; history starts in June 2026.
Path parameters
product_idstringrequired- The product ID, e.g.
prd_rf5v0qjf15.
curl https://data.loveoo.app/v1/products/prd_rf5v0qjf15/history \
-H "Authorization: Bearer $LOVEO_KEY"{
"product": { "id": "prd_rf5v0qjf15", "name": "Muga Crianza", … },
"from": "2026-07-06",
"to": "2026-10-07",
"series": [
{
"store": { "id": "str_k5ty877qjj", "name": "Bodega Santa Cecilia" },
"points": [
{ "date": "2026-10-01", "price_eur": 16.11 }
]
},
{
"store": { "id": "str_b9bvtv0rm2", "name": "Bodegas Belmonte" },
"points": [
{ "date": "2026-08-03", "price_eur": 18.5 }
]
},
…
]
}Stores
Search stores
/v1/stores/searchStores by category or name, near a point, in a district or in a city, with their main category, product count and the distance when you pass a point.
Query parameters
qstring- Text searched in the store's name. Or use
category. categorystring- Main category slug, e.g.
food-drink. districtstring- District slug, e.g.
centroorsalamanca. latnumber- Latitude of the search centre. With
lng; or usecity. lngnumber- Longitude of the search centre.
radius_kmnumber- Radius in km around the point, up to 5. Defaults to 5.
limitnumber- Results per page. 10 by default, up to your plan's cap.
cursorstring- The
next_cursorfrom the previous response.
curl -G https://data.loveoo.app/v1/stores/search \
-H "Authorization: Bearer $LOVEO_KEY" \
-d category=food-drink \
-d district=centro{
"results": [
{
"id": "str_8f95k4gd7v",
"name": "El Rincón",
"district": "Centro",
"main_category": { "slug": "food-drink",
"name": { "en": "Food & Drink", "es": "Comida y bebidas" } },
"products_count": 311,
"distance_km": 0.1
},
…
],
"total": 17,
"next_cursor": "eyJvIjogM30"
}Stores
Retrieve a store
/v1/stores/{store_id}A store's public business profile and a summary of its catalogue: products with a price, how many it lists as available, average price, date of the last read and its main categories.
Path parameters
store_idstringrequired- The store ID, e.g.
str_6gqx689yzr.
curl https://data.loveoo.app/v1/stores/str_6gqx689yzr \
-H "Authorization: Bearer $LOVEO_KEY"{
"store": {
"id": "str_6gqx689yzr",
"name": "La Caja de Vinos",
"address": "Calle de San Felipe Neri, Madrid",
"district": "Centro", "city": "Madrid",
"lat": 40.4164, "lng": -3.7082,
"website": "https://lacajadevinos.com",
"main_category": { "slug": "food-drink", "name": { … } },
"distance_from_sol_km": 0.4
},
"catalogue": {
"products": 207,
"listed_in_stock": 201,
"avg_price": 23.18,
"last_captured_at": "2026-10-03",
"categories": [
{ "slug": "pantry",
"name": { "en": "Pantry", "es": "Despensa" }, "count": 46 }
]
}
}Stores
A store's catalogue
/v1/stores/{store_id}/productsThe products a store publishes, paginated and filterable by category. Each item is an offer, with its image and its category when the store publishes one.
Path parameters
store_idstringrequired- The store ID, e.g.
str_6gqx689yzr.
Query parameters
categorystring- Only products in this category slug.
limitnumber- Results per page. 10 by default, up to your plan's cap.
cursorstring- The
next_cursorfrom the previous response.
curl -G https://data.loveoo.app/v1/stores/str_6gqx689yzr/products \
-H "Authorization: Bearer $LOVEO_KEY" \
-d limit=2{
"results": [
{
"id": "ofr_r40whtks93",
"product_id": "prd_amv615e4v2",
"store": { "id": "str_6gqx689yzr", "name": "La Caja de Vinos", … },
"title": "DOMINIO DE ATAUTA",
"price_eur": 29.9,
"listed_in_stock": true,
"captured_at": "2026-08-02",
"source_url": "https://lacajadevinos.com/?post_type=product&p=13782",
"image_url": "https://lacajadevinos.com/…/DOMINIO-DE-ATAUTA.jpg",
"category": null
},
…
],
"total": 207,
"next_cursor": "eyJvIjogMn0"
}Brands
Search brands
/v1/brands/searchBrands whose name matches your text, with how many offers and stores carry them. No endpoint lists every brand.
Query parameters
qstringrequired- Text searched in the brand name, 2 characters or more.
limitnumber- Results per page. 10 by default, up to your plan's cap.
curl -G https://data.loveoo.app/v1/brands/search \
-H "Authorization: Bearer $LOVEO_KEY" \
-d q=cerave{
"results": [
{ "id": "brd_jnf30hasd7", "name": "CeraVe",
"offers": 82, "stores": 5 }
]
}Brands
Where a brand is sold
/v1/brands/{brand_id}Every store in the city that carries a brand: how many of its products each one has, how many it lists as available, its price range and when we read it. Plus totals, price spread and the split by district.
Path parameters
brand_idstringrequired- The brand ID, e.g.
brd_jnf30hasd7.
Query parameters
limitnumber- Results per page. 10 by default, up to your plan's cap.
cursorstring- The
next_cursorfrom the previous response.
curl -G https://data.loveoo.app/v1/brands/brd_jnf30hasd7 \
-H "Authorization: Bearer $LOVEO_KEY" \
-d limit=3{
"brand": { "id": "brd_jnf30hasd7", "name": "CeraVe" },
"offers": 82,
"listed_in_stock": 82,
"stores_count": 5,
"price": { "min": 5.95, "median": 17.92, "max": 27.95 },
"districts": [
{ "district": "Centro", "stores": 2, "offers": 47 },
{ "district": "Ciudad Lineal", "stores": 1, "offers": 15 },
…
],
"stores": [
{
"id": "str_nmvg6sx7br", "name": "Farma Smart",
"district": "Centro", "city": "Madrid",
"offers": 31, "listed_in_stock": 31,
"price": { "min": 6.0, "max": 26.95 },
"captured_at": "2026-10-06"
},
…
],
"next_cursor": "eyJvIjogM30"
}CSV exportsProfessional
Export a store's catalogue
/v1/exports/stores/{store_id}.csvA store's whole catalogue as CSV, one row per offer, optionally filtered by category. Rows count against your plan's monthly export quota; up to 20,000 rows per file.
Path parameters
store_idstringrequired- The store ID, e.g.
str_6gqx689yzr.
Query parameters
categorystring- Only products in this category slug.
curl https://data.loveoo.app/v1/exports/stores/str_6gqx689yzr.csv \
-H "Authorization: Bearer $LOVEO_KEY" \
-o la-caja-de-vinos.csvoffer_id,product_id,title,brand,category,store_id,store_name,district,city,price_eur,regular_price_eur,listed_in_stock,captured_at,source_url,gtin
ofr_r40whtks93,prd_amv615e4v2,DOMINIO DE ATAUTA,,,str_6gqx689yzr,La Caja de Vinos,Centro,Madrid,29.90,,true,2026-08-02,https://lacajadevinos.com/…,
ofr_pcfs6f4pez,prd_wfbd1a7eg2,DEMUERTE,,,str_6gqx689yzr,La Caja de Vinos,Centro,Madrid,8.90,,true,2026-05-08,https://lacajadevinos.com/…,
…CSV exportsProfessional
Export a brand's offers
/v1/exports/brands/{brand_id}.csvAll of a brand's offers in the city as CSV: store, price, published stock, read date, link and barcode when the store publishes it.
Path parameters
brand_idstringrequired- The brand ID, e.g.
brd_jnf30hasd7.
curl https://data.loveoo.app/v1/exports/brands/brd_jnf30hasd7.csv \
-H "Authorization: Bearer $LOVEO_KEY" \
-o cerave.csvoffer_id,product_id,title,brand,category,store_id,store_name,district,city,price_eur,regular_price_eur,listed_in_stock,captured_at,source_url,gtin
ofr_0ed4wcyd6b,prd_d65psgqjp1,CERAVE GEL LIMPIADOR ESPUMOSO 236 ML,CeraVe,beauty,str_nmvg6sx7br,Farma Smart,Centro,Madrid,10.71,,true,2026-10-02,https://www.farmasmart.es/…,
…Brand trackingProfessional
Tracked brands
/v1/watchesThe brands you track, since when and the date of their last change, with your plan's maximum.
No parameters.
curl https://data.loveoo.app/v1/watches \
-H "Authorization: Bearer $LOVEO_KEY"{
"watches": [
{ "brand": { "id": "brd_jnf30hasd7", "name": "CeraVe" },
"since": "2026-10-01", "last_alert": "2026-10-06" }
],
"limit": 10
}Brand trackingProfessional
Track a brand
/v1/watches/{brand_id}Every night we compare where the brand is sold with the night before and email you the changes: new stores, stores that drop it, minimum price moves of 2% or more, and stock that runs out or comes back. DELETE stops tracking.
Path parameters
brand_idstringrequired- The brand ID, e.g.
brd_jnf30hasd7.
curl -X PUT https://data.loveoo.app/v1/watches/brd_jnf30hasd7 \
-H "Authorization: Bearer $LOVEO_KEY"
# stop tracking
curl -X DELETE https://data.loveoo.app/v1/watches/brd_jnf30hasd7 \
-H "Authorization: Bearer $LOVEO_KEY"{ "brand_id": "brd_jnf30hasd7", "tracking": true }Brand trackingProfessional
A brand's changes
/v1/watches/{brand_id}/alertsThe changes found for a brand you track, newest first: one entry per night with changes.
Path parameters
brand_idstringrequired- The brand ID, e.g.
brd_jnf30hasd7.
Query parameters
limitnumber- How many nights with changes to return. 10 by default, up to 50.
curl https://data.loveoo.app/v1/watches/brd_jnf30hasd7/alerts \
-H "Authorization: Bearer $LOVEO_KEY"{
"brand_id": "brd_jnf30hasd7",
"alerts": [
{
"day": "2026-10-06",
"changes": [
{ "kind": "new_store", "store_id": "str_…", "store_name": "…",
"after": { "offers": 12, "min_price": 9.95 } },
{ "kind": "price_down", "store_id": "str_…", "store_name": "…",
"before": 17.5, "after": 15.95 }
]
}
]
}More
Changelog
Within v1 we only add fields and endpoints. Any breaking change will ship in a new version or be announced to customers 30 days ahead.
New fields
comparableon products andprice_outlieron offers. Different sizes and formats (bottle and magnum, booster and box) are no longer grouped as one product.A brand's median prices are rounded to cents.
New price history endpoint.
match_methodaddsmpnandllm: more products compared across stores.API v1 launch: products, stores, brands, CSV exports and brand tracking.
Missing something?
Write to hola@loveoo.app with what you want to build: people reply, and the API grows with what you ask for.