LOVEO Data

API v1 · REST · JSON

API documentation

Products, prices and published stock from 1,000+ independent stores in Madrid. One key, HTTP requests and JSON responses with the date and source of every value.

Base URL

https://data.loveoo.app/v1

  • Auth

    Bearer ld_…

  • Format

    JSON · UTF-8 · CSV

  • Refresh

    Every night

  • Coverage

    Madrid metro

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

GET /v1/products/search?q=muga crianza
{
  "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. 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. 2

    Keep it on your server

    Put it in an environment variable such as LOVEO_KEY. Calls go from your backend: the key must never reach the browser.
  3. 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).
Terminal
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=5

Getting 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.

Do not put the key in code that runs in a browser or a mobile app: anyone could read it and use up your quota. Call the API from your server.
Headers
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.

JavaScript
// 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.

TrialStarterBasicProfessional
Calls / month10010,00050,000250,000
Calls / minute52060300
Results / call10255050
Stores / month503002,000Unlimited
CSV rows / month———50,000
Tracked brands———10

Stores / month: distinct stores that can appear in your responses each month. Enterprise: custom limits.

We never charge for overage: at a limit, the API answers 429 until the next period or until you change plan. See pricing
Every response
HTTP/1.1 200 OK
X-Quota-Limit: 10000
X-Quota-Remaining: 9874
When you hit a limit
HTTP/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.

codeHTTPWhenWhat to do
invalid_query400A 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.
unauthorized401The key is missing or invalid.Check the header or create a new key.
forbidden403The account is suspended or the endpoint is not in your plan.Write to us or change plan.
not_found404The ID does not exist or the store was removed.Search for it again.
rate_limited429Too many calls per minute.Wait the seconds in Retry-After.
quota_exceeded429You used up this month's calls.Wait for next month or change plan.
store_limit_reached429You queried this month's maximum of distinct stores.Wait for next month or change plan.
Error response
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_count counts 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) or single (one store). Treat an unknown value like name.
comparableboolean
false when 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 id and name.
categoryobject | null
slug and name in 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.
priceobject
min, median and max in euros: one price per retailer, its cheapest. median is null when the product is not comparable.
comparableboolean
Whether the shops sell the same thing and its price can be compared.
Product
{
  "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

GET/v1/products/search

Offers 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 use city.
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_cursor from 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
Response200
{
  "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

GET/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"
Response200
{
  "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

GET/v1/products/{product_id}/prices

Every 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
Response200
{
  "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

GET/v1/products/{product_id}/history

A 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"
Response200
{
  "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

GET/v1/stores/search

Stores 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. centro or salamanca.
latnumber
Latitude of the search centre. With lng; or use city.
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_cursor from the previous response.
curl -G https://data.loveoo.app/v1/stores/search \
  -H "Authorization: Bearer $LOVEO_KEY" \
  -d category=food-drink \
  -d district=centro
Response200
{
  "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

GET/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"
Response200
{
  "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

GET/v1/stores/{store_id}/products

The 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_cursor from the previous response.
curl -G https://data.loveoo.app/v1/stores/str_6gqx689yzr/products \
  -H "Authorization: Bearer $LOVEO_KEY" \
  -d limit=2
Response200
{
  "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

GET/v1/brands/search

Brands 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
Response200
{
  "results": [
    { "id": "brd_jnf30hasd7", "name": "CeraVe",
      "offers": 82, "stores": 5 }
  ]
}

Brands

Where a brand is sold

GET/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_cursor from the previous response.
curl -G https://data.loveoo.app/v1/brands/brd_jnf30hasd7 \
  -H "Authorization: Bearer $LOVEO_KEY" \
  -d limit=3
Response200
{
  "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

GET/v1/exports/stores/{store_id}.csv

A 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.csv
Response200text/csv
offer_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

GET/v1/exports/brands/{brand_id}.csv

All 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.csv
Response200text/csv
offer_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

GET/v1/watches

The 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"
Response200
{
  "watches": [
    { "brand": { "id": "brd_jnf30hasd7", "name": "CeraVe" },
      "since": "2026-10-01", "last_alert": "2026-10-06" }
  ],
  "limit": 10
}

Brand trackingProfessional

Track a brand

PUT/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"
Response200
{ "brand_id": "brd_jnf30hasd7", "tracking": true }

Brand trackingProfessional

A brand's changes

GET/v1/watches/{brand_id}/alerts

The 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"
Response200
{
  "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.

  1. New fields comparable on products and price_outlier on offers. Different sizes and formats (bottle and magnum, booster and box) are no longer grouped as one product.

  2. A brand's median prices are rounded to cents.

  3. New price history endpoint. match_method adds mpn and llm: more products compared across stores.

  4. 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.

Request access