# Promotions & Launch Monitor: Sales, Codes & New Products (`cleanpull/promotions-monitor`) Actor

Watch competitor stores for sale banners, discount codes, free-shipping thresholds, markdowns and new products or collections. Shopify feeds plus any store's pages; a promotion ends only after two runs without it. Changes-only watchlists, compact JSON for AI agents.

- **URL**: https://apify.com/cleanpull/promotions-monitor.md
- **Developed by:** [CleanPull](https://apify.com/cleanpull) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.60 / 1,000 promotion or launches

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## Promotions & Launch Monitor: Sales, Codes & New Products

Give it **competitor store addresses** and get every promotion they show: **sale banners, discount codes, free-shipping thresholds, bundles, gifts, sign-up offers and markdowns**, plus **new products and collections** with the date each went live. It reads **Shopify stores' public product and collection feeds** and the **pages of any store** (WooCommerce, Magento, Salesforce Commerce Cloud, PrestaShop, BigCommerce and custom). Schedule it and every run says **what started, what ended and what launched since the last one**. A promotion counts as ended only after **two runs in a row without it**, so rotating banners are never reported as ended. From **$1.00 per 1,000 pages checked**.

### What you can do with it

- **"Tell me when a competitor starts or ends a sale."** Put their stores in a watchlist, turn on Changes only and schedule it daily: you get the new promotions, the ones that ended (with their last sighting) and one change summary per store.
- **"What codes and free-shipping thresholds are my competitors showing?"** Every code printed on the site, and every free-shipping offer with its amount and currency ($35+, over £50, dès 60 €).
- **"How deep are their markdowns?"** On Shopify, the share of the newest products priced below their compare-at price and the deepest cuts; elsewhere, the crossed-out prices on a sale page.
- **"What did they launch this week?"** Shopify products and collections with their publish date; on other stores, product links new on the pages you watch.
- **"Feed a digest or an AI agent."** Compact JSON with the same fields for every store, a field picker, and a dataset view per job.

### How it reads a store

1. **The home page** (or the country site you give, `brand.com/us/`): its announcement bar, header, hero banners (including their image alt text) and the rest of the page. Menus, footers and pop-ups count only when a text names a code, a threshold or a discount.
2. **Shopify stores:** the store's public feeds, `/products.json` (the newest published products, with prices and compare-at prices) and `/collections.json`.
3. **Extra pages you add:** a sale page (its crossed-out prices are a markdown), a new-arrivals page (new product links are launches), a product page (its structured sale price is a markdown).

Each promotion has a `confidence`: **high** in an announcement bar, the header or the store's own data; **medium** elsewhere on the page, in menus or in image alt text.

In tests on 26 stores on 9 platforms, read from Apify's servers, it read **26 of 26**. It found an announcement-bar promotion on **13**, a free-shipping threshold on **16** and a discount code on **7**, and launches with dates on all **7** Shopify stores that publish their feed. Read twice, minutes apart, 27 pages showed **no false changes**. One rotating bar (Old Navy) swapped a promotion between runs, and nothing was reported as ended.

### What makes the data right

- **Ended means ended.** A promotion missing once is only counted in the run summary; it is reported as ended when the next run misses it too. A page that fails to load never counts as the promotion missing.
- **One promotion stays one promotion.** A code is the promotion whatever words surround it; a free-shipping offer is its threshold; countdowns and dates in a banner ("ends in 2h 13m") do not make a new one.
- **Not every "sale" is a promotion.** Menu links to sale sections, "Sale price" lines on product cards, cart messages ("spend $20 more for free shipping") and privacy text are left out. Codes are read only as stores print them, in capitals after "code".
- **A block is never an empty result.** Each store gets its own proxy ladder (direct, then datacenter, then residential only for stores that block the others); a store that blocks every tier is named in the run summary, with the reason, and not charged.
- **Launches are real.** On Shopify, a product counts as launched when it is new to the watchlist and went live since the last run, dated by the store. On other stores, the first read of a page records its product links as a baseline, and a new link becomes a launch only when two runs in a row show it, so a rotating carousel never reads as a launch.
- **Only delivered rows are billed.** Pages that could not be read cost nothing; the change summary is free; a run stopped by a limit is checked in full next time, and rows already delivered are never charged twice, even after a restart.
- **Only public addresses are read.** A URL or redirect pointing into a private network is never fetched.

### Input

| Field                                            | What it does                                                                                               |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `stores`                                         | Store addresses, one per line (brand.com, or brand.com/us/ for a country site)                             |
| `pages`                                          | Optional. Other pages of those stores: a sale page, new arrivals, a product page (up to 10 each)           |
| `promotionTypes`                                 | Which promotions to report: sitewide-sale, sale, code, free-shipping, bundle, gift, signup-offer, markdown |
| `includeLaunches`, `includeCollections`          | New products and (Shopify) new collections                                                                 |
| `launchWindowDays`                               | First run: Shopify launches within this many days (default 7)                                              |
| `maxProductsPerStore`, `maxLaunchesPerStore`     | Shopify products read (default 100) and launch rows per store per run (default 25)                         |
| `changesOnly`, `monitorName`                     | Only what changed since the last run with the same watchlist name                                          |
| `maxRunCostUsd`                                  | Optional. Max cost per run, for scheduled runs without a spending limit of their own                       |
| `fields`, `maxConcurrency`, `proxyConfiguration` | Field selection, stores at a time (default 3) and your own proxy                                           |
| `startUrls`                                      | Accepted for compatibility with other actors (same as `stores`)                                            |

#### Example inputs

A competitor watchlist, checked daily for changes:

```json
{
  "stores": ["https://www.tentree.com", "https://www.zwilling.com/us/", "colourpop.com"],
  "changesOnly": true,
  "monitorName": "competitors"
}
```

A store and its sale page, codes and free shipping only:

```json
{
  "stores": ["https://www.zwilling.com/us/"],
  "pages": ["https://www.zwilling.com/us/sale/"],
  "promotionTypes": ["code", "free-shipping", "markdown"]
}
```

What Shopify stores launched in the last 30 days:

```json
{
  "stores": ["kith.com", "colourpop.com"],
  "launchWindowDays": 30,
  "promotionTypes": [],
  "maxLaunchesPerStore": 200
}
```

### Output

Three kinds of rows, told apart by `recordType`. A real promotion row:

```json
{
  "recordType": "promotion",
  "store": "tentree.com",
  "storeUrl": "https://www.tentree.com/",
  "platform": "shopify",
  "promotionId": "015303d778fcb51bef4535a04dd3d6a40b05da1774f82bc88ecdcb755008149e",
  "type": "code",
  "status": "running",
  "text": "FREE SHIPPING ENDS MONDAY. CODE: FREESHIP",
  "code": "FREESHIP",
  "threshold": null,
  "thresholdCurrency": null,
  "percentOff": null,
  "amountOff": null,
  "confidence": "high",
  "foundIn": "bar",
  "pageUrl": "https://www.tentree.com/",
  "firstSeen": "2026-10-14T23:19:26.522Z",
  "lastSeen": "2026-10-14T23:19:26.522Z",
  "endedAt": null,
  "firstRun": true,
  "checkedAt": "2026-10-14T23:19:26.522Z"
}
```

`status` is `new` (first seen since the last run), `running` (seen again, or on a watchlist's first run, which also has `"firstRun": true`) or `ended` (missing two runs in a row; `lastSeen` is its last sighting). Free-shipping rows carry `threshold` and `thresholdCurrency` (from €, £ or an ISO code in the text, else the store's own currency when the symbol fits it; a bare $ on a store with no stated currency stays null); markdown rows carry `markdown` (on Shopify: products read, discounted, `sharePercent`, `maxPercentOff` and the ten deepest cuts with their prices).

A launch row:

```json
{
  "recordType": "launch",
  "store": "kith.com",
  "platform": "shopify",
  "kind": "product",
  "title": "Giorgio Armani & Kith for ASICS GEL-MAI 0.1 - Monument / Quarry",
  "url": "https://kith.com/products/ai1203b454-400-dr",
  "launchedAt": "2026-10-14T13:52:49-04:00",
  "createdAt": "2026-10-14T13:52:46-04:00",
  "relaunch": false,
  "confidence": "high",
  "source": "shopify-feed",
  "price": 300,
  "compareAtPrice": null,
  "currency": "USD",
  "vendor": "Kith",
  "checkedAt": "2026-10-14T23:19:21.978Z"
}
```

`kind` is `product` or `collection`; `source` is `shopify-feed` (dated by the store) or `page-diff` (a product link shown on two runs in a row, dated when first seen). `relaunch` marks a product published more than 30 days after it was created.

From the second run on, each store with something new also gets one free `change` row: `started` and `ended` promotions, `launches` with their titles, `freeShippingThreshold` when it moved, and `markdownSharePercent` when the markdown share moved by 10 points or more.

Dataset views: **Promotions**, **Launches** and **Changes**. `RUN_SUMMARY` in the key-value store gives the outcome (`COMPLETE`, `PARTIAL`, `NO_CHANGES`, `EMPTY`, `FAILED`), per store the platform, pages read, promotions seen, started, ended and missing once, launches and residential requests, the charges, and every page that could not be read, with the reason.

### Pricing

| Event                                                                                 | Free and Bronze | Silver | Gold and above |
| ------------------------------------------------------------------------------------- | --------------- | ------ | -------------- |
| Promotion or launch (a delivered row)                                                 | $2.00           | $1.80  | $1.60          |
| Page checked (a home page with its Shopify feeds, or an extra page)                   | $1.00           | $0.90  | $0.80          |
| Request through residential addresses (add-on, only for stores that block the others) | $6.00           | $6.00  | $6.00          |

Per 1,000. Each run also has a $0.00005 start fee. The change summary rows are free. A watchlist of 20 competitor stores checked daily with Changes only is about 600 page checks a month ($0.60), plus the promotions and launches that changed. Requests through residential addresses are capped at 500 KB each (a larger one is not read or charged).

### Limits

- **Bars rendered only by scripts** are not read (the actor reads the page's HTML, without a browser): Kith's and Nike's, for example.
- **Codes** are read when the site prints them; codes sent by email, behind a pop-up click or in a cart are not.
- **Launches on stores without a Shopify feed** come from product-shaped links (`/products/`, `/product/`, `/p/`, numbered `.html` pages); stores whose product addresses look like categories give no launches.
- **Shopify feeds** are read only where the store publishes them (headless fronts often do not: their launches come from product links).
- **Prices and promotions** are those a visitor from the proxy's country sees, without a sign-in.
- **Stores that block every proxy tier** (Uniqlo, Crocs, Adidas in tests) are listed in the run summary and not charged; Amazon, Walmart and Target are not read.
- Read stores at a pace their terms allow: at most two requests at a time per store.
- **Nothing found**: when nothing could be read because it is not there (a store whose home page is not there or sends shoppers to another site, or input naming no store), the run still succeeds, with one free `recordType: "unreadable"` row per item giving the `reason`; nothing is charged. A run near its time limit stops starting stores, delivers what it read and lists the rest the same way.

### Changelog

- **0.1.x, reliability** (2026-10-24): runs that find nothing (a store whose home page is not there or sends shoppers to another site, or input naming no store) end SUCCEEDED with a free `unreadable` row per item saying why, instead of FAILED; a run stops starting stores before its time limit and ends with what it read, never TIMED-OUT. A run where the site refused on every route for every store still fails. Prices and events unchanged.
- **0.1.x** (2026-10-14): RUN_SUMMARY `perStore` says whether each store was delivered in full (`complete`); a store whose page check could not be paid for no longer counts in `storesRead`.
- **0.1** (October 2026): promotions from announcement bars, headers, banners and image alt text (sales, codes, free-shipping thresholds, bundles, gifts, sign-up offers); Shopify product and collection feeds for markdowns and dated launches; sale and product pages; launches from new product links; watchlists with changes only, ended after two runs; Max cost per run.

# Actor input Schema

## `stores` (type: `array`):

Store addresses, one per line (brand.com, or brand.com/us/ for a country site). The home page is read for its announcement bar, banners and offers; on Shopify, the store's public product and collection feeds add markdowns and launches with their dates.

## `pages` (type: `array`):

Optional. Other pages of those stores to read too, one per line: a sale page, a new-arrivals page, or a product page (its structured sale price counts as a markdown). Up to 10 per store; each is one page check.

## `promotionTypes` (type: `array`):

Which kinds of promotion to report and remember.

## `includeLaunches` (type: `boolean`):

Report new products (and, on Shopify, new collections). Shopify: from the store's feed, with the date each went live (high confidence). Other stores: product links new on the pages read since the last run (medium confidence; the first run is the baseline).

## `includeCollections` (type: `boolean`):

Also read Shopify's collections feed for new collections (one to four small requests per store).

## `launchWindowDays` (type: `integer`):

On a watchlist's first run, Shopify products and collections published within this many days are reported as launches. Later runs report what went live since the last run.

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

How many of a Shopify store's newest products to read from its feed (up to 250 a request). Launches and the markdown share come from these.

## `maxLaunchesPerStore` (type: `integer`):

The most launch rows one store gives in a run; the rest are reported on the next run.

## `changesOnly` (type: `boolean`):

Return only what changed since the last run with the same watchlist name: new promotions, promotions that ended, and launches, plus one change summary per store. A promotion counts as ended only after two runs in a row without it, so rotating banners are not reported as ended. The first run is the baseline. Schedule it daily.

## `monitorName` (type: `string`):

Runs with the same name compare against each other. Use one name per watchlist.

## `maxRunCostUsd` (type: `number`):

Optional. Stop once this run's charges would pass this amount. Use it for scheduled runs, which may have no spending limit of their own. Stores not finished are checked in full on the next run.

## `fields` (type: `array`):

Optional. Keep only these fields, for example \["store", "type", "status", "text", "code", "threshold"].

## `maxConcurrency` (type: `integer`):

Stores read in parallel. Each store gets at most two requests at a time.

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

Optional. The actor picks proxies itself per store: direct, then datacenter, then residential only for stores that block the others.

## `startUrls` (type: `array`):

Optional. Accepted for compatibility with other actors: store addresses, same as Stores.

## Actor input object example

```json
{
  "stores": [
    "https://www.tentree.com",
    "https://www.zwilling.com/us/"
  ],
  "promotionTypes": [
    "sitewide-sale",
    "sale",
    "code",
    "free-shipping",
    "bundle",
    "gift",
    "signup-offer",
    "markdown"
  ],
  "includeLaunches": true,
  "includeCollections": true,
  "launchWindowDays": 7,
  "maxProductsPerStore": 100,
  "maxLaunchesPerStore": 25,
  "changesOnly": false,
  "monitorName": "default",
  "maxConcurrency": 3
}
```

# Actor output Schema

## `results` (type: `string`):

Promotion, launch and change rows.

## `promotions` (type: `string`):

No description

## `launches` (type: `string`):

No description

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

No description

## `summary` (type: `string`):

Outcome (COMPLETE, PARTIAL, NO_CHANGES, EMPTY, FAILED), counts per store, charges and every page that could not be read, with the reason.

# 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 = {
    "stores": [
        "https://www.tentree.com",
        "https://www.zwilling.com/us/"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("cleanpull/promotions-monitor").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 = { "stores": [
        "https://www.tentree.com",
        "https://www.zwilling.com/us/",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("cleanpull/promotions-monitor").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 '{
  "stores": [
    "https://www.tentree.com",
    "https://www.zwilling.com/us/"
  ]
}' |
apify call cleanpull/promotions-monitor --silent --output-dataset

```

## MCP server setup

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

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/dlC9pHEGI61tn2nwc/builds/N6gYaYPOX2G3sQDrn/openapi.json
