# Shopify Price History & Fake Discount Tracker (`kondasviktor/shopify-discount-promo-tracker`) Actor

Track Shopify product prices over time, get the 30-day lowest price, and flag inflated "was" prices. History starts with your first scheduled run.

- **URL**: https://apify.com/kondasviktor/shopify-discount-promo-tracker.md
- **Developed by:** [Viktor Kondas](https://apify.com/kondasviktor) (community)
- **Categories:** E-commerce, Automation, AI
- **Features:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 product trackeds

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

## Shopify Price History & Fake Discount Tracker

**Track Shopify product prices over time, get the 30-day lowest price, and flag inflated "was" prices.** Schedule it daily on the stores you watch: every run saves the price of every variant in your own Apify account, then tells you the 30-day low, whether the shop's compare-at ("was") price is consistent with it, and what changed since yesterday. Dataset, CSV, a one-click price history chart and optional webhook or Telegram alerts. **$0.003 per product check, $0.02 per store snapshot**, no subscription.

> Independent tool. Not affiliated with Shopify or any store. Reads each store's public product feed only — no login, no cart, no personal data. Information tool, not legal advice.

### Start your log before Black Friday

Past prices cannot be recovered for most shops, so **your history starts with your first scheduled run**. Run 1 is a baseline: it saves today's prices and returns no warning, just a clear message ("Baseline saved for N products. Schedule this task daily. 30-day lows become reliable after 30 days of runs."). From run 2 you get changes; after about 30 days of daily checks the 30-day low and the discount verdict are fully backed by data.

Black Friday is 27 November 2026 — a schedule started now has roughly two weeks of history by then. The status of every number says how complete it is (`lowest30dStatus`: `full`, `partial` or `none`), so a short history never looks like a full one.

### Quick start

1. Open the task **[30-day lowest-price log](https://apify.com/kondasviktor/shopify-discount-promo-tracker/examples/30-day-lowest-price-log)**, replace the example shops with the Shopify store URLs you want to watch (a domain, a homepage, a product URL, or a collection like `/collections/sale`).
2. Click **Start**. Run 1 saves your baseline. Open the dataset, the **Price history chart** and the **CSV export** from the run's output tab.
3. Click **Schedule → daily** (for example 06:00). Keep the same shop URLs and the same **History store name**, so the history keeps growing.

Every run writes to a named key-value store in **your** account (default `shopify-price-history`). Named stores do not expire.

### Get alerts, not spreadsheets

Use the task **[Competitor price-drop alerts](https://apify.com/kondasviktor/shopify-discount-promo-tracker/examples/competitor-price-drop-alerts)** (`outputMode: changesOnly`, `minDropPct: 5`). It returns only variants that really changed: price drop or rise, new, back in stock, out of stock, removed, or a newly inflated discount. Small moves under `minDropPct` are still recorded in history but are not reported.

- **Webhook or Telegram (only when something changed):** fill in `webhookUrl`, or `telegramBotToken` + `telegramChatId`. You get one summary message per run that had changes and **nothing** on quiet days.
- **Apify integrations:** connect the built-in email or Slack integration to the task (Integrations → add → run succeeded). Because the dataset only holds real changes, an attached dataset is empty on quiet days. The webhook/Telegram inputs above are the way to be notified only when something changed.
- Every run also writes a `CHANGES` record (counts per change type plus the changed items) to the run's key-value store.

### What you get

One dataset item per **variant** per run (`rowType: product`), plus one summary item per store (`rowType: store`). Variants are tracked by their Shopify variant ID, so a renamed product handle keeps its history.

| Field | Meaning |
|---|---|
| `store`, `title`, `handle`, `variantId`, `variantTitle`, `sku`, `productUrl` | Which variant (handle and titles are refreshed each run; `variantId` is the history key) |
| `price`, `compareAtPrice`, `currency`, `available` | Current price, the shop's "was" price, ISO 4217 currency, in stock |
| `lowest30d`, `lowest30dStatus`, `checkedDays30d`, `historyDaysCovered` | Lowest price of the last 30 days and how complete that window is |
| `discountVerdict`, `saleStartedAt`, `referenceLowest30d`, `claimedDiscountPct` (`discountPct`), `actualDiscountPct` | Is the "was" price consistent with the lowest price of the 30 days before the sale? |
| `changeType`, `previousPrice`, `priceChangePct`, `baseline` | What changed since the previous check |
| `removed`, `removedAt` | The variant is no longer returned by a completed check (history kept) |
| `chartUrl` | Link to the price history chart (see below) |

Legacy fields from earlier versions keep working: `promoStatus`, `onSaleSince`, `daysOnSale`, `lowestPriceSeen30d` (same value as `lowest30d`), `regularPriceObserved`, `referenceInflationPct`, `inflatedReference` (now set from the verdict: `true`, `false`, or `null` when there is not enough history).

**`lowest30dStatus`** — `full`: 27+ checked days in the last 30 and no gap longer than 2 days · `partial`: some history, or gaps (missed runs hide anything that happened between checks) · `none`: first sighting.

**`discountVerdict`** — `consistent`: the compare-at price is within tolerance (default 1%) of the lowest price in the 30 days before the sale started · `inflated_reference`: it is higher · `insufficient_history`: fewer than 27 checked days (or a gap longer than 2 days) before the sale — the partial numbers are still returned · `no_discount`: no compare-at price above the price.

**`changeType`** — `new` (variant first seen after the baseline) · `price_drop` · `price_rise` · `new_inflated_discount` (verdict became `inflated_reference`) · `back_in_stock` · `out_of_stock` · `removed` · `none`.

**CSV and chart (not charged).** Every run writes `price-history.csv` (long format, one row per price change: `shop, product_handle, product_title, variant_id, variant_title, sku, currency, timestamp, price, compare_at_price, available`) and `price-history.html` (one small step-line chart per product: price, the shop's "was" price as a dashed line, the 30-day low as a marked level; up to 50 charts sorted by the largest recent price movement, the rest are in the CSV). The chart is a single self-contained page: no scripts, fonts or external requests; light and dark mode; readable on a phone. Both files appear in the run's output tab and in your history store (`chart-<shop>`, `csv-<shop>`) for a stable location. **The links are for the account owner when logged in — the storage is private and there are no public or shareable chart links.**

### The EU 30-day rule

Under the EU price-indication rules (Directive 98/6/EC as amended by the Omnibus Directive 2019/2161), an announced price reduction must reference the lowest price the seller applied in the 30 days before the reduction. This tracker compares each shop's "was" price with the lowest price **it recorded itself** in the 30 days before the sale started (`referenceLowest30d`) and tells you how much history backs that comparison. It is an information tool, not legal advice; member states allow some exceptions (for example for perishable goods, products on the market for less than 30 days, or progressive reductions).

### Who uses it

- **Sellers watching competitors** — see price drops, new products and sales the day they happen.
- **Brands checking resellers** — find out whether a reseller's "was" price was ever charged.
- **Journalists and consumer groups checking Black Friday claims** — evidence with a verdict, a history window and a CSV.

### Pricing

Pay per event, no subscription: **$0.003 per product check** (per product — a product with 12 variants is one charge) and **$0.02 per store snapshot** (one summary item per store per run). Reading the catalog, retries, the CSV, the chart, the changes record, removed-variant items and alerts are free; shops that cannot be read are free.

Examples with every product delivered each day (`mode: all`): **300 products in one store checked daily ≈ $27.60 per month**; **1,000 products ≈ $90.60 per month**. `outputMode: changesOnly` charges only the products that changed (plus the store snapshot of a store that had changes — a quiet run pushes and charges nothing), `mode: summary` only the store snapshot. Use **Max products per run** (`maxProducts`) as a hard cost cap and **Max products read per shop** (`scanLimitPerStore`) to limit large shops. Set **Max cost per run** in the Run options as a hard limit.

### Input

| Field | Description |
|---|---|
| `storeUrls` | Shopify stores: domain, homepage, product URL (whole store), collection URL (that collection only) or market sub-folder like `/en-de`. Can import a list from a text file / Google Sheet CSV. Max 500 per run. |
| `historyEnabled` | Keep price history (default on). Legacy alias `trackHistory`; history is off if either is off |
| `historyStoreName` | Named key-value store in your account (default `shopify-price-history`). Tasks that share a name share (and merge) their history |
| `outputMode` | `all` (default) or `changesOnly` — only variants whose `changeType` is not `none` |
| `minDropPct` | Price moves smaller than this are recorded but reported as `none` (default 5) |
| `maxProducts` | Cap on products delivered across all stores — your cost cap (default 200) |
| `scanLimitPerStore` | Products read per shop, 250 per request, free (default 5,000). A capped or failed scan is not counted as a completed check |
| `webhookUrl`, `telegramBotToken`, `telegramChatId` | Optional summary alert, only when something changed |
| `mode` | `on-sale` (default) · `all` · `changes` (legacy: sale started / deepened / ended …) · `summary` (store rows only) |
| `maxProductsPerStore`, `minDiscountPct`, `includeStoreSummary` | Per-store cap, minimum discount, store summary item ($0.02) on/off |
| `referenceTolerancePct` | How far the "was" price may exceed the pre-sale 30-day low before it is flagged (default 1) |
| `proxyConfiguration` | Residential proxy is recommended for big catalogs; direct egress often gets HTTP 429 |

### FAQ

**Why is my first run empty of history?** Run 1 is a baseline: there is nothing to compare with yet, so the 30-day fields are empty and `baseline` is `true`. This is not an error. From run 2 on you get changes; the 30-day low is reliable after about 30 days of daily checks.

**Can I get prices from before I started?** No. The tracker only knows the prices it saw itself. Your history starts with your first scheduled run.

**What happens if a scheduled run fails?** That day is not counted as a completed check, nothing is marked as removed, and `lowest30dStatus` shows `partial` until the window is covered again. A scan that hits `scanLimitPerStore` or a page that keeps failing (HTTP 429/5xx after up to 5 retries) is treated the same way.

**Can two tasks track the same shop?** Yes. Before saving, the tracker re-reads the record and merges it (union of price points and check dates), so concurrent tasks on the same shop and store name do not overwrite each other.

**Does it work on every Shopify store?** No. It needs the public `/products.json` feed. Password-protected shops, some headless stores and shops that disallow it in `robots.txt` are returned as a free item with the reason.

**Where is my history stored and what if I delete it?** In a named key-value store in your Apify account (default `shopify-price-history`), one record per shop (large shops are split into numbered records). Delete the store or a record and that shop starts a new baseline. Let the Actor create the store on its first run — an Actor with limited permissions cannot open a store you created by hand.

**What if a shop changes currency, or shows different prices by country?** Shopify Markets can localise prices (and currency) by the visitor's country. The tracker records the currency the shop actually presents and, with an Apify proxy, pins the proxy's exit country to the shop's own country (override it in the proxy settings). If the presented currency still changes, the old series is closed and a new baseline starts; currencies are never mixed in one series.

**Does it cover Amazon?** No — Shopify stores only.

**Why is `onSalePct` 0 on a shop that runs Black Friday?** Many shops apply Black Friday discounts at checkout (codes, automatic discounts) instead of marking products down. Those never appear in the public product feed.

**Can you add a field or fix a store?** Yes — open an issue with the store URL.

### Limitations

- Shopify stores only, through the public product feed. Discounts applied at checkout (codes, bundles, "buy 2 get 1") are not visible there.
- Prices are in the store's primary currency and market. Market sub-folders such as `/en-de` work only where the shop serves its feed there.
- The 30-day verdict needs 27+ checked days before the sale; before that you get `insufficient_history` and the partial numbers.

### Related Actors

- [EU Omnibus Discount Checker](https://apify.com/kondasviktor/eu-omnibus-discount-checker) — check a single product page's discount against the legally required 30-day lowest price
- [Product Catalog Scraper](https://apify.com/kondasviktor/product-catalog-scraper) — export full Shopify catalogs as import-ready CSV
- [Website Change Monitor](https://apify.com/kondasviktor/website-change-monitor) — watch a store's landing page and promo banners for changes
- [Vinted Deal Monitor](https://apify.com/kondasviktor/vinted-deal-monitor) — second-hand deals, price drops and Telegram/webhook alerts across 26 markets

***

Built by [Viktor Kondas](https://apify.com/kondasviktor) · [Vibe Coder's Life](https://vibecoderslife.com/?utm_source=apify\&utm_medium=readme\&utm_campaign=shopify-discount-promo-tracker) · Custom work: hello@vibecoderslife.com

# Actor input Schema

## `storeUrls` (type: `array`):

Shopify stores to track: a homepage, a bare domain, any product URL (the whole store is read), a collection URL such as `/collections/sale` (only that collection), or a market sub-folder such as `/en-de`. You can also import a list from a text file or Google Sheet CSV. Prefilled with the Black Friday example shops.

## `historyEnabled` (type: `boolean`):

Saves every price change of every variant in a store in YOUR Apify account, so each scheduled run can tell you the 30-day lowest price and whether a "was" price is inflated. Leave it on. History starts with your first run and cannot be recovered for the time before it. Required for `changesOnly` output.

## `historyStoreName` (type: `string`):

Name of the key-value store in your account that holds the history, one record per shop (3–63 characters: lowercase letters, digits, hyphens). Named stores do not expire. Several tasks can share a name — their histories are merged. Use another name to keep separate watchlists apart.

## `outputMode` (type: `string`):

`all` returns every item selected by "What to return" below. `changesOnly` returns only variants that changed since the last check (price drop or rise, new, back in stock, out of stock, removed, new inflated discount) — so Apify's email, Slack or webhook integrations fire only on real changes. The first run is a baseline and returns nothing in this mode.

## `minDropPct` (type: `number`):

Price changes smaller than this are still saved in the history but are reported as `changeType: none`. 5 = ignore moves under 5%.

## `maxProducts` (type: `integer`):

Hard cap on products delivered across all stores — also your cost cap (200 products = $0.60, however many variants they have). Store summary rows are not counted. Reading the catalog itself is free.

## `scanLimitPerStore` (type: `integer`):

How many products of each shop are read (250 per request, free). If a shop has more, the run says so and that day is NOT counted as a completed check, so nothing is wrongly marked as removed. Raise it for big catalogs.

## `webhookUrl` (type: `string`):

One JSON POST per run that had changes (a summary plus the changed items). Nothing is sent when nothing changed. Must be a public https/http URL.

## `telegramBotToken` (type: `string`):

Create a bot with @BotFather and paste its token. You get one summary message per run that had changes, nothing otherwise. Needs the chat ID below.

## `telegramChatId` (type: `string`):

Chat or channel ID that receives the summary message.

## `mode` (type: `string`):

`on-sale` returns discounted variants, deepest first. `all` returns every variant. `changes` returns variants whose price or sale status changed since the previous run. `summary` returns store summaries only — the cheapest daily monitor. A product with many variants is one $0.003 charge.

## `maxProductsPerStore` (type: `integer`):

Stops one big store from using the whole budget. Rows are picked deepest discount first.

## `minDiscountPct` (type: `number`):

Only return products discounted by at least this much. 0 = every sale item. Use 30–50 to find the deepest deals. Ignored in `changes` and `summary` mode.

## `includeStoreSummary` (type: `boolean`):

One extra row per store ($0.02): share of the catalog on sale, average / median / deepest discount, promo level and trend, Black Friday detection, inflated compare-at prices.

## `referenceTolerancePct` (type: `number`):

A "was" (compare-at) price is flagged `inflated_reference` when it is more than this many percent above the lowest price recorded in the 30 days before the sale started. 1 follows the EU 30-day rule. Needs 27+ checked days before the sale to give a verdict.

## `trackHistory` (type: `boolean`):

Older name of "Keep price history", kept so saved tasks still work. History is off when either one is switched off.

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

Residential proxy is recommended for big Shopify catalogs (Gymshark, Allbirds, …). Without a proxy, many stores return HTTP 429. Datacenter often works for small shops.

## Actor input object example

```json
{
  "storeUrls": [
    {
      "url": "https://www.glossier.com"
    },
    {
      "url": "https://www.outdoorvoices.com"
    },
    {
      "url": "https://www.kyliecosmetics.com"
    }
  ],
  "historyEnabled": true,
  "historyStoreName": "shopify-price-history",
  "outputMode": "all",
  "minDropPct": 5,
  "maxProducts": 10,
  "scanLimitPerStore": 1000,
  "mode": "on-sale",
  "maxProductsPerStore": 5,
  "minDiscountPct": 0,
  "includeStoreSummary": true,
  "referenceTolerancePct": 1,
  "trackHistory": true,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

## `chart` (type: `string`):

No description

## `csv` (type: `string`):

No description

## `changes` (type: `string`):

No description

## `summary` (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 = {
    "storeUrls": [
        {
            "url": "https://www.glossier.com"
        },
        {
            "url": "https://www.outdoorvoices.com"
        },
        {
            "url": "https://www.kyliecosmetics.com"
        }
    ],
    "historyEnabled": true,
    "historyStoreName": "shopify-price-history",
    "outputMode": "all",
    "minDropPct": 5,
    "maxProducts": 10,
    "scanLimitPerStore": 1000,
    "mode": "on-sale",
    "maxProductsPerStore": 5,
    "minDiscountPct": 0,
    "includeStoreSummary": true,
    "referenceTolerancePct": 1,
    "trackHistory": true,
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("kondasviktor/shopify-discount-promo-tracker").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 = {
    "storeUrls": [
        { "url": "https://www.glossier.com" },
        { "url": "https://www.outdoorvoices.com" },
        { "url": "https://www.kyliecosmetics.com" },
    ],
    "historyEnabled": True,
    "historyStoreName": "shopify-price-history",
    "outputMode": "all",
    "minDropPct": 5,
    "maxProducts": 10,
    "scanLimitPerStore": 1000,
    "mode": "on-sale",
    "maxProductsPerStore": 5,
    "minDiscountPct": 0,
    "includeStoreSummary": True,
    "referenceTolerancePct": 1,
    "trackHistory": True,
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("kondasviktor/shopify-discount-promo-tracker").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 '{
  "storeUrls": [
    {
      "url": "https://www.glossier.com"
    },
    {
      "url": "https://www.outdoorvoices.com"
    },
    {
      "url": "https://www.kyliecosmetics.com"
    }
  ],
  "historyEnabled": true,
  "historyStoreName": "shopify-price-history",
  "outputMode": "all",
  "minDropPct": 5,
  "maxProducts": 10,
  "scanLimitPerStore": 1000,
  "mode": "on-sale",
  "maxProductsPerStore": 5,
  "minDiscountPct": 0,
  "includeStoreSummary": true,
  "referenceTolerancePct": 1,
  "trackHistory": true,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call kondasviktor/shopify-discount-promo-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kondasviktor/shopify-discount-promo-tracker"
        }
    }
}
```

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/Y7aPuBS3995HQTSfq/builds/sd5CbcVkYAVgqpJlV/openapi.json
