E-commerce and scraper monitoring: UptimeRobot vs custom scripts for checkout pages, price feeds and cron jobs
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
Use an external service such as UptimeRobot for anything a shopper would notice: storefront uptime, a keyword monitor that alerts when the Add to cart button disappears, an API monitor that checks the product JSON has a price above zero, and status monitors for payment providers. Use your own scripts for what only you can judge: whether a scraper's output is complete and plausible, and whether the checkout flow works end to end. Connect the two with heartbeat monitors: each script pings UptimeRobot only when it succeeds, so a crashed script, a dead server or a run that produced bad data all raise the same alert. Heartbeat, API and SSL monitors need a paid UptimeRobot plan; Solo starts at $9 a month billed yearly.
A store can be "up" and still not take money. The homepage returns 200 while a theme update has removed the Add to cart button, a price feed returns zeros, or the payment provider is down. Price scrapers fail even more quietly: the cron job runs, exits cleanly and writes 400 rows of nothing because the target changed its layout. This guide splits monitoring into what an external service does best and what only your own code can check, then joins them so every failure ends in the same alert.
UptimeRobot's plans, limits and API fields come from its pricing page, its machine-readable pricing file and its v3 OpenAPI specification, checked in October 2026. The three scripts were tested against a local mock of the UptimeRobot v3 API, heartbeat and Slack-style webhook endpoints, and a mock store, with Playwright driving Microsoft Edge. They weren't run against a live UptimeRobot account. Detection times and costs are our calculations from the published check intervals and prices.
1. What to monitor, and who should do it
Our rule of thumb: if a shopper would notice it, check it from outside; if only you can tell whether it's right, check it in code, then report to the outside. A monitor running on the same server as your store or scraper dies with it, so the alerting has to live somewhere else.
| Failure | What catches it | Why |
|---|---|---|
| Store down, DNS or SSL broken | UptimeRobot HTTP monitor, SSL and domain expiry | Needs to run from outside, from more than one location |
| Page returns 200 but is broken | UptimeRobot keyword monitor | An error page or a theme without the buy button still returns 200 |
| Prices show as 0 or variants are missing | UptimeRobot API monitor with JSON assertions | Reads the product JSON your storefront already serves |
| Payment provider outage | UptimeRobot dependency monitor | You can't fix it, but you need to know before support tickets arrive |
| Checkout flow broken by an app or script | Custom Playwright probe + heartbeat | Needs a real browser and clicks; an HTTP check can't add to cart |
| Scraper crashed, cron didn't run, server dead | Heartbeat monitor | Silence is the signal: no ping, no "OK" |
| Scraper ran but the data is wrong | Custom output guard + heartbeat | Only your code knows what plausible data looks like |
| Competitor changed a price | Your scraper, reported by the guard | A business event, not an outage: don't page anyone for it |
2. The UptimeRobot side, as code
Clicking monitors together in a dashboard works for five monitors. Past that, keep them in a file in your repository and sync it with the API, so a new product line or a new store is a pull request. UptimeRobot's v3 API uses a bearer token, and the Platform API is available on every plan, including Free. These are the monitors we'd start with for a Shopify store and one daily price scraper:
[
{"friendlyName": "Storefront", "type": "HTTP", "url": "https://shop.example.com/",
"interval": 60, "timeout": 30},
{"friendlyName": "Product page: Add to cart button", "type": "KEYWORD",
"url": "https://shop.example.com/products/blue-mug", "interval": 300, "timeout": 30,
"keywordType": "ALERT_NOT_EXISTS", "keywordCaseType": "CaseInsensitive", "keywordValue": "Add to cart"},
{"friendlyName": "Product JSON: price above zero", "type": "API",
"url": "https://shop.example.com/products/blue-mug.js", "interval": 300, "timeout": 30,
"config": {"apiAssertions": {"logic": "AND", "checks": [
{"property": "$.price", "comparison": "greater_than", "target": 0},
{"property": "$.variants", "comparison": "is_not_empty"}]}}},
{"friendlyName": "Stripe status", "type": "DEPENDENCY", "url": "https://status.stripe.com",
"interval": 300, "timeout": 30, "config": {"dependency": {"providerUrlname": "stripe"}}},
{"friendlyName": "Price scraper: daily run", "type": "HEARTBEAT",
"interval": 86400, "gracePeriod": 3600},
{"friendlyName": "Checkout probe: every 15 min", "type": "HEARTBEAT",
"interval": 900, "gracePeriod": 600}
]
- Keyword monitor with
ALERT_NOT_EXISTS. It alerts when "Add to cart" disappears from the HTML. That catches a broken theme, a product that went unavailable and an error page served with status 200. The keyword has to be in the server-rendered HTML; if your theme renders the button with JavaScript, use the API monitor or the checkout probe instead. - API monitor on
/products/<handle>.js. Shopify serves every product as JSON at that address, with the price in cents. The assertions fail if the price drops to zero or the variants list is empty, which is what a botched CSV import looks like. - Dependency monitor. Watches a third-party status page, here Stripe's. This is a paid-plan feature, with 1 to 5 services on Solo.
- Two heartbeat monitors. The interval is how often a ping is expected and the grace period is how late it may be. A daily scraper with a one-hour grace period alerts about 25 hours after the last good run.
The sync script creates missing monitors, updates changed ones and reports monitors that aren't in the file without deleting them:
#!/usr/bin/env python3
"""Keep UptimeRobot monitors in a JSON file and sync them through the v3 API.
Python 3.9+, standard library only.
export UPTIMEROBOT_API_KEY=...
python3 monitors.py monitors.json # show what would change
python3 monitors.py monitors.json --apply # create and update monitors
"""
import json
import os
import sys
import urllib.error
import urllib.request
API = os.environ.get('UPTIMEROBOT_API', 'https://api.uptimerobot.com/v3')
KEY = os.environ['UPTIMEROBOT_API_KEY']
def call(method, url, body=None):
data = json.dumps(body).encode() if body is not None else None
headers = {'Authorization': 'Bearer ' + KEY, 'Content-Type': 'application/json'}
req = urllib.request.Request(url if url.startswith('http') else API + url, data, headers, method=method)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def existing():
"""All monitors in the account, by friendly name. The API pages through them with nextLink."""
out, url = {}, '/monitors?limit=200'
while url:
page = call('GET', url)
for m in page['data']:
out[m['friendlyName']] = m
url = page.get('nextLink')
return out
def same(have, want):
"""True if `want` matches `have`, ignoring extra keys the API adds to nested objects."""
if isinstance(want, dict):
return isinstance(have, dict) and all(same(have.get(k), v) for k, v in want.items())
return have == want
def main():
wanted = json.load(open(sys.argv[1]))
apply = '--apply' in sys.argv
try:
current = existing()
except urllib.error.HTTPError as e:
sys.exit('Listing monitors failed: HTTP %d %s' % (e.code, e.read()[:300].decode('utf-8', 'replace')))
errors = 0
for spec in wanted:
name = spec['friendlyName']
cur = current.get(name)
try:
if cur is None:
print('create %s (%s)' % (name, spec['type']))
if apply:
m = call('POST', '/monitors', spec)
if m.get('heartbeatUrl'):
print(' heartbeat URL: ' + m['heartbeatUrl'])
continue
if str(cur.get('type')).upper() != spec['type'].upper():
print('SKIP %s: type changed to %s; delete it and run again' % (name, spec['type']))
continue
diff = {k: v for k, v in spec.items() if k != 'type' and not same(cur.get(k), v)}
if diff:
print('update %s: %s' % (name, ', '.join(sorted(diff))))
if apply:
call('PATCH', '/monitors/%s' % cur['id'], diff)
else:
print('ok %s (%s)' % (name, cur.get('status')))
except urllib.error.HTTPError as e:
errors += 1
print('ERROR %s: HTTP %d %s' % (name, e.code, e.read()[:300].decode('utf-8', 'replace')))
# Monitors that exist in the account but not in the file are reported, never deleted
for name in sorted(set(current) - {s['friendlyName'] for s in wanted}):
print('extra %s (not in %s)' % (name, sys.argv[1]))
sys.exit(1 if errors else 0)
if __name__ == '__main__':
main()
A dry run against our mock API, with one existing monitor whose interval differs from the file:
$ python3 monitors.py monitors.json
update Storefront: interval
create Product page: Add to cart button (KEYWORD)
create Product JSON: price above zero (API)
create Stripe status (DEPENDENCY)
create Price scraper: daily run (HEARTBEAT)
create Checkout probe: every 15 min (HEARTBEAT)
extra Old staging (not in monitors.json)
With --apply it prints each heartbeat monitor's URL, which goes into the scripts below. A second run reports every monitor as ok. The comparison ignores extra fields the API adds inside nested objects, so it doesn't report changes that aren't there.
3. The custom side: guard the scraper's output
Most scraper failures aren't crashes. The target changes a class name and the parser returns None for every price, or a geo redirect serves yen instead of dollars and every price jumps by 15,000%. The guard below runs after each scraper run and compares today's output with the previous run.
#!/usr/bin/env python3
"""Check a price scraper's output before you trust it, report price changes,
and ping an UptimeRobot heartbeat only when the run looks healthy.
Python 3.9+, standard library only.
export HEARTBEAT_URL=https://heartbeat.uptimerobot.com/... # from the heartbeat monitor
export ALERT_WEBHOOK=https://hooks.slack.com/services/... # optional, Slack-style webhook
python3 scrape_guard.py prices-today.jsonl prices-yesterday.jsonl
Each line of the files is a JSON object with at least "sku" and "price".
"""
import json
import os
import sys
import time
import urllib.request
MIN_ROWS = 0.8 # today needs at least 80% of yesterday's rows
MAX_MISSING = 0.05 # at most 5% of rows without a usable price
CHANGE = 0.05 # report price moves of 5% or more
SPIKE = 0.5 # moves of 50% or more are more often parser bugs than real prices
MAX_SPIKES = 0.1 # if over 10% of rows spike at once, treat the run as broken
def load(path):
if not os.path.exists(path):
return {}
rows = (json.loads(line) for line in open(path, encoding='utf-8') if line.strip())
return {str(r['sku']): r for r in rows}
def valid(price):
return isinstance(price, (int, float)) and not isinstance(price, bool) and price > 0
def send(url, payload=None, tries=3):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(url, data, {'Content-Type': 'application/json'})
for i in range(tries):
try:
urllib.request.urlopen(req, timeout=20).close()
return True
except OSError as e:
print('%s: %s' % (url.split('?')[0], e), file=sys.stderr)
time.sleep(2 ** i)
return False
def main():
today, prev = load(sys.argv[1]), load(sys.argv[2])
problems, changes, spikes = [], [], []
if not today:
problems.append('no rows in %s' % sys.argv[1])
elif prev and len(today) < MIN_ROWS * len(prev):
problems.append('%d rows today, %d yesterday' % (len(today), len(prev)))
missing = [s for s, r in today.items() if not valid(r.get('price'))]
if today and len(missing) > MAX_MISSING * len(today):
problems.append('%d of %d rows have no usable price, e.g. %s'
% (len(missing), len(today), missing[:3]))
for sku, row in today.items():
old, new = prev.get(sku, {}).get('price'), row.get('price')
if not (valid(old) and valid(new)):
continue
move = (new - old) / old
if abs(move) >= SPIKE:
spikes.append('%s %.2f -> %.2f (%+.0f%%, check it)' % (sku, old, new, move * 100))
elif abs(move) >= CHANGE:
changes.append('%s %.2f -> %.2f (%+.0f%%)' % (sku, old, new, move * 100))
if today and len(spikes) > MAX_SPIKES * len(today):
problems.append('%d prices moved 50%% or more at once: currency, page layout or parser change?'
% len(spikes))
lines = ['Scraper run FAILED: ' + p for p in problems]
if changes or spikes:
lines.append('%d price changes:' % (len(changes) + len(spikes)))
lines += changes[:30] + spikes[:30]
report = '\n'.join(lines)
if report:
print(report)
if os.environ.get('ALERT_WEBHOOK'):
send(os.environ['ALERT_WEBHOOK'], {'text': report})
if problems:
# No ping: UptimeRobot alerts when the heartbeat is overdue, even if this
# script, the scraper or the whole server never runs again.
sys.exit(1)
if os.environ.get('HEARTBEAT_URL') and not send(os.environ['HEARTBEAT_URL']):
sys.exit(2)
print('healthy: %d rows, %d price changes' % (len(today), len(changes) + len(spikes)))
if __name__ == '__main__':
main()
Run it right after the scraper in the same cron entry, then rotate the files:
15 6 * * * cd /srv/prices && python3 scrape.py > prices-today.jsonl \
&& python3 scrape_guard.py prices-today.jsonl prices-yesterday.jsonl \
&& mv prices-today.jsonl prices-yesterday.jsonl
The && chain matters: a failed guard leaves yesterday's file in place, so tomorrow's run compares against the last good data instead of the broken run. Results from our test, against a 100-SKU baseline:
| Test input | Result | Heartbeat |
|---|---|---|
| 3 normal price changes, one 10× jump, one missing price | Healthy; 4 changes posted, the 10× jump flagged "check it" | Pinged |
| 30 of 100 prices missing (parser broke) | FAILED: 30 of 100 rows have no usable price | Not pinged |
| Every price × 155 (USD page served in JPY) | FAILED: 100 prices moved 50% or more at once | Not pinged |
| 50 rows instead of 100 (pagination broke) | FAILED: 50 rows today, 100 yesterday | Not pinged |
| First run, no previous file | Healthy, 0 changes | Pinged |
| Heartbeat endpoint unreachable | Three retries, then exit code 2 | Failed, logged |
Tune the thresholds to your catalog. A store running a 60%-off clearance sale will trip the spike rule. That's deliberate: a human should glance at a jump that large before it reaches a repricing rule. If you reprice automatically from scraped data, the guard is the cheapest insurance you'll buy. For the scraper itself, see monitoring Shopify competitor prices and geo-targeted price checks, which explains the currency mix-ups this guard catches.
4. The custom side: a synthetic checkout probe
UptimeRobot checks one URL at a time. It can't add a product to the cart and reach checkout, which is the path that breaks when an app injects a script or a theme update changes a form. A short Playwright script can, and it reports through the same heartbeat mechanism:
#!/usr/bin/env python3
"""Synthetic checkout check: product page -> add to cart -> cart -> checkout page loads.
It never fills in payment details or places an order. Pings a heartbeat on success.
pip install playwright && playwright install chromium
export HEARTBEAT_URL=https://heartbeat.uptimerobot.com/...
python3 checkout_probe.py https://shop.example/products/blue-mug
The default selectors match Shopify's Dawn theme. Override them for other themes.
"""
import os
import sys
import urllib.request
from playwright.sync_api import sync_playwright
ADD = os.environ.get('ADD_SELECTOR', 'button[name="add"]')
CHECKOUT = os.environ.get('CHECKOUT_SELECTOR', 'button[name="checkout"]')
CART = os.environ.get('CART_PATH', '/cart')
READY = os.environ.get('CHECKOUT_READY', 'input[type="email"], input[name="email"]')
def main():
product = sys.argv[1]
step = 'open product page'
with sync_playwright() as p:
browser = p.chromium.launch(channel=os.environ.get('PW_CHANNEL'))
page = browser.new_page()
page.set_default_timeout(30000)
try:
page.goto(product, wait_until='domcontentloaded')
step = 'add to cart'
page.click(ADD)
page.wait_for_load_state('networkidle')
step = 'open cart'
page.goto(product.split('/products/')[0] + CART, wait_until='domcontentloaded')
step = 'go to checkout'
page.click(CHECKOUT)
step = 'checkout form'
page.wait_for_selector(READY)
except Exception as e:
page.screenshot(path='checkout-fail.png', full_page=True)
print('FAILED at "%s": %s' % (step, str(e).splitlines()[0]), file=sys.stderr)
sys.exit(1) # no heartbeat, so UptimeRobot alerts after the grace period
finally:
browser.close()
if os.environ.get('HEARTBEAT_URL'):
urllib.request.urlopen(os.environ['HEARTBEAT_URL'], timeout=20).close()
print('checkout reachable')
if __name__ == '__main__':
main()
In our test against a mock store, the healthy path reached the checkout form and pinged the heartbeat. A product page without the button failed at "add to cart" after the 30-second timeout, saved a full-page screenshot and sent no ping.
- It stops at the checkout form. It never enters payment details or places an order. Test payments belong in your payment provider's test mode, not in production monitoring.
- It adds a cart every 15 minutes. That's about 2,900 abandoned carts a month in your analytics. Filter the probe out by its IP or user agent, and exclude it from abandoned-cart emails; see our abandoned cart workflow.
- Run it somewhere other than the store's own infrastructure, such as a small VPS that also runs the scraper. Chromium needs roughly 300–500 MB for one page, by our estimate.
- Selectors are theme-specific. The defaults match Shopify's Dawn theme. Set
ADD_SELECTOR,CHECKOUT_SELECTORandCHECKOUT_READYfor other themes and platforms.
5. What UptimeRobot costs for this setup
| Plan | Price | Monitors | Interval | What matters here |
|---|---|---|---|---|
| Free | $0 | 50 | 5 min | HTTP, keyword, ping and port only. No heartbeat, API, SSL or dependency monitors. UptimeRobot describes it as for hobby and non-profit projects. |
| Solo | $10/month, or $9 billed yearly | 10 (a 50-monitor tier costs more) | 60 s | Adds heartbeat, API, SSL and domain expiry, DNS, maintenance windows, Slack and Telegram alerts, 1–5 dependency monitors |
| Team | $41/month, or $35 billed yearly | 100 | 30 s | Webhook, Zapier and PagerDuty integrations, 3 login seats, incident comments |
| Scale | $77/month, or $65 billed yearly | 200 (a 500-monitor tier costs more) | 15 s | Everything in Team, 5 seats, unlimited status pages |
Prices from UptimeRobot's pricing page, October 2026. The setup in section 2 uses six monitors, so Solo is the right plan: the free plan has no heartbeat monitors, and without them neither script can raise an alert when it stops running. Webhook alerts need Team, but Solo's Slack integration covers the alerts in this guide.
Detection time is where the free plan costs money. Example, with your own numbers in place of ours: a store doing $20,000 a month takes about $27 an hour on average. A five-minute interval detects an outage on average 2.5 minutes in, a 60-second interval after 30 seconds. That gap is small per incident; it's the alerting on broken checkouts and silent scrapers, not the faster interval, that pays for Solo.
6. Our opinion: build vs buy
- Buy the external checks. Multi-location probing, SMS and voice alerts, status pages and SSL expiry are commodity features. Rebuilding them costs more in your time than a year of Solo.
- Build the data checks. No monitoring service knows that your competitor's prices can't all rise 15,000% overnight. A 100-line guard does.
- Self-hosted monitoring is a valid choice, but only on a separate server, in a different data center, from what it monitors. Uptime Kuma on a $4–6 VPS works well if someone owns its updates. Then something has to monitor that server too, which is where many teams give up and buy.
- Don't monitor competitors with keyword monitors. It works for a handful of SKUs:
ALERT_NOT_EXISTSon "$24.00" alerts when the price changes. But it hits their site every minute, breaks on A/B tests and currency switching, and mixes business events into your outage alerts. A daily scraper with the guard is politer and more accurate. - Route alerts by urgency. Store down or checkout broken: SMS or a phone call. Scraper failed: Slack, someone looks during working hours. Price changes: a Slack channel, never a page.
7. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| External uptime, keyword, API, dependency and heartbeat monitoring | Covers the shopper-facing checks and gives custom scripts a dead man's switch, with a v3 API on every plan for monitors as code. Free for 50 basic monitors at 5-minute intervals; Solo at $9 a month billed yearly adds the heartbeat and API monitors this guide relies on. |
Alternatives: free and open-source tools
We don't earn anything from these links.
- Uptime Kuma: self-hosted monitoring with HTTP, keyword and push (heartbeat) monitors. Free, but you run and watch the server.
- Healthchecks: open-source cron job monitoring built around heartbeat pings, also offered as a hosted service.
- Playwright for Python: the browser automation library used by the checkout probe.
8. Pitfalls
- Pinging the heartbeat at the start of a job. Ping at the end, and only on success, or a job that hangs halfway reports healthy.
- A grace period shorter than the job's run time. A scraper that takes 40 minutes on a busy day needs a grace period longer than that, or you'll get false alarms.
- Monitoring behind the CDN cache only. A cached homepage can stay up while the origin is down. Monitor a product page or the JSON endpoint as well, which are cached less often.
- Bot protection blocking the monitor. A firewall or bot-protection rule can block UptimeRobot's checks. Allow-list its published IP ranges (
api.uptimerobot.com/meta/ips) rather than loosening the rule. - Forgetting maintenance windows. Planned theme deployments will trigger the keyword monitor. Paid plans support maintenance windows; use them, or the team learns to ignore alerts.
- Treating the API key like a read-only token. The main API key can delete monitors. Keep it in a secret store, not in the repository next to
monitors.json.
Let UptimeRobot watch what shoppers see: storefront, Add to cart, product JSON and the payment provider. Write small scripts for what only you can judge, the checkout flow and the scraper's data, and have each one ping a heartbeat only on success. That turns crashes, dead servers and silently wrong data into one kind of alert. For a store with a price scraper, that's about six monitors on UptimeRobot's Solo plan at $9 a month billed yearly; the free plan lacks the heartbeat monitors the scripts depend on.