# Sainsbury's Grocery Price & Promotion Watch (UK) (`superslowsloth/sainsburys-products`) Actor

Search Sainsbury's groceries by keyword and get one flat row per product: price in GBP, unit price (per kg or litre), was-price, every promotion and Nectar offer with its dates, rating, review count, categories, image and URL. Sort by price or rating. For UK grocery price and promotion watching.

- **URL**: https://apify.com/superslowsloth/sainsburys-products.md
- **Developed by:** [Superslow Sloth](https://apify.com/superslowsloth) (community)
- **Categories:** E-commerce, Automation
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.84 / 1,000 product scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Sainsbury's Grocery Price & Promotion Watch (UK)

Search [Sainsbury's groceries](https://www.sainsburys.co.uk) for one or more
terms and get one flat row per product: the shelf price in pounds, the unit
price (per litre, kilo or each), the was-price when a promotion lowers the
price, **every promotion with its wording and start and end dates**, whether a
Nectar card is needed, rating, review count, categories, barcodes, image and
the product URL. Run it on a schedule and diff the dataset to get a **UK grocery
price-change and promotion watch**, or read a whole aisle for price comparison
against Tesco, Asda and Aldi.

No account, no API key, no login, no browser. One request returns 60 products.

### Input

| Field | Notes |
|---|---|
| `searchTerms` | One or more search terms, one per line. Required. A product found by two terms is returned once, under the first. Sainsbury's matches loosely: `milk` finds 963 products, including oat drinks and baby formula. |
| `maxItems` | Products to read per search term (default 100, up to 9,900). 60 per page. |
| `sort` | `relevance` (default), `price-asc`, `price-desc`, `rating`. |
| `proxyConfiguration` | Apify proxy by default (see "How it fetches"). |

#### About the sorts

The sorts are Sainsbury's own and the actor checks each one: the response says
which sort it applied (`controls.sort.active`), and the actor fails a term (at
no charge) rather than return rows in an order that was not asked for. This
matters because Sainsbury's accepts some sort names it does not implement:
`NAME_ASC` is answered with HTTP 200 and the default order (measured).

- **Featured products are pinned to the top of every sort.** Sainsbury's paid
  "Featured" placements (2 of the first 60 for `milk`) come first whatever the
  order, so the first one or two rows of a price sort can be out of order. They
  are flagged `is_featured`; drop them if you want a clean ranking.
- The **rating** sort is Sainsbury's "Ratings - High to Low". It is not a strict
  sort on the star figure.

### Output

Prices are numbers in pounds sterling; `currency` is always `GBP`.

| Field | Notes |
|---|---|
| `query` | The search term that first found this product. |
| `product_uid`, `url`, `eans` | Sainsbury's product id, the `https://www.sainsburys.co.uk/gol-ui/product/<slug>` link, and the barcodes it lists (EAN-13 plus Sainsbury's in-store codes). |
| `name`, `product_type`, `categories`, `labels` | As Sainsbury's shows them. `product_type` is `BASIC`, `LOOSE`, `CATCHWEIGHT` or `MULTIVARIANT` (see below). `labels` are shelf badges such as `Chilled`, `BRITISH`, `ALDI PRICE MATCH*`. |
| `price`, `price_measure` | The shelf price for one pack, **with a simple promotion already applied** ("Buy 1 for 2", "Save 1/3 with Nectar"). `price_measure` is `kg` for loose produce, null for an ordinary pack. |
| `unit_price`, `unit_measure`, `unit_measure_amount` | The price per `ltr`, `kg`, `ea` or `sht` printed on the shelf label. Null when Sainsbury's sends none. |
| `was_price`, `was_unit_price` | The price before a promotion, read from the promotion's `original_price` (and from `original_unit_price`). **Null when no promotion lowers the price**, never 0. |
| `on_promotion`, `nectar_offer` | `on_promotion` is true when the record carries any promotion. `nectar_offer` is true when any of them needs a Nectar card; with a `was_price` it means the lower price is the Nectar price. |
| `promotions` | A list of objects, one per promotion: `strap_line` (the shelf wording, "Meal Deal for £3.95", "Buy any 3 for £3.00 with Nectar"), `type` and `group` (`simple`, `multi_buy`, `meal_deal`), `is_nectar`, `start_date` and `end_date` (ISO, UTC), `original_price`, `discount_percentage` (only sent on percentage promotions, else null), `promotion_uid`, `url`. Empty list when there is none. |
| `options` | `CATCHWEIGHT` and `MULTIVARIANT` products only: one object per weight band or size with `label`, `price`, `unit_price`, `unit_measure` and (variants) `product_uid` and `promotions`. Empty for the rest. |
| `average_weight`, `average_weight_measure` | `LOOSE` produce: the weight of one piece (a banana is 0.17 kg). Null elsewhere. |
| `rating`, `review_count` | Star rating out of 5 and the review count. Sainsbury's sends a rating of 0 when there are no reviews; that is **null** here, with `review_count` 0. |
| `is_featured`, `is_alcoholic`, `is_available` | Sainsbury's own flags. Every one of 723 distinct products sampled was `is_available: true`; the search does not list what cannot be bought. |
| `image_url` | The product photo. |

#### Honest nulls, and four product shapes

A field Sainsbury's did not send is `null`, never `0` or `""`.

Most products are `BASIC`. Two shapes have **no shelf price of their own**, and
for them `price`, `unit_price` and `was_price` are null:

- `CATCHWEIGHT` (meat sold by weight band): `options` lists each band with its
  price, for example a pair of chicken breast fillets at 7.14 for 200 g to 340 g
  and 10.08 for 340 g to 480 g, both at 21.00 per kg.
- `MULTIVARIANT` (sizes of one item, such as a mattress protector): `options`
  lists each size with its own product id and price.

Promotions on a multivariant sit on each option; the parent carries none.

### Pricing

Pay per event:

- **$0.002** per run (`actor-start`), charged only after your input is
  validated, so a run that fails on bad input costs nothing.
- **$0.00084** per product row written (`product-scraped`), i.e. $0.84 per
  1,000 products.

### How it fetches

It calls the JSON API behind Sainsbury's own search page
(`www.sainsburys.co.uk/groceries-api/gol-services/product/v1/product`), 60
products per request, paging with `page_number`. Measured 2026-10-12: HTTP 200
and JSON from Apify datacenter exits, no key or cookie needed. A plain
residential VM was refused with HTTP 403 in the same test, which is why the
default proxy is on; a refusal (403, 429, 5xx) is treated as the address being
blocked, and the actor rotates to a fresh one and retries. An HTTP 400 is
permanent and is not retried. The measured walks: a 17-page search (963
products) came back in full with 963 distinct ids, and 71 requests over three
runs met no rate limit.

Things worth knowing, all measured:

- **Depth ceiling.** The count tops out at 10,000, and Sainsbury's will not page
  that deep: page 165 at 60 per page (9,900 products) answers and page 166 is a
  400\. The actor stops at 9,900 per term and logs any term that had more behind
  it; make the term more specific to see the rest.
- **Past the end.** A page past the last one is an empty 200, which ends the
  term.
- **Loose matching.** Sainsbury's matches words loosely, so a short term can
  return thousands of products and an empty term returns the whole catalogue.
  The actor refuses an empty term.
- **Nectar prices.** Nectar prices need a card at the till. The actor reports
  them as Sainsbury's lists them, flagged with `nectar_offer`; the shelf price
  for a Nectar promotion is the Nectar price.
- **Not offered.** The page size is capped at 60 (a request for 90 or 200 is
  answered with 60), so there is no input for it. Offer and brand filters
  exist on the site but are not wired into this actor.

# Actor input Schema

## `searchTerms` (type: `array`):

What you would type into the Sainsbury's search box, one term per line (for example semi skimmed milk, hovis bread or ben & jerry's). Each term is searched and read separately. A product that two terms both find is returned once, under the first. Sainsbury's matches loosely, so a broad word such as milk returns hundreds of related products (963 measured), including plant drinks and baby formula.

## `maxItems` (type: `integer`):

Read at most this many products for each search term. Sainsbury's serves 60 per page. Up to 9,900 per term are accepted, which is the deepest Sainsbury's will page into any one search.

## `sort` (type: `string`):

The order Sainsbury's lists the results in. 'Relevance' is its own default (favourites first). Sainsbury's pins its paid 'Featured' products to the top of every sort, so one or two rows at the head of a price or rating sort can be out of order; they are marked is_featured in the output.

## `proxyConfiguration` (type: `object`):

Apify proxy (automatic datacenter exits) is the default. A refused request (403, 429) is retried from a fresh address, which is what the proxy is for. Sainsbury's refused a plain residential-broadband VM in testing but answered Apify datacenter exits.

## Actor input object example

```json
{
  "searchTerms": [
    "milk"
  ],
  "maxItems": 100,
  "sort": "relevance",
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `items` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchTerms": [
        "milk"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("superslowsloth/sainsburys-products").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searchTerms": ["milk"],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("superslowsloth/sainsburys-products").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchTerms": [
    "milk"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call superslowsloth/sainsburys-products --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,superslowsloth/sainsburys-products"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/aW7dBHYI0ouhNBsRs/builds/ZaIUMaYNInpntwgS0/openapi.json
