API Reference

The Irish grocery shelf, as an API.

Aldi, Tesco, SuperValu and Dunnes — catalogue, shelf prices, daily movers, promotions and category analytics. JSON or CSV, refreshed nightly, one key.

4retailers
~50kSKUs / night
15datasets
JSON · CSVformats
 GET /api/tesco/products
{
  "store": "tesco",
  "scrape_date": "2026-06-29",
  "products": [
    {
      "name": "Avonmore Super Milk 1L",
      "price": 1.49,
      "unit_price": 1.49,
      "unit_basis": "per L"
    }
  ]
}

Try it without a key

Every endpoint below answers unauthenticated requests with the first 50 rows, so you can check the data shape, the field names and how fresh it is before you talk to anyone. No sign-up, no key, no card. Paste this into a terminal:

Request · no key needed
curl "https://basketwatchireland.com/api/tesco/products?limit=5"
What the free trial covers. Per-SKU datasets return 50 rows and cannot be paged past that. Summary endpoints (counts, KPIs, category analytics) and single-product lookups return in full. CSV exports, barcodes and the barcode lookup, product copy (description, ingredients, nutrition), pack, own_label, match_id, brand_canonical, pmp and the complete row sets need a key.

Authenticating

When you want the full dataset, send your key on every request as an X-API-Key header (preferred) or an ?api_key= query parameter. Keep it private.

Request · full access
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/products?limit=5"
How access is priced. API access is metered per record returned, from €0.001, bought as prepaid credit. One record returned costs one credit (a barcode lookup is one credit per barcode found), requests that return no rows are free, and every response reports what it cost (X-Credits-Charged) and what is left (X-Credits-Remaining). Check your balance any time with GET /api/credits. See pricing and buy credits, from €49.99 with no upper limit, or book a demo.

Buying access as an agent

An autonomous agent can buy its own credits and start querying, with no human, no browser and no card entry. BasketWatch accepts machine payments over MPP, settling by card through Stripe shared payment tokens.

1 · discover what is on sale
curl "https://basketwatchireland.com/api/credits/mpp"

Free and unauthenticated: an agent that cannot read the price without paying cannot decide whether to buy. Returns the packs, the per-record rate and the purchase URL.

2 · ask to buy, with no credential
curl -X POST "https://basketwatchireland.com/api/credits/mpp/buy?pack=4999"

Answers 402 Payment Required with a WWW-Authenticate: Payment challenge naming the stripe method and charge intent. The request parameter carries the amount in minor units and the networkId your shared payment token must be scoped to. pack is in euro cents, so 4999 is the €49.99 pack and 99999 the €999.99 one. Every pack converts at the same €0.001 per record as the card packs on the pricing page.

3 · pay and retry
curl -X POST "https://basketwatchireland.com/api/credits/mpp/buy?pack=4999" \
  -H "Authorization: Payment <base64url(credential)>"

The credential echoes the challenge back intact alongside your token, as {"challenge": {...}, "payload": {"spt": "spt_..."}}. A successful purchase returns a funded api_key, the credits added and the balance. Send that key as X-API-Key on every request from then on.

Two things worth knowing. The challenge id is an HMAC over the challenge contents, so a payment presented against edited terms is refused: you cannot rewrite the amount and replay it. And the charge is idempotent on your token, so a retried request after a timeout settles to the same PaymentIntent rather than charging the card twice. Send X-API-Key with the purchase to top up a key you already hold; omit it and a new metered key is minted and returned.

Agents holding USDC can pay over x402 instead, at /api/credits/x402, where that door is enabled. Both doors are advertised in the 402 you receive when your balance runs out, and the service is described for machines at /llms.txt.

Using BasketWatch from an AI assistant (MCP)

If you work in Claude Desktop, Claude Code, Cursor or any other MCP client, you can ask questions about Irish grocery prices in plain language and have the assistant call this API for you. The server runs locally against your own key, so nothing is proxied through us and your credits meter exactly as they do on a direct request.

Configuration · claude_desktop_config.json
{
  "mcpServers": {
    "basketwatch": {
      "command": "python",
      "args": ["-m", "basketwatch_mcp"],
      "env": {
        "BASKETWATCH_API_BASE": "https://basketwatchireland.com",
        "BASKETWATCH_API_KEY": "YOUR_KEY",
        "BASKETWATCH_MCP_MAX_ROWS": "200"
      }
    }
  }
}

Seven tools are exposed: search_products, compare_price_across_stores, get_promotions, recent_price_changes, newly_added_products, removed_products and list_products. Then you simply ask:

  • “Which supermarket is cheapest for Barry’s Tea 80 bags today?”
  • “What went up in price at Dunnes yesterday, and was it a shelf rise or an offer ending?”
  • “List everything on promotion at SuperValu right now under €2.”
Your credit pack works here. There is no separate MCP plan, no surcharge and no second balance. A pack bought on the pricing page, or by an agent over MPP, is the same balance whether the records are pulled by curl, your own code or your assistant calling a tool. One record returned costs one credit, calls that return no rows are free, and GET /api/credits shows the balance and ledger at no charge.
Result sets are capped. Each tool returns at most BASKETWATCH_MCP_MAX_ROWS rows per call, 200 by default, which keeps an assistant’s answers fast and your spend predictable when a model calls a tool repeatedly. Raise it for longer answers, or use the HTTP API directly for bulk work, where limit and offset page the full dataset. Without a key you still get a live 50-row sample per call, so you can try the tools before buying anything.

Conventions

  • One path shape: per store. Every dataset is /api/{store}/<dataset>, where {store} is aldi, tesco, supervalu or dunnes — the same path shape for all four. A few datasets are specific to one retailer (promotions and out-of-stock, specialbuys); these are flagged on the cards below.
  • CSV. Append .csv to any dataset for a CSV download (e.g. /api/dunnes/products.csv), ready for Excel, Power Query or pandas.
  • Snapshot date. Every response includes scrape_date (the nightly snapshot the values come from).
  • Money. Prices are euro decimals; unit_price is per kg or per litre with unit_basis naming the unit.
  • Pagination. limit + offset page large results (default limit 100); q free-text filters name/brand. limit is clamped, not validated: an over-large value returns the cap rather than an error, so when paging, compare against your effective page size rather than assuming a short page means the end of the data.
  • Search. ?q= is a relevance-ranked full-text search over product name and brand, best match first. It is token-based rather than a literal substring, so word order does not matter and hyphens and accents are folded: coca cola finds Coca-Cola, and Moët and Moet are the same query. Pack sizes in the query are normalised too, so 2l, 2 litre and 2ltr all match the same products however the retailer spelled it. Adding ?sort=, ?category= or a historical ?on= switches to a filtered scan instead, because those override relevance ordering.
  • What counts as a row. The unit limit counts depends on the request. Plain /products: one row per SKU, cap 100,000. With ?fields=: one row per SKU, cap 25,000. With ?from=/to=: one row per SKU per collection day, cap 50,000. So limit=1000 on a month-long range may return 33 products across 30 days, not 1,000 products.
  • Dates. Three ways to ask. on=YYYY-MM-DD is a single snapshot day. days=N is a lookback ending today. from=YYYY-MM-DD&to=YYYY-MM-DD is an explicit range, both ends inclusive, and either bound may be given alone. Ranges are capped at 400 days, and a malformed or inverted range returns 400 rather than being silently ignored.
  • A range on the catalogue returns a series. Without dates, /api/{store}/products is one row per SKU. With from=/to= it is one row per SKU per collection day, ordered by id then date so each SKU stays contiguous and pages cannot skip rows. Filters, ?fields= and the CSV form all still apply. A month of Tesco is roughly 675,000 rows, so page it: with a range the limit caps at 50,000.
  • Product copy, barcodes and identity. Catalogue endpoints return prices by default. Add ?fields= to attach the rest of what we hold per SKU: description, ingredients, regulated_name, abv, nutrition, product_type, pack, own_label, match_id, brand_canonical, pmp. (barcodes is returned by default and does not need requesting.) Comma-separated, any combination; an unrecognised name returns 400 rather than being ignored.
  • pack: structured size. Pack size arrives from retailers as free text, and the same size is spelled many ways: Coca-Cola at one retailer alone appears as 2ltr, 2 Litre and 2L. Multipacks frequently state the volume only in the product name (24 x 330ml) while pack_size says 12 Pack. ?fields=pack returns a parsed object — unit_count, unit_size, unit_measure, total_ml, total_g — so you can compute €/litre or €/kg without writing your own parser. A size we cannot read returns nulls rather than a guess: a wrong volume silently corrupts every derived figure, so we would rather tell you we do not know.
  • match_id: the same product across retailers. The identifier of the cross-retailer match group this SKU belongs to, or null if it is unmatched. Join on it to line the same product up across banners instead of rebuilding the matching yourself. About half of the live catalogue is matched; most groups are joined on barcode, so barcodes and match_id are two views of the same work.
  • brand_canonical: a grouping key for the brand. The same brand reaches us spelled several ways because each retailer types it its own way: JACOB'S / JACOBS / Jacob's, NESTLE / NESTLÉ, Dr. Oetker / DR OETKER. Across the live catalogue 1,092 brands carry two or more spellings, so brand-level aggregation is wrong until they are folded together. This field is a normalised KEY for that (lower case, accents stripped, punctuation and spaces removed), not a display name: choosing a prettiest spelling would mean deciding which retailer's rendering is correct, so brand keeps the raw string exactly as published. It folds typography, not identity, so it will not merge Coke with Coca-Cola; that needs a curated synonym table rather than a rule.
  • pmp: price-marked packs. A pack printed with a price on the wrapper. Retailers put it in the product name rather than any structured field (Lucozade Sport 750ml PMP€2.50), so this parses it into is_pmp and marked_price. Small but real: 27 products live, and only 12 state the amount. The rest are a bare PMP with the figure dropped, so marked_price is null while is_pmp stays true; they are price-marked, we simply do not know at what.
  • own_label: a floor, not a census. True when the product's brand is a known retailer own-label brand, checked against the same curated list our matcher uses. Read it as a lower bound: it is accurate where a retailer brands own label under its own name, and it under-reports where a retailer runs many separate house marques. Measured against the live catalogue it flags 23.6% of Tesco and 28.3% of Dunnes, but only 33.9% of Aldi — whose true own-label share is far higher, because most of its marques are not on the list. We would rather understate it than infer true for every brand we do not recognise.
  • Barcodes are returned by default. Every catalogue row carries barcodes, the GTIN/EAN codes we hold for that SKU, as a list of strings. You do not need ?fields= for it: a barcode is the join key for the whole dataset, so it is identity rather than an optional extra. A product can carry more than one (7.4% do, and a handful carry many more), and a product with no code we have found returns [], not null. It never changes the row count: you still get one row per SKU, so limit and offset page exactly as they would otherwise, and asking for it explicitly does not reduce your page size. Coverage: Dunnes 99.7%, SuperValu 98.3%, Tesco 96.0%, Aldi 68.9%. Aldi used to sit near zero because it publishes no codes in its catalogue; its own-label range has since been resolved against Aldi's own barcode lookup, so most of the Aldi shelf now carries a confirmed code. Note those are Aldi own-brand codes, which no other retailer sells, so they identify the product rather than bridge it to a rival. These are the same codes our cross-retailer matcher joins on, so you can reproduce or extend the matching yourself. To go the other way, from a code to its products at every retailer, use the barcode lookup. Barcodes need a key: the keyless trial returns prices and pack data only.

Stores: alditescosupervaludunnes

Catalogue & search

GET /api/{store}/products

Full catalogue with the current shelf price per SKU. Add ?q= to filter, ?limit=&offset= to page, ?fields= to attach product copy, and ?from=&to= to get every day in a range rather than a single snapshot.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/products?limit=1"
Response
[
  {
    "id": "tesco:320395135",
    "name": "Biona Organic Rye Sesame Crispbread 200g",
    "brand": "BIONA",
    "category_path": "Food Cupboard > Crackers, Rice Cakes & Breadsticks",
    "url": "https://www.tesco.ie/shop/en-IE/products/320395135",
    "pack_size": "200g",
    "first_seen": "2026-05-27",
    "last_seen": "2026-06-29",
    "scrape_date": "2026-06-29",
    "price": 2.75,
    "unit_price": 13.75,
    "unit_basis": "1 KG",
    "clubcard_price": null,
    "offer_text": null,
    "available": 1,
    "removed": 0
  }
]
GET /api/{store}/products?fields=pack,own_label,match_id

Structured pack size, own-label flag and the cross-retailer match id, for building a comparable panel.

Response
[
  {
    "id": "tesco:315853194",
    "name": "Coca-Cola Zero Sugar Soft Drink 2 Litre",
    "price": 2.85,
    "pack": {
      "unit_count": null, "unit_size": 2000.0, "unit_measure": "ml",
      "total_ml": 2000.0, "total_g": null
    },
    "own_label": false,
    "match_id": 1454102
  },
  {
    "id": "tesco:315469891",
    "name": "Coca-Cola Original Taste Soft Drink 24 x 330ml",
    "price": 15.00,
    "pack": {
      "unit_count": 24, "unit_size": 330.0, "unit_measure": "ml",
      "total_ml": 7920.0, "total_g": null
    },
    "own_label": false,
    "match_id": null
  }
]
GET /api/{store}/products

GTIN/EAN codes per SKU, as an array. Join on these to match the same product across retailers, or against your own product master. To go from a code to the product, use the barcode lookup.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/products?limit=2"
Response
[
  {
    "id": "tesco:254656868",
    "name": "Tesco Butter 227G",
    "price": 2.35,
    "barcodes": ["5099839718014"]
  },
  {
    "id": "tesco:301225533",
    "name": "Heinz Tomato Ketchup 910G",
    "price": 4.50,
    "barcodes": ["5000157024671", "0000050157024"]
  }
]
GET /api/{store}/products?fields=description,ingredients

Product copy in bulk: descriptions, ingredient declarations, the EU-1169 regulated name, ABV, per-100g nutrition and our product type, attached to the catalogue rows rather than fetched one SKU at a time. This is the catalogue route with a query string, not a separate endpoint, so every catalogue parameter still applies: q, category, on, from/to, paging and the CSV form.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/products?fields=description,ingredients,nutrition&limit=1"
Response
[
  {
    "id": "tesco:320395135",
    "name": "Biona Organic Rye Sesame Crispbread 200g",
    "price": 2.75,
    "description": "Organic Rye Sesame Crispbread",
    "ingredients": "Wholegrain Rye Flour*(86.5%), Sesame Seeds* (8.7%), Sea Salt, *= Certified Organic Ingredients",
    "nutrition": { "energy": 1570, "fat": 1.2, "protein": 9.4 }
  }
]
  • One request per store. A whole catalogue comes back in a single call: the page cap with fields= is 25,000 rows and the largest store is about 22,000 SKUs. Copy is heavy, so size the request accordingly (Tesco with description and ingredients is roughly 10MB, Dunnes nearer 40MB because its descriptions run long); limit and offset page it down if you would rather pull in chunks.
  • Add a date range for copy over time. ?fields=description,ingredients&from=2026-08-01&to=2026-08-31 returns each SKU once per collection day with its copy attached, so you can track a price series and the label text together. The copy itself is static and repeats on every row of a SKU; only the price fields move.
  • Coverage is not 100%. Retailers publish this copy unevenly. Descriptions: Dunnes 93%, SuperValu 90%, Tesco 83%, Aldi 52%. Ingredients: roughly two thirds of the catalogue, and only ~21% of Aldi. Nutrition is SuperValu/Dunnes only; ABV is effectively so (a handful of Aldi rows, none for Tesco). Absent values come back as null, never as an error.
  • Nutrition is a per-100g/ml object (energy in kJ, fat, carbs, sugar, protein, salt in g).
GET /api/{store}/price-history

Every price observation per SKU across a date range: one row per product per collection day. This is the time series behind the single-day snapshot that on= returns, and the endpoint to use for trend, elasticity or promo-cadence work.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/price-history?from=2026-08-01&to=2026-08-31&q=milk"
Response
[
  {
    "id": "tesco:320395135",
    "name": "Biona Organic Rye Sesame Crispbread 200g",
    "brand": "BIONA",
    "category_path": "Food Cupboard > Crackers, Rice Cakes & Breadsticks",
    "pack_size": "200g",
    "scrape_date": "2026-08-01",
    "price": 2.79,
    "unit_price": 13.95,
    "unit_basis": "1 KG",
    "clubcard_price": null,
    "offer_text": null,
    "was_price": null,
    "offer_valid": null,
    "available": 1
  },
  { "id": "tesco:320395135", "scrape_date": "2026-08-02", "price": 2.75 }
]
  • Narrow it. q= (name or brand), category=, or ids= with a comma-separated list of product ids. Unfiltered, a month of Tesco is around 675,000 rows, so page it with limit (default 1,000, maximum 50,000) and offset.
  • Ordering. Rows come back ordered by product id, then date, so each SKU's series stays contiguous and paging cannot skip or repeat a row.
  • Default span. With no from, the last 30 days ending at to (or today).
GET /api/{store}/products/count

Fast catalogue size for a store, without returning rows.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/dunnes/products/count"
Response
{ "total": 15069 }
GET /api/product/{product_id}

A single product: current price, unit price, pack, plus its full price history. With a key the row also carries the product copy (description, ingredients, regulated_name, abv, nutrition, product_type) and its barcodes; the keyless trial returns the catalogue fields and history only, and only for the products in the 50-row sample, so the lookup can be tried without being used to read the catalogue one product at a time. For copy across many SKUs use ?fields= on the catalogue route rather than looping here.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/product/tesco:320395135"
Response
{
  "product": {
    "id": "tesco:320395135",
    "name": "Biona Organic Rye Sesame Crispbread 200g",
    "brand": "BIONA",
    "url": "https://www.tesco.ie/shop/en-IE/products/320395135",
    "image_url": "https://digitalcontent.api.tesco.com/v2/media/ghs/.../1014470272.jpeg",
    "category_path": "Food Cupboard > Crackers, Rice Cakes & Breadsticks",
    "pack_size": "200g",
    "pack_qty": 200.0,
    "pack_unit": "G",
    "first_seen": "2026-05-27",
    "last_seen": "2026-06-29",
    "description": "Organic Rye Sesame Crispbread",
    "ingredients": "Wholegrain Rye Flour*(86.5%), Sesame Seeds* (8.7%), Sea Salt, *= Certified Organic Ingredients",
    "regulated_name": "Organic Rye Sesame Crispbread",
    "abv": null,
    "nutrition": null,
    "product_type": "other"
  },
  "history": [
    { "scrape_date": "2026-05-27", "price": 2.79 },
    { "scrape_date": "2026-06-29", "price": 2.75 }
  ]
}
GET /api/barcode/{code}

Look a barcode up: every product carrying that GTIN, EAN or UPC code, across all four retailers in one call, in the same row shape as the catalogue. Send the code as printed or scanned: spaces, hyphens and leading zeros are ignored, so 05000127014084, 5000127014084 and the 12-digit UPC-A form of a code all find the same products, and a code this API returned round-trips. ?store= narrows the search to one retailer and ?fields= attaches the product copy exactly as on the catalogue (ingredients, nutrition and the rest; nutrition is null where we do not hold it, which is currently every Tesco product). Live listings come first, in store order. A product its retailer has since delisted is still returned, flagged removed: 1, because its identity data still stands. Each row's matched_barcodes says which code found it. Billed per barcode found, not per row: a lookup costs one credit whether the product is listed at one retailer or all four, and a code we do not hold returns found: false and costs nothing.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/barcode/5000127014084?fields=ingredients,nutrition"
Response
{
  "barcode": "5000127014084",
  "found": true,
  "products": [
    {
      "store": "tesco",
      "id": "tesco:253158380",
      "matched_barcodes": ["5000127014084"],
      "name": "Kellogg's Corn Flakes Breakfast Cereal 1kg",
      "brand": "Kellogg's",
      "pack_size": "1kg",
      "price": 4.99,
      "removed": 0,
      "barcodes": ["5000127014084"],
      "ingredients": "Maize, Barley Malt Extract, Sugar, Salt, Niacin, Iron, Vitamin B6, Riboflavin, Thiamin, Folic Acid, Vitamin D, Vitamin B12",
      "nutrition": null
    },
    {
      "store": "supervalu",
      "id": "supervalu:1011636000",
      "matched_barcodes": ["5000127014084"],
      "name": "Kellogg's Corn Flakes Cereal (1 kg)",
      "brand": "Corn Flakes",
      "pack_size": "1 kg",
      "price": 5.25,
      "removed": 0,
      "barcodes": ["5000127014084", "5013503014110"],
      "ingredients": "Maize, BARLEY Malt Extract, Sugar, Salt, Niacin, Iron, Vitamin B6, Riboflavin, Thiamin, Folic Acid, Vitamin D, Vitamin B12",
      "nutrition": { "energy": 1604.0, "fat": 0.9, "carbs": 84.0, "sugar": 8.0, "protein": 7.0, "salt": 1.1 }
    },
    {
      "store": "dunnes",
      "id": "dunnes:100114495",
      "matched_barcodes": ["5000127014084"],
      "name": "Kellogg's Corn Flakes Breakfast Cereal 1kg",
      "price": 4.99,
      "removed": 0
    }
  ]
}

Rows carry the catalogue's other fields too (unit_price, offer_text, clubcard_price, url, scrape_date and so on); the example is trimmed for length.

GET /api/barcodes?codes={code},{code},...

The same lookup for a list: up to 100 codes per request, comma-separated. Every product found comes back once in products, with matched_barcodes listing the codes it carries; codes we do not hold are listed in not_found and anything that is not a barcode in invalid, so a list can be checked for coverage in one call. ?store= and ?fields= work as above. Billing is one credit per code found (the matched count), however many retailers list each product; the misses and invalid codes in a list are free.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/barcodes?codes=5000127014084,5000157024671,5099999999990"
Response
{
  "requested": 3,
  "valid": 3,
  "matched": 2,
  "products": [
    { "store": "tesco", "id": "tesco:252261477", "matched_barcodes": ["5000157024671"], "name": "Heinz Beans in Tomato Sauce 415g" },
    { "store": "tesco", "id": "tesco:253158380", "matched_barcodes": ["5000127014084"], "name": "Kellogg's Corn Flakes Breakfast Cereal 1kg" },
    { "store": "supervalu", "id": "supervalu:1011892000", "matched_barcodes": ["5000157024671"], "name": "Heinz Baked Beans (415 g)" },
    { "store": "supervalu", "id": "supervalu:1011636000", "matched_barcodes": ["5000127014084"], "name": "Kellogg's Corn Flakes Cereal (1 kg)" },
    { "store": "dunnes", "id": "dunnes:100111378", "matched_barcodes": ["5000157024671"], "name": "Heinz Baked Beans 415g" },
    { "store": "dunnes", "id": "dunnes:100114495", "matched_barcodes": ["5000127014084"], "name": "Kellogg's Corn Flakes Breakfast Cereal 1kg" }
  ],
  "not_found": ["5099999999990"],
  "invalid": []
}
GET /api/compare?q={query}

Cross-retailer comparison: each matched product with its price and unit price in every store that stocks it, plus how many stores it spans (coverage).

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/compare?q=heinz%20ketchup"
Response
{
  "as_of": "2026-06-29",
  "matches": [
    {
      "match_id": 561961,
      "brand": "Heinz",
      "pack": "910g",
      "category": "Food Cupboard",
      "display_name": "Heinz Tomato Ketchup 910g",
      "coverage": 4,
      "stores": {
        "aldi": {
          "id": "000000000000336799",
          "name": "Tomato Ketchup 910g",
          "price": 3.99,
          "unit_price": 4.38,
          "unit_basis": "1 KG",
          "was_price": null,
          "clubcard_price": null,
          "scrape_date": "2026-06-29"
        },
        "tesco": {
          "id": "tesco:254722768",
          "name": "Heinz Top Down Squeezy Tomato Ketchup 910g",
          "price": 5.50,
          "unit_price": 6.04,
          "unit_basis": "1 KG",
          "was_price": null,
          "clubcard_price": 4.50,
          "scrape_date": "2026-06-29"
        },
        "supervalu": {
          "id": "supervalu:1003041000",
          "name": "Heinz Tomato Ketchup (910 g)",
          "price": 5.50,
          "unit_price": 6.04,
          "unit_basis": "1 KG",
          "was_price": null,
          "clubcard_price": null,
          "scrape_date": "2026-06-29"
        },
        "dunnes": {
          "id": "dunnes:100684285",
          "name": "Heinz Tomato Ketchup 910g",
          "price": 5.50,
          "unit_price": 6.04,
          "unit_basis": "1 KG",
          "was_price": null,
          "clubcard_price": null,
          "scrape_date": "2026-06-29"
        }
      }
    }
  ]
}
GET /api/aldi/specialbuys

Aldi Specialbuys, the rotating middle-aisle range: active lines grouped by category, plus recently ended and newly listed buys. Aldi-only.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/aldi/specialbuys?limit=1"
Response
{
  "active_by_category": [
    { "category_path": "SpecialBuys > Clothing", "count": 54, "avg_price": 6.63 }
  ],
  "recently_ended": [
    { "id": "000000000708833001", "name": "Bottle with Carabiner", "brand": "CROFTON", "category_path": "SpecialBuys > Kitchen", "last_seen": "2026-06-26", "last_price": 3.99 }
  ],
  "newly_listed": [
    { "id": "000000000738367001", "name": "Adult Fiction", "brand": "HACHETTE", "category_path": "SpecialBuys > Hobbies and Crafts", "first_seen": "2026-06-28", "launch_price": 3.99 }
  ]
}

Changes & lifecycle

GET /api/{store}/changes

Day-over-day movements in three separate buckets: shelf-price increases / decreases, promotion changes (started / ended / changed), and loyalty-price changes. The loyalty bucket (clubcard_changes) reflects each retailer's own scheme — Tesco Clubcard, SuperValu Real Rewards; Dunnes and Aldi have no loyalty pricing, so it stays empty. Each response covers a single retailer (the example below is Tesco). Accepts ?on=YYYY-MM-DD.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/changes"
Response
{
  "date": "2026-06-29",
  "prev_date": "2026-06-28",
  "shelf_increases": [
    {
      "id": "tesco:254656789",
      "name": "Flora Original Spread 500g",
      "brand": "FLORA",
      "category_path": "Fresh Food > Butter, Spreads & Margarine",
      "url": "https://www.tesco.ie/shop/en-IE/products/254656789",
      "pack_size": "500g",
      "prev_price": 2.50,
      "price_now": 2.75,
      "delta": 0.25,
      "pct": 10.0,
      "promo": null
    }
  ],
  "shelf_decreases": [],
  "promotions": [
    {
      "id": "tesco:317990690",
      "name": "Cuisine de France Lye Pretzel 85g",
      "brand": "CUISINE DE FRANCE",
      "kind": "started",
      "offer_now": "Any 3 for €3 Clubcard Price",
      "was_price": null,
      "shelf_price": 1.75
    }
  ],
  "clubcard_changes": [
    {
      "id": "tesco:313603753",
      "name": "Garnier Ambre Solaire Bronze Oil 150ml",
      "brand": "GARNIER",
      "kind": "added",
      "clubcard_now": 8.50,
      "clubcard_prev": null,
      "shelf_price": 17.00,
      "offer_text": "€8.50 Half Price Clubcard Price"
    }
  ]
}
GET /api/{store}/new-products

Newly listed SKUs first seen in the latest snapshot. Use days=N for a lookback, or from=/to= for an explicit range (both ends inclusive).

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/new-products"
Response
[
  { "id": "tesco:324436415", "name": "Adams Polishes Detail Spray 473ml", "brand": "ADAM'S POLISHES", "category_path": "Home & Living > DIY & Car Care", "first_seen": "2026-06-29", "launch_price": 13.00 }
]
GET /api/{store}/removed

Delisted SKUs absent from the latest snapshot (two-snapshot confirmed).

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/dunnes/removed"
Response
[
  { "id": "dunnes:100221987", "name": "Dunnes My Family Fabric Conditioner 3L", "brand": "Dunnes Stores", "category_path": "Dunnes > Household & Cleaning > Laundry", "first_seen": "2026-05-30", "last_seen": "2026-06-27", "last_price": 3.50 }
]
GET /api/tesco/out-of-stock

Present-but-unavailable lines (the retailer's availability flag), distinct from removed. Tesco only — Tesco's catalogue exposes a per-SKU availability flag; the other retailers don't.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/out-of-stock"
Response
{
  "date": "2026-06-29",
  "count": 882,
  "items": [
    { "id": "tesco:317208754", "name": "Sheep Hill Sauvignon Blanc 75cl", "brand": "A GABB FAMILY WINE", "category_path": "Drinks > Wine", "url": "https://www.tesco.ie/shop/en-IE/products/317208754", "pack_size": "75cl", "price": 18.00, "clubcard_price": 9.00 }
  ]
}
GET /api/{store}/shrinkflation

Pack-size reductions: same product, smaller pack, with the old and new size and the effective unit-price change. Returns an empty array until a pack-size reduction is detected; the example below shows the shape.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/shrinkflation"
Response
[
  {
    "id": "tesco:250114433",
    "name": "Cadbury Dairy Milk",
    "brand": "CADBURY",
    "category_path": "Food Cupboard > Confectionery",
    "pack_size": "180g",
    "start_date": "2026-04-01",
    "end_date": "2026-06-29",
    "start_price": 2.50,
    "end_price": 2.50,
    "start_pack_qty": 200.0,
    "start_pack_unit": "G",
    "end_pack_qty": 180.0,
    "end_pack_unit": "G",
    "pack_shrink_pct": 10.0,
    "start_unit_price": 12.50,
    "end_unit_price": 13.89,
    "unit_basis": "1 KG",
    "unit_price_pct": 11.1,
    "headline_pct": 11.1
  }
]
GET /api/{store}/volatile

Most volatile SKUs: products whose price has changed most often over the tracked window. Set the window with days=N, or bound it exactly with from=/to=.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/supervalu/volatile"
Response
[
  { "id": "supervalu:1500119003", "name": "Coca-Cola 1.75L", "brand": "Coca-Cola", "category_path": "Drinks > Minerals", "distinct_prices": 4, "observations": 24, "min_price": 1.85, "max_price": 2.95, "avg_price": 2.31, "range_pct": 59.5 }
]

Promotions

GET /api/{store}/promotions

Live deals: products on offer, with was-price, offer text and loyalty pricing (Clubcard / Real Rewards). Not available for Aldi (everyday-low-price).

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/dunnes/promotions"
Response
[
  {
    "id": "dunnes:100827389",
    "name": "Vanish Oxi Action Stain Remover Spray 500ml",
    "brand": "Vanish",
    "category_path": "Dunnes > Household & Cleaning > Laundry",
    "url": "https://www.dunnesstoresgrocery.com/sm/delivery/rsid/520/product/100827389",
    "pack_size": "500ml",
    "last_seen": "2026-06-29",
    "scrape_date": "2026-06-29",
    "price": 5.00,
    "unit_price": 10.00,
    "unit_basis": "1 L",
    "offer_text": "SAVE €2.50",
    "was_price": 7.50,
    "offer_valid": "2026-06-16 23:00:00Z - 2026-07-21 22:59:59Z",
    "clubcard_price": null
  }
]

Analytics

GET /api/{store}/kpis

Headline numbers for the latest snapshot: catalogue size, active SKUs today vs yesterday, promotion share, out-of-stock count, average price, price moves and new lines.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/kpis"
Response
{
  "total_products": 21056,
  "active_today": 20518,
  "active_yesterday": 20473,
  "active_delta": 45,
  "on_promo_today": 7473,
  "promo_pct_today": 36.4,
  "out_of_stock_today": 882,
  "avg_price_today": 7.28,
  "avg_price_yesterday": 7.26,
  "prices_up_today": 0,
  "prices_down_today": 0,
  "new_today": 29
}
GET /api/{store}/category-index

Per-category average price over time, with the SKU count behind each point — the series behind the dashboard graphs.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/category-index"
Response
{
  "Food Cupboard": [
    { "date": "2026-05-27", "avg_price": 3.51, "n": 4333 },
    { "date": "2026-06-29", "avg_price": 3.52, "n": 4385 }
  ],
  "Drinks": [
    { "date": "2026-05-27", "avg_price": 11.17, "n": 1452 }
  ]
}
GET /api/{store}/category-summary

Per-department roll-up: SKU count, average price and price churn, with the retailer browse URL.

Request
curl -H "X-API-Key: YOUR_KEY" \
  "https://basketwatchireland.com/api/tesco/category-summary"
Response
[
  { "category": "Food Cupboard", "sku_count": 4385, "avg_price": 3.52, "changed": 0, "changed_pct": 0.0, "price_delta_pct": 0.0, "url": "https://www.tesco.ie/shop/en-IE/browse/food-cupboard/all" }
]

Errors, limits & freshness

StatusMeaning
200OK. JSON body (or CSV for .csv paths).
401Missing or invalid key on an endpoint that requires one.
404Unknown store, dataset or product id.
429Rate limit exceeded — back off and retry.

Freshness. Data refreshes nightly across all four retailers; every response carries scrape_date so you always know the snapshot you are reading.

Beyond the API

Need historical back-catalogue, scheduled bulk CSV exports, or a natural-language / agent interface? An Agent & MCP endpoint is available, and bespoke historical pulls are available on request. Talk to us — or get an API key to start.