Price Drop Alerts to Slack or Email with a Scraper
Get price drop alerts in Slack or email: a scheduled change-only scrape, Apify's Slack and Gmail integrations, and a tested webhook handler with a threshold.
Published · Updated · By Omar Eldeeb
To get price drop alerts, run a scraper on a schedule in change-only mode, then notify on the finished run. On Apify: schedule E-commerce Product Scraper Pro with monitorMode and onlyChanges, and either add the built-in Slack or Gmail integration (no code, every run) or a webhook to a small handler that posts only drops above your threshold.
This page covers both, with a webhook handler I tested end-to-end on 2026-10-20 against a real run that contained a real price drop.
Step 1: a run that only outputs changes
The alert is only as good as the diff behind it. Save this as a task (add your own product URLs; the listing URL here is the collection used for the tests below):
{
"listingUrls": [{ "url": "https://www.skullcandy.com/collections/headphones" }],
"maxItemsPerStartUrl": 5,
"maxProductResults": 5,
"monitorMode": true,
"onlyChanges": true
}The first run stores a baseline and returns every product as new. From the second run on, unchanged products aren't written (they're billed as a cheaper "product checked" event), and each changed product row carries changeType (price-down, price-up, back-in-stock, out-of-stock, changed), previousPrice, priceChange and priceChangePct.
I ran exactly this input on 2026-10-20 against a baseline from 2026-10-18 (run heds8zid9ccytKKhQ). It billed 3 products as checked-unchanged and wrote 2 rows: Aviator 900 ANC price-down from $299.99 to $229.99 (−23.33%), and Crusher ANC 2 price-up from $169.99 to $239.99.
Then add a schedule to the task. Apify schedules use cron with a timezone setting; 0 */4 * * * checks every four hours (6 runs a day).
Option A: Slack or Gmail integration, no code
On the task's Integrations tab you can connect:
- Slack. Click Configure on the Slack integration, log in, pick the workspace, channel, events and a message template. The template is Handlebars: per Apify's Slack docs, you can use
userId,eventType,createdAt,eventDataandresource, with dot notation for nested fields. A useful template:Price check finished: {{resource.status}}. Changed products: dataset {{resource.defaultDatasetId}} - Gmail. "Send results email via Gmail" emails the run's results after a successful run and, per the Gmail integration docs, can attach the dataset in several formats. With
onlyChanges, the attachment is exactly the list of changed products.
The limit: both fire on every successful run, including runs where nothing changed or everything went up. Fine for a daily digest, noisy every few hours. For "only tell me about real drops", use option B.
Option B: a webhook to your own handler
An Apify webhook sends an HTTP POST to your URL when an event happens, for example ACTOR.RUN.SUCCEEDED. Add it on the same Integrations tab. Facts from the webhook actions docs that shape the handler:
- The default payload has the keys
userId,createdAt,eventType,eventDataandresource, and for run eventsresourceis the run object, the same as the Get run API returns. Soresource.defaultDatasetIdis where the changed rows are. - Requests time out after 2 minutes, so respond first and do slow work after.
- A non-2xx answer is retried with exponential backoff (about 1, 2, 4 minutes and so on, up to 11 retries).
- A webhook can be delivered more than once, so the handler must be idempotent.
Here is the handler. It reads the run's dataset through the Apify API, keeps price-down rows at or past your threshold plus back-in-stock rows, and posts a Slack message through an incoming webhook. Python standard library only:
"""Receive an Apify webhook, read the run's changed products, post a Slack message. Python 3.9+, stdlib only."""
import json
import os
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
APIFY_TOKEN = os.environ["APIFY_TOKEN"]
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL") # unset = print the message instead
MIN_DROP_PCT = float(os.environ.get("MIN_DROP_PCT", "5"))
seen_runs = set() # Apify may deliver a webhook more than once
def dataset_items(dataset_id):
url = f"https://api.apify.com/v2/datasets/{dataset_id}/items?clean=1"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {APIFY_TOKEN}"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
def alert_lines(rows):
lines = []
for row in rows:
if row.get("recordType") != "product" or "identity-changed" in (row.get("changes") or []):
continue
pct = row.get("priceChangePct")
if row.get("changeType") == "price-down" and pct is not None and -pct >= MIN_DROP_PCT:
lines.append(f"*{row['name']}* {row['previousPrice']:.2f} → {row['price']:.2f} {row['currency']}"
f" ({pct:+.1f}%) <{row['url']}|{row['site']}>")
elif row.get("changeType") == "back-in-stock":
lines.append(f"*{row['name']}* is back in stock at {row['price']} {row['currency']} <{row['url']}|{row['site']}>")
return lines
def slack_payload(run, lines):
text = f"{len(lines)} price alert(s) from run {run['id']}"
return {"text": text, # fallback for notifications
"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
+ [{"type": "section", "text": {"type": "mrkdwn", "text": line}} for line in lines[:45]]}
def send(payload):
if not SLACK_WEBHOOK_URL:
print(json.dumps(payload, indent=1, ensure_ascii=False))
return
req = urllib.request.Request(SLACK_WEBHOOK_URL, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=30).read()
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
run = body["resource"] # the run object, from the default payload
self.send_response(200) # answer fast; Apify times out after 2 minutes
self.end_headers()
if body.get("eventType") != "ACTOR.RUN.SUCCEEDED" or run["id"] in seen_runs:
return
seen_runs.add(run["id"])
lines = alert_lines(dataset_items(run["defaultDatasetId"]))
print(f"run {run['id']}: {len(lines)} alert(s)")
if lines:
send(slack_payload(run, lines))
if __name__ == "__main__":
HTTPServer(("0.0.0.0", int(os.environ.get("PORT", "8000"))), Hook).serve_forever()Notes on the choices:
- Rows with
identity-changedare skipped. If a store starts serving a different product at a watched URL, the actor reports that once instead of a fake price change. Don't alert on it as a drop. - The threshold uses
priceChangePct, which the actor computes against the stored previous price (same currency only). SetMIN_DROP_PCTper channel. lines[:45]keeps the message under Slack's limit of 50 blocks per message (Block Kit reference).seen_runslives in memory. For a long-running service, store processed run IDs in a file or database so a restart doesn't re-alert.
How I tested it (no real Slack needed)
I ran a local mock that prints whatever is POSTed to it, started the handler with SLACK_WEBHOOK_URL=http://127.0.0.1:9001/, and POSTed a webhook body built the same way Apify builds the default payload, with resource set to the real run object of heds8zid9ccytKKhQ. I sent it twice to check duplicate handling. The output of curl, the handler and the mock, with the JSON compacted:
POST 1 -> 200
POST 2 -> 200
run heds8zid9ccytKKhQ: 1 alert(s)
MOCK SLACK RECEIVED: {
"text": "1 price alert(s) from run heds8zid9ccytKKhQ",
"blocks": [
{ "type": "section", "text": { "type": "mrkdwn", "text": "1 price alert(s) from run heds8zid9ccytKKhQ" } },
{ "type": "section", "text": { "type": "mrkdwn",
"text": "*Aviator® 900 ANC* 299.99 → 229.99 USD (-23.3%) <https://www.skullcandy.com/products/aviator-900-anc-headphones|skullcandy.com>" } }
]
}One alert, sent once. The price-up row in the same run was correctly ignored. Leave SLACK_WEBHOOK_URL unset and the handler prints the payload instead, which is the easiest way to try it. To deploy, put it behind HTTPS on any small server or serverless function and paste that URL into the webhook's request URL.
Email instead of Slack
Swap send() for your mail provider's API, or keep Slack and add the Gmail integration for a daily digest. The filtering logic (alert_lines) doesn't change.
What alerts cost
You pay for the runs, not the alerts. From the actor's pricing table (pay per event; FREE and GOLD are Apify plan tiers):
| Event | FREE | GOLD |
|---|---|---|
| Product checked, unchanged | $0.0012 | $0.0002 |
| Product scraped (a changed row) | $0.0048 | $0.0008 |
| Actor start, per run | $0.005 | $0.005 |
The test run above: 1 start + 1 listing page ($0.0004 FREE / $0.00021 GOLD) + 2 changed rows + 3 checks = $0.0186 on FREE, $0.00741 on GOLD. For 200 product URLs checked every four hours with nothing changing, one run is 200 × $0.0012 + $0.005 = $0.245 on FREE or 200 × $0.0002 + $0.005 = $0.045 on GOLD, times 6 runs a day. Each product that does change costs $0.0036 more on FREE ($0.0006 on GOLD) than an unchanged check. Sites reached through residential proxies also bill traffic per MB (see the README). Apify's free plan includes $5 of usage a month with no credit card; the actor has no in-actor free trial.
During Black Friday and the holiday season
Two things change in the sale window. You'll want faster checks, and you'll get far more alerts. A temporary schedule 0 * 23-30 11 * runs hourly from Monday 23 November to Monday 30 November 2026 (Cyber Monday): 192 runs in America/New_York. For 200 unchanged products that is 192 × $0.245 = $47.04 on FREE or 192 × $0.045 = $8.64 on GOLD, before change rows. Raise MIN_DROP_PCT to 15-20% for that week, or the channel turns into a feed, and remember a "drop" is measured against your stored previous price, not the retailer's "was" price. Start the baseline in October so that comparison means something. Timing by retailer is in the holiday sales calendar, and how to spot fake Black Friday discounts covers the baseline maths.
Related: competitor price monitoring (watchlists and GTIN matching), back-in-stock alerts, and the e-commerce price tracker query builder, which assembles the monitor input for you.
Disclosure: I built the actor and this site. The webhook handler works with any job that writes rows with a previous and current price.
Sources: Apify docs: webhook actions (payload, retries, timeout, duplicates), Apify docs: webhook events, Apify docs: Slack integration, Apify docs: Gmail integration, Apify docs: Schedules, Slack: sending messages using incoming webhooks, Slack Block Kit blocks, and the actor's README.
Quick answers
How do I get an alert when a price drops on a website?
Schedule a scraper to re-check the product page, compare each price with the last one it saw, and send a message only when the new price is lower by more than your threshold. Browser extensions do this for single products on a few big stores; a scheduled scraper does it for any list of supported pages and sends the alert where your team works.
Can Apify send a Slack message when a run finishes?
Yes. The Slack integration on an Actor's or task's Integrations tab posts a templated message to a channel on events you pick, such as run succeeded. The template can use run fields like {{resource.defaultDatasetId}}, but it can't filter rows, so use a webhook to your own code if you only want messages about real drops.
Why did I get the same price alert twice?
Apify's docs say a webhook can occasionally be delivered more than once, and two overlapping runs of the same watchlist can both report the same change. Remember the run IDs you've already processed and don't schedule runs of the same list so close together that they overlap.
What percentage price drop should trigger an alert?
Start around 5-10% for regular monitoring so rounding and small promotions don't flood the channel, and lower it only for the few products where any cut matters. During big sale weeks, raise it, because many products move at once.