Price monitoring is the repeated collection and comparison of product prices so you can detect real changes over time. To build it, keep a product catalog, fetch each page on a schedule, extract price and currency, validate the result, store every observation, and alert only when the new price differs from the last valid one for the same offer.
Start with an official API, licensed feed, or your own merchant export. If those do not cover a legitimate need and public-page collection is permitted, use a small, paced scraper with a defined product list. Review applicable terms, robots.txt, and legal requirements before collecting data.
This guide covers the data model, a working Python example, the collection schedule, change detection, proxy decisions, and the operational checks that turn page observations into useful alerts. If the collection layer itself is still undecided, use the ecommerce scraping tool guide to choose between APIs, HTTP clients, crawler frameworks, and browser automation.
If the source data comes from competitor product pages, start with the competitor price scraping guide to define scope, offer matching, and responsible collection before those observations enter this pipeline.
If the monitored offer is a hotel stay, use the hotel price scraping guide to keep dates, occupancy, room identity, booking terms, taxes, and fees attached to each quote before it reaches this pipeline.
Price monitoring pipeline: the core workflow
Treat each price check as a data observation, not as an overwrite of the last value. Keeping the observation history makes changes auditable and lets you repair alert logic without fetching the page again.
- Maintain a canonical catalog of products and offers to monitor.
- Schedule checks according to business value and expected change frequency.
- Apply policy, scope, cache, and rate checks before fetching.
- Retrieve the permitted page or API response.
- Classify the response before extracting data.
- Extract product identity, offer, price, currency, shipping, and availability.
- Normalize and validate the observation.
- Compare it with the last valid comparable observation.
- Store the observation, then send a deduplicated alert if a rule matches.

The validation step is the boundary between collection and monitoring. A request can succeed while the observation is wrong. For example, a consent page may return HTTP 200, or the parser may capture a crossed-out list price instead of the current offer.
Define what "price" means first
One product page can show several valid numbers:
- List or manufacturer suggested price.
- Current single-purchase price.
- Member, subscription, or loyalty price.
- Coupon price that requires an extra action.
- New, used, marketplace, or third-party offer.
- Unit price, price range, or "from" price.
- Shipping, tax, deposit, or other mandatory charges.
Choose one price definition for each monitor. If the goal is competitor shelf-price tracking, you might capture the current generally available offer and keep coupons in a separate field. If the goal is customer checkout cost, the monitor may need shipping and other mandatory charges as well.
Do not silently substitute one type for another. A missing current price should become a classified null observation, not the list price. Store the raw display text alongside the normalized value so an operator can audit unusual changes.
A practical observation schema looks like this:
{
"product_id": "catalog-1842",
"source": "shop.example",
"source_product_id": "SKU-492",
"offer_type": "standard",
"seller": "Example Retailer",
"amount": "49.95",
"currency": "USD",
"shipping_amount": "0.00",
"availability": "in_stock",
"observed_at": "2026-07-01T12:00:00Z",
"parser_version": "shop-example-v3",
"source_url": "https://shop.example/products/SKU-492"
}
Use a decimal type for money in application code and storage. Binary floating-point arithmetic can introduce rounding errors that make threshold comparisons unreliable.
Build a stable product and offer identity
Price history is useful only when every observation refers to the same thing. Titles and URLs alone are weak identifiers: titles change, tracking parameters create duplicate URLs, and one page may contain several sizes or sellers.
Create an internal product ID, then map each source to a stable source ID such as a SKU, GTIN, model number, or marketplace listing ID. Add variant attributes such as size, color, quantity, condition, seller, and fulfillment method when they affect the offer.
Before enqueueing a page:
- Restrict the hostname and path to an approved allowlist.
- Remove tracking and affiliate parameters.
- Normalize equivalent URL forms.
- Deduplicate by source, product ID, variant, and offer.
- Reject redirects that leave the approved host or change the product unexpectedly.
Keep product matching separate from price extraction. If a monitor cannot prove that the page still represents the expected product and variant, quarantine the observation instead of adding it to the price series.
Choose the least complex data source
Use the most stable authorized source available:
| Source | Best fit | Main tradeoff |
|---|---|---|
| Official API or licensed feed | Structured commercial integrations | Access, quotas, field coverage, and license terms |
| Merchant or seller export | Monitoring your own catalog | Does not cover external sellers |
| Server-rendered public HTML | Small permitted page sets | Markup and offer placement can change |
| Browser-rendered page | Permitted fields that require JavaScript | More compute, bandwidth, and page-state complexity |
Start with a normal HTTP client if the required fields exist in the initial response. A browser should be an evidence-based choice, not the default. It loads more resources, costs more to operate, and creates additional failure modes around cookies, rendering, and sessions.
For a concrete HTTP implementation, the Python Requests proxy guide shows connection setup and bounded retries. If a permitted field genuinely depends on rendering, use an isolated context as described in the Playwright proxy guide.
The Robots Exclusion Protocol defines how crawlers retrieve and interpret robots.txt. Robots directives are only one input: they do not replace permission, site terms, privacy review, or applicable law.
Schedule checks without wasting requests
More frequent checks do not automatically produce better data. Set the interval according to how often a product changes, how quickly the business needs to react, and what request volume the source permits.
Use a tiered schedule:
- High-priority products with frequent changes get the shortest permitted interval.
- Stable catalog items move to a slower interval.
- Out-of-stock items use a separate restock cadence.
- Repeated unchanged observations gradually reduce check frequency.
- Recent changes temporarily increase frequency if the source policy allows it.
Add deterministic jitter so thousands of products do not fire at the top of the hour. Cache responses where appropriate, honor validators such as ETag and Last-Modified, and avoid fetching unchanged assets that are not needed for extraction.
Estimate load before deployment. For 12,000 products checked every six hours, the baseline is 48,000 requests per day, or roughly 0.56 requests per second averaged across the full day. The delay calculator helps translate task counts and delays into request pacing, but source-specific limits still control the final schedule.
Extract and normalize comparable prices
Prefer structured fields over brittle presentation selectors when they represent the intended offer. Many product pages embed schema.org Product data as JSON-LD, with offers.price, offers.priceCurrency, and offers.availability. Google documents the same fields for product structured data (page last updated September 2026). Read that block first and fall back to a CSS selector only when it is missing or does not match the visible offer.
Use a source-specific adapter rather than one universal selector set.
Each adapter should return either a typed observation or a classified failure:
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class PriceObservation:
product_id: str
offer_type: str
amount: Decimal
currency: str
available: bool
def comparable_total(observation: PriceObservation, shipping: Decimal) -> Decimal:
return observation.amount + shipping
Normalize:
- Decimal and thousands separators according to the confirmed locale.
- ISO 4217 currency codes rather than ambiguous symbols.
- Included and excluded shipping according to one documented rule.
- Unit quantities when packages contain different counts or weights.
- Availability into a small controlled set of states.
- Timestamps to UTC while retaining the monitored market.
Do not compare €39,99 with $39.99 as though they were the same value. Either keep each market and currency in a separate series or convert using a recorded exchange rate and timestamp. If you convert, retain both the source amount and the converted amount.
A minimal working example in Python
This script fetches product pages with httpx, reads the JSON-LD offer with parsel, appends every observation to SQLite, and prints a line when the price moves by more than 5% against the last valid observation. It needs Python 3.10 or newer and pip install httpx parsel. Replace the example URL with a page you are allowed to monitor.
import json
import random
import sqlite3
import time
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
import httpx
from parsel import Selector
db = sqlite3.connect("prices.db")
db.execute(
"""CREATE TABLE IF NOT EXISTS observations (
product_id TEXT NOT NULL,
observed_at TEXT NOT NULL,
status TEXT NOT NULL,
amount TEXT,
currency TEXT
)"""
)
def find_offer(html: str) -> dict | None:
for raw in Selector(text=html).css('script[type="application/ld+json"]::text').getall():
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
nodes = data if isinstance(data, list) else [data]
for node in list(nodes):
if isinstance(node, dict) and isinstance(node.get("@graph"), list):
nodes.extend(node["@graph"])
for node in nodes:
if isinstance(node, dict) and node.get("@type") == "Product":
offers = node.get("offers")
offer = offers[0] if isinstance(offers, list) and offers else offers
if isinstance(offer, dict) and "price" in offer:
return offer
return None
def record(product_id: str, status: str, amount: str | None = None, currency: str | None = None) -> None:
db.execute(
"INSERT INTO observations VALUES (?, ?, ?, ?, ?)",
(product_id, datetime.now(timezone.utc).isoformat(), status, amount, currency),
)
db.commit()
def last_valid(product_id: str):
return db.execute(
"SELECT amount, currency FROM observations "
"WHERE product_id = ? AND status = 'ok' "
"ORDER BY observed_at DESC LIMIT 1",
(product_id,),
).fetchone()
def check(client: httpx.Client, product_id: str, url: str, threshold: Decimal = Decimal("5")) -> None:
try:
response = client.get(url)
except httpx.HTTPError:
record(product_id, "network_error")
return
if response.status_code != 200:
record(product_id, f"http_{response.status_code}")
return
offer = find_offer(response.text)
if offer is None:
record(product_id, "no_offer")
return
try:
amount = Decimal(str(offer["price"]))
except InvalidOperation:
record(product_id, "bad_price")
return
if amount <= 0:
record(product_id, "bad_price")
return
currency = offer.get("priceCurrency")
previous = last_valid(product_id)
record(product_id, "ok", str(amount), currency)
if previous and previous[1] == currency:
old = Decimal(previous[0])
change = (amount - old) / old * 100
if abs(change) >= threshold:
print(f"{product_id}: {old} -> {amount} {currency} ({change:.1f}%)")
products = {"catalog-1842": "https://shop.example/products/SKU-492"}
with httpx.Client(timeout=20, headers={"User-Agent": "price-monitor/1.0"}) as client:
for product_id, url in products.items():
check(client, product_id, url)
time.sleep(random.uniform(2, 5))
The script stores a classified status instead of a guessed price, so a consent page, a redirect, or a missing offer never enters the price series. It does not follow redirects, because httpx leaves follow_redirects off by default and a 301 to another host should be reviewed. Pages that expose an AggregateOffer with lowPrice instead of price land in no_offer, which is the right outcome until you decide how to handle ranges.
To run it every six hours, add a cron entry such as 0 */6 * * * python3 /opt/monitor/check.py. For routing through a proxy, pass it when you create the client, as the httpx proxy docs show: httpx.Client(proxy="http://username:password@host:port"). Persistent job queues, retries, and a real alert channel come later. Get the observation history right first.
Validate observations before detecting changes
Validation prevents parser failures from becoming business alerts. Check that:
- The response is the expected page type, not a login, consent, challenge, or error page.
- The source product ID and selected variant match the catalog.
- The intended seller and offer type are present.
- The amount is positive and parses into the expected currency.
- Required components such as shipping are present or explicitly unknown.
- Availability is a recognized state.
- The parser version and observation timestamp are recorded.
Add range checks, but do not use them to erase legitimate changes. A 90% drop may be a flash sale, a unit mismatch, a monthly payment, or a parser error. Store it as quarantined, collect supporting evidence allowed by your retention policy, and require confirmation before alerting.
Measure data quality independently from transport success. Track valid observation rate, missing-price rate, unexpected-page rate, product-mismatch rate, parser error rate, and alert confirmation rate. A 99% HTTP success rate is meaningless if half the accepted prices refer to the wrong offer.
Detect meaningful price changes
Compare the new result with the last valid observation for the same product, market, variant, seller, offer type, and currency. Comparing unlike offers is a common source of false alerts.
Useful rules include:
- Any absolute change greater than a fixed amount.
- A percentage decrease or increase beyond a threshold.
- A new historical low within a defined lookback period.
- A move below a target price.
- A return to stock with a valid price.
- A competitor gap that persists for two or more observations.
Calculate percentage change against the previous valid price:
percentage change = ((new price - old price) / old price) × 100
Use confirmation rules for noisy sources. Two matching observations several minutes apart can prevent a transient partial render from triggering an alert. The tradeoff is slower detection, so reserve confirmation for changes where false positives are costly.

Design alerts for action, not volume
An alert should explain what changed and why it matters. Include:
- Product and monitored variant.
- Previous and current comparable price.
- Absolute and percentage change.
- Currency, market, seller, and offer type.
- Observation time and source URL.
- The rule that triggered the alert.
- A stable event ID for deduplication.
Deduplicate on product, offer, rule, and change event. Do not send the same alert on every unchanged run after a threshold is crossed. Close or update an event when the price returns above the target, the item becomes unavailable, or a newer valid change replaces it.
Keep quarantined observations out of customer-facing channels. Route them to an operations queue with the parser version and failure reason so the team can distinguish a real price event from a source change.
Handle failures and backoff
Classify responses before retrying:
| Result | Likely meaning | Action |
|---|---|---|
| 200 with wrong content | Consent, challenge, redirect, or parser mismatch | Quarantine; inspect the page class |
| 403 | Access, authorization, or policy denial | Stop automatic retries and review access |
| 429 | Request rate is too high | Honor Retry-After, reduce concurrency, and cool down |
| 5xx | Temporary source or upstream failure | Retry a limited number of times with backoff and jitter |
| Timeout | Network, proxy, source, or rendering delay | Identify the slow layer before retrying |
The HTTP definition of 429 Too Many Requests explains that a server may include Retry-After. Treat it as a minimum wait, not a suggestion to resume every worker simultaneously.
Put retries back into the scheduler with an attempt count and next-run time. Sleeping inside a worker wastes capacity and makes shutdowns harder. Cap retries, add jitter, and use a circuit breaker when one source begins failing broadly.
Which proxies fit price monitoring
Proxies can support legitimate price monitoring when observations vary by region, cloud workers need controlled outbound routing, or independent jobs need isolated network identities. They do not grant permission, repair a broken parser, or justify ignoring access controls.
The rule for retail monitoring is short. Use ISP proxies when you check a fixed set of retailers from one market on a steady schedule, because a static, dedicated IP gives you consistent latency and one route per job. Use residential proxies when the price depends on the shopper's location, because you can check the same product from several countries or cities.
Match routing to the job:
- Use a stable ISP proxy for repeated checks where predictable latency and one consistent route matter.
- Use a sticky residential session for a permitted multi-step regional page flow.
- Use rotating residential proxies for independent location checks when each observation can stand alone.
- Test datacenter proxies first when the source permits them and cost efficiency matters.
- Keep one browser page load on one identity. Do not rotate midway through its subrequests.
The best proxy for web scraping guide compares these options by target strictness, session needs, speed, and cost. For a fixed set of retailers in one market, compare ISP proxy plans. If broad location coverage is central, residential proxies offer country, state, and city targeting, and the data usage calculator estimates the bandwidth a schedule needs.
Restock monitoring on large retailers such as Target or Walmart is a related but harder job. Monitors often check every few seconds during a product release, and the retailer's bot protection is stricter than on most catalog pages. The sneaker proxies guide covers ISP vs residential setups for release-day traffic. Use the same delay calculator to match monitor delays to task and proxy counts. For a Target-specific bot setup, the Refract Bot guide covers monitor proxies and delays.
Before increasing proxy pool size, reduce duplicate work, slow the scheduler, cache stable pages, and fix synchronized retries. A larger pool can hide poor request discipline without improving data accuracy.
Test the complete monitoring system
Use saved, redacted fixtures when your policy permits them. Your test set should cover:
- A normal in-stock offer.
- A sale with both list and current prices.
- A coupon or member-only price.
- An unavailable product.
- Multiple sellers or variants.
- Locale-specific decimal and currency formats.
- Consent, challenge, and error pages.
- A large legitimate increase and decrease.
- Missing shipping or incomplete rendering.
Run parser tests on every adapter change. Then replay observations through normalization, comparison, deduplication, and alert rules. A selector unit test alone cannot catch an alert that compares a standard offer with a subscription offer.
Deploy source adapters independently. Canary a new parser on a small product subset, compare valid observation rates with the previous version, and keep a rollback path. Alert on sudden shifts in missing fields or page classifications before users report bad data.
FAQ
How often should prices be monitored?
Use the slowest interval that still supports the business decision. Fast-changing, high-value products may need frequent permitted checks, while stable items can be checked daily or less often. Adjust the interval from observed change frequency and the limits the source sets.
Should a price monitor use a browser?
Only when a permitted field is missing from the initial HTML, the embedded JSON-LD, and any approved API. HTTP clients are simpler, faster, and cheaper. Prove that rendering is necessary before adding a browser.
Should price monitoring include shipping?
Include shipping when the metric is total customer cost, and keep it separate when the metric is shelf price. Whichever rule you choose, apply it to every comparable observation.
Do I need proxies for price monitoring?
Not for a small catalog checked slowly from one market. Add proxies when you need to measure prices from other regions or isolate workloads. A proxy does not give you permission to collect a page and does not fix a parser that reads the wrong price.
Is price monitoring legal?
It depends on the source, the site's terms, the data collected, and your jurisdiction, so this is not legal advice. Prefer official APIs and licensed feeds, and read the competitor price scraping FAQ and the data scraping legality guide before you collect from public pages.
How do I avoid false price alerts?
Compare only like-for-like offers, quarantine observations that fail validation, and require a second matching observation before alerting on large moves. Most false alerts come from a consent page, a variant mismatch, or a unit change, not a real price change.
Final thoughts
Reliable price monitoring depends on identity and validation, not scraping speed. Define the exact offer, store every paced observation, compare only like-for-like records, and quarantine suspicious changes before they become alerts.
Start with a small catalog and a structured source where one exists. Once the alerts are trustworthy, scale the schedule gradually and add proxy routing only where regional measurement or workload isolation is a clear need.
