# Shopify Product Scraper by URL - Price, Stock & Changes (`lukehunter/shopify-product-tracker`) Actor

Scrape specific Shopify products by URL, from any store: price, compare-at price, sale flag and per-variant stock. In change mode on a schedule, get price-drop and stock-change alerts to Discord, Telegram, Slack or any webhook. Removed products are reported once. No login, no proxies.

- **URL**: https://apify.com/lukehunter/shopify-product-tracker.md
- **Developed by:** [Luke Hunter](https://apify.com/lukehunter) (community)
- **Categories:** E-commerce, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.00 / 1,000 products

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 Product Scraper by URL - Price, Stock & Changes

**For e-commerce and pricing teams, brands watching their resellers, and dropshippers who need the current price and stock of specific Shopify products.** Give it a list of product URLs from any Shopify store or custom domain and it returns one row per product (or per variant): price, compare-at ("was") price, sale flag, per-variant stock and SKU - read from each product's own public Shopify product feed. Turn on **change mode** (`onlyChangedSinceLastRun`) and run it on a schedule, and you only receive the products whose price, compare-at price or availability changed since the last run.

Pay-per-result: **$0.002 per product — 1,000 products = $2.** No start fee, and unchanged products in change mode are not charged.

### Quick start (1 minute)

1. Open the **Input** tab and paste one or more Shopify product URLs - the page address of a product, from any store.
2. Choose **One row per** *product* (variants listed inside the row) or *variant* (one row per size/colour).
3. Click **Start**, then export the dataset to CSV/Excel/JSON or pull it through the API shown below.

```json
{
  "productUrls": [
    "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
    "https://www.allbirds.com/products/mens-canvas-runner-nz"
  ],
  "granularity": "product",
  "maxItems": 100
}
```

That prefilled example reads two products and costs $0.004.

### Example output

One product row from a real run against allbirds.com (the `variants` list is shortened here to 3 of the product's 7 variants):

```json
{
  "store": "www.allbirds.com",
  "productId": "7340901859408",
  "handle": "womens-allbirds-flip-flop-dusty-pink",
  "url": "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
  "title": "Women's Allbirds Flip Flop - Dusty Pink",
  "vendor": "Allbirds",
  "productType": "Shoes",
  "currency": "USD",
  "price": 25,
  "compareAtPrice": 50,
  "onSale": true,
  "available": true,
  "variantsCount": 7,
  "variants": [
    { "variantId": "42146889039952", "title": "5", "sku": "A12513W050", "price": 25, "compareAtPrice": 50, "available": false },
    { "variantId": "42146889072720", "title": "6", "sku": "A12513W060", "price": 25, "compareAtPrice": 50, "available": false },
    { "variantId": "42146889105488", "title": "7", "sku": "A12513W070", "price": 25, "compareAtPrice": 50, "available": false }
  ],
  "status": "active",
  "changedFields": null,
  "checkedAt": "2026-10-03T08:00:00.000Z"
}
```

`price` is the lowest variant price and `compareAtPrice` is that same variant's "was" price. `available` is true if any variant can be bought. In change mode the same row also tells you what moved - this is what a later run delivers if the price of that product had dropped (illustrative, not a real price change):

```json
{
  "store": "www.allbirds.com",
  "productId": "7340901859408",
  "handle": "womens-allbirds-flip-flop-dusty-pink",
  "url": "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
  "title": "Women's Allbirds Flip Flop - Dusty Pink",
  "vendor": "Allbirds",
  "productType": "Shoes",
  "currency": "USD",
  "price": 19.99,
  "compareAtPrice": 50,
  "onSale": true,
  "available": true,
  "variantsCount": 7,
  "variants": [
    { "variantId": "42146889039952", "title": "5", "sku": "A12513W050", "price": 19.99, "compareAtPrice": 50, "available": false }
  ],
  "status": "active",
  "changedFields": ["price"],
  "checkedAt": "2026-10-04T08:00:00.000Z"
}
```

A product the store has taken down (the URL answers 404) is delivered once as `"status": "removed"` with `price`, `title` and the other product fields set to `null`.

### Track price and stock changes on a schedule

Change mode turns the Actor into a change feed. Every run after the first delivers - and charges for - only products whose **price, compare-at price or availability** changed since the previous run. Edits to a title, description or image are not changes and cost nothing.

1. Save this as a Task (a list of the products you care about, with change mode on):

```json
{
  "productUrls": [
    "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
    "https://www.allbirds.com/products/mens-canvas-runner-nz"
  ],
  "onlyChangedSinceLastRun": true,
  "granularity": "product",
  "maxItems": 500
}
```

2. Add a **Schedule** (hourly, daily - whatever fits how fast the prices move).
3. **The first run is a baseline**: it delivers every product (they are all new to the tracker, `changedFields` is `["firstSeen"]`) and remembers them. You are charged normally for that run.
4. Every later run delivers only what changed. `changedFields` lists which tracked fields moved: `price`, `compareAtPrice`, `available`, `variants` (a variant was added or removed), `status` (removed, or back after being removed) or `firstSeen`.
5. Attach a **Webhook** for `ACTOR.RUN.SUCCEEDED` (or an Integration: Make, Zapier, n8n, Slack, Google Sheets) so changes reach your team or your repricing script as soon as a run finds them.

A run where nothing changed **succeeds** with an empty dataset and charges nothing; the run's status message says so, e.g. "Checked 2 of 2 product URL(s). 2 unchanged. Nothing to deliver or charge." Pick `"granularity": "variant"` to track every size/colour on its own: only the variants that changed are delivered.

State is kept per store in a named key-value store (`stateStoreName`, default `shopify-product-tracker-state`), so it survives between runs. Use another name to keep separate histories for separate tasks, or delete the store to start over with a fresh baseline.

### Get Shopify price drop and back-in-stock alerts in Discord, Telegram, Slack, Teams, Google Chat, your phone (ntfy/Pushover) or any app (webhook)

Want a **Shopify price drop alert** or a **back-in-stock alert** in Discord, Telegram, Slack or on your phone instead of reading change rows from a dataset? Turn on change mode (`onlyChangedSinceLastRun`), add one or more destinations in the Actor's **Notifications (optional)** section, and each run sends the changes it delivered. It is a **monitor that notifies you**: it only reads each product's public Shopify feed, and it never buys anything, adds to a cart or logs in to a store.

| Channel | Fill in | What you get |
|---|---|---|
| Discord | `discordWebhookUrl` | One card per change, 10 per message |
| Telegram | `telegramBotToken` + `telegramChatId` | Up to 10 changes per message, one line each |
| Slack | `slackWebhookUrl` | Block Kit message, up to 10 changes per message |
| Mattermost, Rocket.Chat | `slackCompatibleWebhookUrl` | Slack-style incoming webhook: a Markdown list (these apps do not render Slack Block Kit) |
| Microsoft Teams | `teamsWebhookUrl` | Adaptive Card from a Teams Workflows webhook, up to 10 changes per card |
| Google Chat | `googleChatWebhookUrl` | Card in a space, up to 10 changes per message |
| ntfy (phone push, no account) | `ntfyTopic` (optional `ntfyServer`, `ntfyToken`) | A push per change, up to 10, then one "...and N more" |
| Pushover (phone push) | `pushoverUserKey` + `pushoverAppToken` | A push per change, up to 10, then one "...and N more" |
| Any app: Zapier, Make, n8n, Pipedream, IFTTT, Home Assistant | `webhookUrl` | One JSON POST per run with every delivered row |

Fill in as many as you like; each channel works on its own and a failing one never stops the others.

#### Setup, channel by channel

- **Discord (1 minute):** Server Settings → Integrations → Webhooks → New Webhook → pick the channel → **Copy Webhook URL**.
- **Telegram (2 minutes):** chat with **@BotFather**, send `/newbot`, paste the token into `telegramBotToken`. Add the bot to the chat or channel (administrator for a channel; for a chat with yourself press **Start** on the bot). `telegramChatId` is a numeric id (`123456789`, or `-1001234567890` for a group or channel) or a public `@channelusername`.
- **Slack:** at api.slack.com/apps create an app (**From scratch**) → **Incoming Webhooks** → switch it on → **Add New Webhook to Workspace** → pick the channel → copy the `https://hooks.slack.com/services/...` URL.
- **Mattermost or Rocket.Chat:** create an **incoming webhook** in the app and paste its public `https://` URL into `slackCompatibleWebhookUrl`. It must be reachable from the internet: localhost, private IP addresses and `.local` / `.internal` names are rejected.
- **Microsoft Teams:** in the channel open **... → Workflows** → **Post to a channel when a webhook request is received** → finish the wizard and copy the URL (a host ending in `.logic.azure.com` or `.powerplatform.com`). The old "Office 365 connector" webhooks are retired and not supported.
- **Google Chat:** in the space open **Apps & integrations → Webhooks → Add webhook**, name it and copy the `https://chat.googleapis.com/v1/spaces/...` URL (needs a Google Workspace account that allows webhooks).
- **ntfy (free, no account):** install the ntfy app (or open ntfy.sh), subscribe to a topic with a long random name, and put the same name in `ntfyTopic`. Anyone who knows the topic can read it, so make it unguessable. Own server or a protected topic: set `ntfyServer` (public `https://` address) and `ntfyToken`.
- **Pushover:** copy your user key from pushover.net into `pushoverUserKey`, create an application at pushover.net/apps/build and put its API token in `pushoverAppToken`. Pushover itself is a separate paid app after its trial: check their pricing.
- **Any app (webhook):** paste the catch-hook URL from Zapier, Make, n8n, Pipedream, IFTTT or Home Assistant into `webhookUrl`. It must be a public `https://` address.

#### The recipe: a price and stock alert in five steps

1. List the products to watch in `productUrls`.
2. Turn on `onlyChangedSinceLastRun` (pick `granularity: "variant"` to watch each size or colour on its own).
3. Add your destination(s) from the table above.
4. Run it once. The first run is the baseline: it sends nothing, because nothing has changed yet.
5. Create a **Schedule** (hourly or daily, to match how fast the prices move). From then on each run sends the changes it found.

```json
{
  "productUrls": [
    "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink"
  ],
  "onlyChangedSinceLastRun": true,
  "granularity": "product",
  "maxItems": 500,
  "discordWebhookUrl": "https://discord.com/api/webhooks/<id>/<token>",
  "ntfyTopic": "your-long-random-topic-name",
  "maxNotifications": 25
}
```

#### The generic webhook (`webhookUrl`)

Each run sends **one POST per 100 delivered rows** (so usually one) with this body, followed by whatever your tool does with it:

```json
{
  "source": "apify",
  "actor": "shopify-product-tracker",
  "runId": "HG7ML7M8z78YcAPEB",
  "count": 2,
  "total": 2,
  "part": 1,
  "parts": 1,
  "items": [
    {
      "...": "the full dataset rows"
    }
  ]
}
```

`count` is the number of rows in this POST, `total` the number delivered in the run, and `part` / `parts` number the POSTs when a run delivers more than 100 rows. `maxNotifications` does not cap the webhook: your tool gets every delivered row. Your endpoint should answer with a 2xx status; redirects are not followed and a failure is reported in the run status, never retried in a loop.

#### What each message contains

Each change is announced with the product name and a link to the product: `Price drop: $24.00 → $19.99`, `Price rise`, `Sold out`, `Back in stock`, `Compare-at price changed`, `Variants changed` (a size or colour was added or removed), `Removed` (the store took the product down, with its last known price) or `Back on sale` (a removed product returned). The details show the new price, the old price, the compare-at price, whether it is on sale, stock, the number of variants, the vendor and the store. In variant mode the message names the size or colour. Anything the store does not publish is left out. Link previews are off, and titles are escaped so a title can never inject formatting or mentions.

#### What to expect

- **Only delivered change rows are sent, and only in change mode.** A normal run delivers every product rather than changes, so it sends nothing (the status message says so). The baseline run and runs with no changes send nothing.
- **"Product" granularity says "in stock" when any size is.** If one size sells out while others remain, the message says `Stock changed` rather than `Sold out`; use `granularity: "variant"` to be told about each size.
- **`maxNotifications` (default 25, max 100)** caps how many changes go to chat and phone channels per run (the webhook is not capped). The rest are still in the dataset and the status message says so. ntfy and Pushover send at most 10 individual pushes, then one "...and N more" summary.
- **Cost: notifications are free.** You pay only the usual price for delivered changes, exactly as without alerts. There is no extra charge for any channel.
- **Failures never fail the run.** A deleted webhook or an unreachable service leaves the run successful and the dataset complete, and the status message says what happened, for example `Notified: Discord 12, Slack 12, ntfy 10 (+summary). Teams failed (HTTP 404) - check the webhook URL.` The logs show the channel name and HTTP status only, never a URL or token.
- **Your URLs, tokens and ntfy topic are stored as secret inputs.** Anyone holding a webhook URL can post to that channel, so treat it like a password.
- **It is a monitor that notifies you.** It never buys anything, adds to a cart or logs in to a store. The messages go only to the destinations you set up.
- **Nothing changes by default.** With no destination set, a run behaves exactly as before and the dataset rows are identical.

### How it works

For each URL the Actor:

1. Cleans the URL up to `https://<store>/products/<handle>` (query strings, `.json` suffixes, trailing slashes and collection paths are removed).
2. Reads the store's `robots.txt` once per store and skips any path it disallows, saying so in the run status.
3. Reads the product's public `/products/<handle>.js` feed, which carries price, compare-at price and the in-stock flag for every variant. The store's currency is read once per store from the matching `.json` feed.
4. Delivers one flat row per product (or per variant), charging only for rows it actually delivers.

It reads politely - about one request every 2 seconds per store, with an honest, self-identifying User-Agent - and reports a store that blocks it as **blocked** instead of working around it. Different stores are read without waiting on each other.

### Input

| Field | Type | Default | Description |
|---|---|---:|---|
| `productUrls` | string\[] | *(required)* | Shopify product page URLs, any store or custom domain. Up to 5,000 per run. |
| `maxItems` | integer | 100 | 1-5,000. Hard cap on rows delivered in the run - your cost cap. In variant mode each variant is a row. |
| `granularity` | `"product"` | `"variant"` | `"product"` | One row per product with a `variants` list, or one row per variant. |
| `onlyChangedSinceLastRun` | boolean | `false` | Change mode: deliver only products (or variants) whose price, compare-at price or availability changed since the last run. |
| `stateStoreName` | string | `shopify-product-tracker-state` | Name of the store that remembers the last values seen in change mode. Letters, numbers and hyphens. |
| `discordWebhookUrl` | string (secret) | *(none)* | Discord webhook URL. Must look like `https://discord.com/api/webhooks/<id>/<token>`. Empty means no Discord messages. See the alerts section above. |
| `telegramBotToken` | string (secret) | *(none)* | Telegram bot token from @BotFather. Needs `telegramChatId`. |
| `telegramChatId` | string | *(none)* | Telegram chat to post to: a numeric chat id or `@channelusername`. The bot must be added to that chat or channel. Needs `telegramBotToken`. |
| `slackWebhookUrl` | string (secret) | *(none)* | Slack incoming webhook, `https://hooks.slack.com/services/...`. |
| `slackCompatibleWebhookUrl` | string (secret) | *(none)* | Mattermost / Rocket.Chat incoming webhook (public `https://` URL; Markdown list, no Block Kit). |
| `teamsWebhookUrl` | string (secret) | *(none)* | Microsoft Teams Workflows webhook (host ending `.logic.azure.com` or `.powerplatform.com`). |
| `googleChatWebhookUrl` | string (secret) | *(none)* | Google Chat space webhook, `https://chat.googleapis.com/v1/spaces/...`. |
| `ntfyTopic` | string (secret) | *(none)* | ntfy topic name; setting it turns ntfy phone pushes on. Pick a long, unguessable name. |
| `ntfyServer` | string | `https://ntfy.sh` | ntfy server. Only change it for your own public `https://` ntfy server. |
| `ntfyToken` | string (secret) | *(none)* | Optional ntfy access token for a self-hosted server or protected topic. Needs `ntfyTopic`. |
| `pushoverUserKey` | string (secret) | *(none)* | Pushover user key. Needs `pushoverAppToken`. |
| `pushoverAppToken` | string (secret) | *(none)* | Pushover application API token. Needs `pushoverUserKey`. |
| `webhookUrl` | string (secret) | *(none)* | Generic JSON webhook (Zapier, Make, n8n, Pipedream, IFTTT, Home Assistant): every delivered row, 100 per POST. Public `https://` only. |
| `maxNotifications` | integer | 25 | 1–100. Most changes sent to chat and phone channels per run (the webhook is not capped). Extra changes are still in the dataset. Only used when a destination is set. |

### Output fields

| Category | Fields |
|---|---|
| Identity | `store`, `productId`, `handle`, `url` |
| Product | `title`, `vendor`, `productType` |
| Price | `price`, `compareAtPrice`, `onSale`, `currency` |
| Stock | `available`, `variantsCount`, `variants` (product mode) |
| Variant mode only | `variantId`, `variantTitle`, `sku` |
| Status | `status` (`active` / `removed`), `changedFields` (change mode), `checkedAt` |

Missing values are `null`. Nothing is invented: `currency` is only filled in when the store's own data states it, and `available` is `null` if a store does not expose availability.

### Use it as an API

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")

run = client.actor("lukehunter/shopify-product-tracker").call(
    run_input={
        "productUrls": ["https://www.allbirds.com/products/mens-canvas-runner-nz"],
        "onlyChangedSinceLastRun": True,
    }
)

for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["title"], item["price"], item["changedFields"])
```

### Pricing and cost control

Pay per delivered row.

**$0.002 per product — 1,000 products = $2.**

| Rows delivered | Cost |
|---:|---:|
| 2 | $0.004 |
| 100 | $0.20 |
| 1,000 | $2.00 |
| 5,000 | $10.00 |

There is no charge for starting a run, and none for a URL that is blocked, skipped by `robots.txt`, invalid or not a Shopify product. In change mode, unchanged products are not charged. `maxItems` is a hard upper bound on cost, and the Actor never delivers a row your run's maximum-cost setting cannot pay for.

Notifications to Discord, Telegram, Slack, Teams, Google Chat, ntfy, Pushover and webhooks are free: you pay only for delivered changes, never for messages sent.

### Limitations

- **Shopify stores only**, and only products you list: this Actor does not crawl a store or search for products. To pull a store's whole catalogue use [Shopify Store Products Scraper](https://apify.com/lukehunter/shopify-store-products-scraper).
- **Some stores block automated requests.** They answer HTTP 403 and this Actor reports that store as `blocked` in the run status, charges nothing for it, and stops sending it requests for the rest of the run. It does not use proxies, rotate identities or try to get around a block.
- **robots.txt is respected.** A store that disallows its product feeds is skipped and reported, and a `robots.txt` that cannot be read means the store is skipped rather than read without confirmed permission.
- **Stock is Shopify's yes/no flag per variant, not a stock count.** `available: false` means the variant cannot be bought right now; the number of units left is not exposed and not collected.
- **Prices are what a visitor sees in the store's default market**: before shipping, tax and discount codes. A market or language prefix in a URL (such as `/en-gb/`) is dropped, so the default market is read. `currency` can be `null` if a store does not state it.
- **Password-protected or non-Shopify pages** return an error for that URL, not data.
- **Change mode compares against the last successful run.** The first run delivers everything. A variant that disappears from a product is only reported in product mode (as `variants`).
- A scheduled run sees a product as it is at that moment; changes that happen and revert between two runs are not seen.
- Reads about 1 product per 2 seconds per store, so 1,000 products from one store take roughly 35 minutes. Products from different stores are not delayed against each other.
- This is an independent tool, not affiliated with, endorsed by, or connected to Shopify or any store. You are responsible for using the collected data in line with each store's terms and applicable law.

### Use it from your code, Make, Zapier or n8n

Built for automated pipelines: small runs, stable field names, one row per item, and you pay only for rows actually delivered.

**One API call that runs the Actor and returns the data** (replace `$APIFY_TOKEN` with your token from Apify Console → Settings → Integrations):

```bash
curl -s -X POST "https://api.apify.com/v2/acts/lukehunter~shopify-product-tracker/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"productUrls": ["https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink", "https://www.allbirds.com/products/mens-canvas-runner-nz"], "maxItems": 20, "granularity": "product"}'
```

**JavaScript** (`npm i apify-client`):

```js
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('lukehunter~shopify-product-tracker').call({
  "productUrls": [
    "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
    "https://www.allbirds.com/products/mens-canvas-runner-nz"
  ],
  "maxItems": 20,
  "granularity": "product"
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

**Python** (`pip install apify-client`):

```python
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ['APIFY_TOKEN'])
run = client.actor('lukehunter~shopify-product-tracker').call(run_input={"productUrls": ["https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink", "https://www.allbirds.com/products/mens-canvas-runner-nz"], "maxItems": 20, "granularity": "product"})
items = client.dataset(run['defaultDatasetId']).list_items().items
```

**Schedules, webhooks and no-code tools:** save your input as a Task, add a Schedule (e.g. hourly or daily), and attach a webhook or one of the Integrations tab's connectors (Make, Zapier, n8n, Google Sheets, Slack) to push each run's results into your own system.

**Only new items each run:** set `"onlyChangedSinceLastRun": true` on a scheduled Task and every run after the first delivers, and charges for, only items it hasn't delivered before for that same input. That makes it a ready-made change feed.

**Reliability:** an automated test runs this Actor's parser against the live site every day, and failures are fixed fast.

**Feedback:** an honest rating on the Store page, good or bad, helps others decide if this Actor fits their needs. Missing a field or found a problem? Open an issue on the Actor page.

### FAQ

#### Does it work on any Shopify store, including custom domains?

Yes - any store whose product pages live at `/products/<handle>` and answer with Shopify's standard product feeds, on a `myshopify.com` address or a custom domain. A store that blocks automated requests is reported as blocked.

#### How do I only get the products whose price changed?

Set `onlyChangedSinceLastRun` to `true` and put the Actor on a schedule - see "Track price and stock changes on a schedule" above. The first run delivers everything and remembers it; later runs deliver only changes, and unchanged products are free.

#### What counts as a change?

A change in `price`, `compareAtPrice` or `available` on any variant, or a variant being added or removed. Anything else on the product page (title, description, images) is ignored.

#### How do I track stock per size?

Use `"granularity": "variant"`. Each variant gets its own row with `variantTitle`, `sku`, `price` and `available`, and in change mode only the variants that changed are delivered.

#### What happens when a product is deleted?

The store answers 404 for the URL. The Actor delivers the product once with `"status": "removed"` and then stays quiet about it. If the product comes back, you get a row with `changedFields: ["status"]`.

#### Why is `currency` sometimes `null`?

The currency is read from the store's own product data. If a store does not state it, the field is `null` - the Actor never guesses it from the domain.

#### Can I pass URLs with tracking parameters or `?variant=` IDs?

Yes. Query strings and fragments are removed, and the same product listed twice is only read once.

#### Can I get Shopify price drop alerts in Discord, Telegram, Slack or on my phone?

Yes. Set `discordWebhookUrl`, `telegramBotToken` + `telegramChatId`, `slackWebhookUrl`, `teamsWebhookUrl` or `googleChatWebhookUrl`, or use `ntfyTopic` (free, no account) or `pushoverUserKey` + `pushoverAppToken` for phone pushes. Mattermost and Rocket.Chat work through `slackCompatibleWebhookUrl`. Alerts need change mode (`onlyChangedSinceLastRun`) and a schedule: each run then sends only the price, compare-at, stock and removal changes it delivered. Setup steps for each are in the alerts section above. Notifications cost nothing extra.

#### Can I get Shopify price drop alerts by email?

Not natively. There are two routes. (1) Set `webhookUrl` to a Zapier, Make or n8n catch hook and add an email or Gmail step: the webhook delivers every row as JSON, so the email can list the changes. (2) Apify's own run integrations and run notifications can email you when a run finishes, but they only send a link to the run or dataset, not the changes themselves. For instant alerts without building anything, Telegram, ntfy or Pushover reach your phone directly.

#### Can I get Shopify price drop alerts in WhatsApp?

Not natively, and we do not use unofficial WhatsApp APIs, which risk getting your number banned. The official route is the WhatsApp Business Platform (Cloud API) with its own business account, message templates and pricing: point `webhookUrl` at a Zapier, Make or n8n flow that calls it. For free phone alerts use Telegram or ntfy instead.

### Related Actors

Other data tools from the same developer, built to the same standard: official or public sources, hard cost caps, and honest documentation of limits.

- **[Shopify Store Products Scraper](https://apify.com/lukehunter/shopify-store-products-scraper)**: full product catalogues from any Shopify store, with prices, sale prices, variants and stock.
- **[Walmart Category Scraper](https://apify.com/lukehunter/walmart-category-scraper)**: product names, prices, was-prices and ratings from Walmart category pages.
- **[Vinted Cross-Country Price Comparison](https://apify.com/lukehunter/vinted-cross-country-prices)**: compare the same search across Vinted country markets with EUR-normalized summaries and median price gaps.
- **[Vinted Scraper](https://apify.com/lukehunter/vinted-scraper)**: Vinted search results with prices, brands and sizes in any Vinted country, plus scheduled new-listing alerts sent to Discord or Telegram.
- **[Vinted Item Scraper by URL](https://apify.com/lukehunter/vinted-item-tracker)**: price-drop and sold alerts for specific Vinted item URLs: price, brand, size, condition and availability, with a changes-only mode.
- **[AliExpress Search Scraper](https://apify.com/lukehunter/aliexpress-scraper)**: AliExpress search results with prices, discounts, ratings and sold counts, by keyword.
- **[Temu Scraper](https://apify.com/lukehunter/temu-scraper)**: Temu search results with prices, product links and images for any keyword, with a new-products-only mode for scheduled runs.
- **[YouTube Channel Scraper](https://apify.com/lukehunter/youtube-stats-tracker)**: new-video alerts and channel monitoring for any list of YouTube channels: subscribers, latest videos and a new-uploads-only mode for scheduled runs.

# Actor input Schema

## `productUrls` (type: `array`):

Product page URLs from any Shopify store or custom domain, e.g. "https://www.allbirds.com/products/mens-canvas-runner-nz". Query strings, trailing slashes, ".json" suffixes and collection paths are cleaned up automatically. Up to 5,000 per run; a URL that is not a Shopify product gets its own error in the run status.

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

Hard cap on the number of rows delivered in this run, 1-5000, default 100. You are charged $0.002 per delivered row, so this is your cost cap. In variant mode every variant is one row.

## `granularity` (type: `string`):

"product" returns one row per product with a variants list (default). "variant" returns one row per variant (size, colour...) with its own price, SKU and stock, which suits stock-level tracking.

## `onlyChangedSinceLastRun` (type: `boolean`):

Turn this on and run the Actor on a schedule to get only products whose price, compare-at price or availability changed since the previous run (variant by variant in variant mode). Unchanged products are never delivered or charged. The first run delivers everything and remembers it. A product that disappears (HTTP 404) is delivered once with status "removed". Off by default, so a one-off run returns every product.

## `stateStoreName` (type: `string`):

Only used when change mode is on. Name of the key-value store that remembers the last price and stock seen for each product, so it persists across scheduled runs. Leave the default unless several Actor tasks must keep separate histories. Letters, numbers and hyphens only.

## `discordWebhookUrl` (type: `string`):

Post results to a Discord channel. In Discord: Server Settings -> Integrations -> Webhooks -> New Webhook -> choose the channel -> Copy Webhook URL. Looks like https://discord.com/api/webhooks/<id>/<token>. Leave empty to skip Discord.

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

Post results to Telegram. Create a bot by chatting with @BotFather on Telegram (send /newbot) and paste the token it gives you, e.g. 123456789:AA.... Needs "Telegram chat ID" below. Leave empty to skip Telegram.

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

Where the bot should post: a numeric chat id (e.g. 123456789, or -1001234567890 for a group or channel) or a public @channelusername. The bot must be added to that chat or channel first (as an administrator for a channel), and for a private chat you must press Start on the bot. Needs "Telegram bot token" above.

## `slackWebhookUrl` (type: `string`):

Post results to a Slack channel (Block Kit cards, up to 10 per message). Create an incoming webhook at api.slack.com/apps -> Create New App -> Incoming Webhooks -> Add New Webhook to Workspace. Looks like https://hooks.slack.com/services/T000/B000/XXXX. Leave empty to skip Slack.

## `slackCompatibleWebhookUrl` (type: `string`):

Post results to a Mattermost or Rocket.Chat incoming webhook, which accept Slack-style webhook payloads. Must be a public https:// address (no localhost, private IP or .local/.internal hosts). These apps do not render Slack Block Kit, so they get a plain Markdown list. For real Slack use "Slack webhook URL" above. Leave empty to skip.

## `teamsWebhookUrl` (type: `string`):

Post results to a Teams channel as an Adaptive Card. In Teams: channel -> ... -> Workflows -> "Post to a channel when a webhook request is received" -> copy the URL. The host must end in .logic.azure.com or .powerplatform.com. The retired Office 365 connector webhooks are not supported. Leave empty to skip Teams.

## `googleChatWebhookUrl` (type: `string`):

Post results to a Google Chat space. In the space: Apps & integrations -> Webhooks -> Add webhook -> copy the URL. Looks like https://chat.googleapis.com/v1/spaces/<space>/messages?key=...\&token=.... Leave empty to skip Google Chat.

## `ntfyTopic` (type: `string`):

Get a phone push with no account via ntfy (ntfy.sh). Install the ntfy app, subscribe to a topic name, and enter the same name here. Anyone who knows the topic can read it, so pick a long, unguessable name (letters, numbers, - and \_). At most 10 individual pushes per run, then one "...and N more". Leave empty to skip ntfy.

## `ntfyServer` (type: `string`):

Only used with "ntfy topic". Leave as https://ntfy.sh, or enter the public https:// address of your own ntfy server.

## `ntfyToken` (type: `string`):

Only used with "ntfy topic", for a self-hosted server or a reserved topic that needs a login. An access token from ntfy (starts with tk\_). Leave empty for open topics.

## `pushoverUserKey` (type: `string`):

Get a phone push via Pushover. Your user key is shown on the Pushover dashboard (pushover.net). Needs "Pushover app token" below. At most 10 individual pushes per run, then one "...and N more". Leave empty to skip Pushover.

## `pushoverAppToken` (type: `string`):

The API token of a Pushover application you create at pushover.net/apps/build (name it after this Actor). Needs "Pushover user key" above.

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

POST the delivered rows as JSON to any public https:// URL: {source, actor, runId, count, items}. At most 100 rows per POST; every delivered row is sent regardless of the max-notifications cap. Use it to route results to email, a spreadsheet or any app. Localhost, private IP and .local/.internal addresses are rejected. Leave empty to skip.

## `maxNotifications` (type: `integer`):

Caps how many results are sent to chat and phone channels per run (Discord, Telegram, Slack, Teams, Google Chat, ntfy, Pushover), 1-100, default 25. Extra results are still in the dataset. The generic webhook is not capped. Only used when a destination above is set.

## Actor input object example

```json
{
  "productUrls": [
    "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
    "https://www.allbirds.com/products/mens-canvas-runner-nz"
  ],
  "maxItems": 100,
  "granularity": "product",
  "onlyChangedSinceLastRun": false,
  "stateStoreName": "shopify-product-tracker-state",
  "ntfyServer": "https://ntfy.sh",
  "maxNotifications": 25
}
```

# 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 = {
    "productUrls": [
        "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
        "https://www.allbirds.com/products/mens-canvas-runner-nz"
    ],
    "maxItems": 100,
    "granularity": "product"
};

// Run the Actor and wait for it to finish
const run = await client.actor("lukehunter/shopify-product-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 = {
    "productUrls": [
        "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
        "https://www.allbirds.com/products/mens-canvas-runner-nz",
    ],
    "maxItems": 100,
    "granularity": "product",
}

# Run the Actor and wait for it to finish
run = client.actor("lukehunter/shopify-product-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 '{
  "productUrls": [
    "https://www.allbirds.com/products/womens-allbirds-flip-flop-dusty-pink",
    "https://www.allbirds.com/products/mens-canvas-runner-nz"
  ],
  "maxItems": 100,
  "granularity": "product"
}' |
apify call lukehunter/shopify-product-tracker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,lukehunter/shopify-product-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/f2la7zTSc4Vx35pJh/builds/DEqsMXIJebdLOhkHO/openapi.json
