How to Scrape Wayfair Product Data: Prices & SKUs
Scrape Wayfair product data at scale: pull SKUs, per-option prices, reviews, and stock from the page JSON, and get past PerimeterX with a scraping API.

To scrape Wayfair product data cleanly, you have two jobs, plus a third that catches almost everyone out. The first two are the usual pair: read the structured data Wayfair already ships inside every page, and get past PerimeterX, the HUMAN Security bot layer that decides whether you load that page at all. The third is specific to Wayfair. A single listing is not one product with one price. It is a parent (Wayfair calls it a SuperSKU) that fans out into many buyable options, where each color, size, and finish carries its own SKU, its own price, and its own stock. Scrape only the price you see on load and you store a number most of your shoppers will never pay. This guide walks the whole pipeline for public catalog data: the fields worth pulling, how Wayfair's option model works, how to read both the JSON-LD and the hidden application JSON, how to catch a PerimeterX block that returns HTTP 200, where reviews live, and how to discover SKUs at volume. Every code sample uses SparkProxy's Scraping API, so the anti-bot work is one request parameter instead of an infrastructure project.
Is scraping Wayfair legal?
Settle the framing before you write any code, because "it's public" answers a narrower question than most people assume.
In the United States, the Ninth Circuit's ruling in hiQ Labs v. LinkedIn (2022) held that scraping data that is publicly accessible, meaning no login and no authentication wall, generally does not violate the Computer Fraud and Abuse Act. That decision is about unauthorized access, not a blanket license to do anything with the data afterward. Wayfair's Terms of Use separately prohibit automated data collection, so a scraper can breach that contract even where it clears the CFAA bar. Two different legal questions, and public access only settles the first.
Two housekeeping checks belong in any serious project:
- Read
wayfair.com/robots.txt. It disallows a range of paths (cart, checkout, account, and internal search or API endpoints among them). Robots.txt is not law, but respecting it is the baseline for defensible, good-faith scraping, and ignoring it is the first thing a plaintiff points at. - Collect public product data only. SKU, price, options, availability, aggregate ratings. Never anything behind a login, and never the personal details (names, cities) attached to individual reviews.
Guardrails that keep a project on the right side of the line: rate-limit yourself and back off on errors so you are not degrading the site for real shoppers, don't republish copyrighted assets (product photography, full review text) beyond fair use, and if the data feeds a commercial product, run it past a lawyer. This is engineering guidance, not legal advice. Price monitoring, catalog research, and competitive intelligence are common, legitimate uses of public retail data; for the business context, see how e-commerce companies use proxies for competitive intelligence.
What Wayfair data you can extract (fields reference)
A public Wayfair product page (a PDP) lives at a URL that ends in .html with a product identifier, along the lines of /furniture/pdp/. The visible fields are stable, and the reliable ones come out of the page's structured data rather than the rendered DOM. Here is the reference set worth pulling, and which source each field comes from.
| Field | Where it lives | Example | Notes |
|---|---|---|---|
| SKU | JSON-LD `sku`; per-option `sku` in app JSON | `"MCRR2249"` | The buyable unit's key; one listing has many |
| Product name | JSON-LD `name` | `"Mercury Row 84'' Rolled Arm Sofa"` | The parent listing name |
| Brand | JSON-LD `brand.name` | `"Mercury Row"` | Wayfair private or partner brand |
| Price range | JSON-LD `offers.lowPrice` / `highPrice` | `499.99` / `1299.99` | An `AggregateOffer` spanning options |
| Per-option price | App JSON, per option | `749.00` | The number a shopper actually pays |
| Currency | JSON-LD `offers.priceCurrency` | `"USD"` | Currency code |
| Availability | App JSON, per option `inStock` | `true` | Per option, not per listing |
| Rating | JSON-LD `aggregateRating.ratingValue` | `4.5` | Average, 0 to 5 |
| Review count | JSON-LD `aggregateRating.reviewCount` | `2317` | Integer count |
| Options | App JSON `options` / option categories | Color, Size | Each combination resolves to a SKU |
| Image | JSON-LD `image`; per-option in app JSON | image URL | Options often have their own photos |
The SKU is your anchor, but read the next section before you decide what a "SKU" means on Wayfair, because it is the one place this target differs from Amazon or Best Buy in a way that quietly corrupts datasets.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
The option and SuperSKU model
Here is the Wayfair-specific gotcha, and it is the whole reason this guide exists as its own post. On most retailers a product page maps to one sellable thing with one price. On Wayfair, a product page is a SuperSKU: a parent listing that groups a set of purchasable options, and every option combination is its own SKU with its own price, its own inventory, and often its own image.
Take a sofa offered in six upholstery colors and two sizes. That is one PDP, one URL, one product name, and up to twelve buyable SKUs behind it. The gray loveseat and the blue three-seater are different prices and can differ in stock. A rug listing in eight sizes is eight SKUs, and the price gap between the 5x8 and the 9x12 is not small. If your scraper reads the price rendered on first load and writes one row per PDP, you have recorded the default option and thrown away the other eleven. Worse, you cannot tell later which option that price belonged to.
Contrast this with the sibling retailers. On Amazon and Walmart the classic trap is location: the same item shows different prices by ZIP or store, so you pin a location. On Wayfair the base price is essentially national, and the trap is the option. Pinning a ZIP does nothing for you here. Enumerating the options is the job.
There are two ways to capture every option's price:
- Read the options out of the application JSON that hydrates the page. It already contains the full option tree and, in most page versions, the per-option price and stock. This is the fast path: one request, all options.
- Drive the option selector with a browser scenario, clicking each option and re-reading the price, when the JSON only carries the currently selected option's price. Slower, but it survives page versions that lazy-load per-option pricing.
Design your schema for this from the start. One row per option SKU, each carrying the parent SuperSKU id and the option labels (color, size) that define it. That is the difference between a price feed you can trust and a pile of default-option noise.
Where the data lives: JSON-LD and the app JSON
Wayfair's storefront is a React application, so scraping the rendered DOM with CSS selectors is the brittle path: class names are hashed and the layout shifts. Two structured sources are far steadier.
JSON-LD. Every PDP embeds a block describing the product in schema.org vocabulary. It gives you the anchor fields for free:
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Product","sku":"MCRR2249",
"name":"Mercury Row 84'' Rolled Arm Sofa","brand":{"@type":"Brand","name":"Mercury Row"},
"offers":{"@type":"AggregateOffer","lowPrice":499.99,"highPrice":1299.99,
"priceCurrency":"USD","offerCount":12},
"aggregateRating":{"@type":"AggregateRating","ratingValue":4.5,"reviewCount":2317}}
</script>
Notice offers is an AggregateOffer, not a single Offer. That is the SuperSKU model showing through in the schema: lowPrice and highPrice describe the range across the twelve options, and offerCount is how many there are. Treat lowPrice as "cheapest option", not "the price". Single-option products do ship a plain Offer with one price, so handle both shapes.
The application JSON. The range in JSON-LD is not enough when you need the price of a specific color-and-size. For that you go to the JSON Wayfair serializes into the page to hydrate its React app. It carries the full option tree, per-option pricing, and inventory. The exact script id and object shape move between page versions, so don't hard-code a path. Grab the candidate blobs, keep the one that parses and contains product data, then walk it. This is the same discipline that makes hidden-endpoint scraping reliable in general, covered in how to scrape hidden JSON API endpoints.
Why Wayfair is hard: PerimeterX and HUMAN
Getting the HTML is the hard part, and the reason is PerimeterX, now part of HUMAN Security. It is one of the more capable bot-management stacks on the public web, and Wayfair runs it aggressively. Three things break naive scrapers.
The "Press & Hold" challenge. When PerimeterX flags a request, Wayfair serves a challenge page ("Press & Hold" to confirm you are human, sometimes phrased "Robot or human?"). The trap is that this page does not always come back with an error status. You will see it on a 403 or a 429, but also sometimes on an HTTP 200. If your code trusts the status code, response.ok is True, you save the page, and you have stored a captcha shell instead of a product. You have to inspect the body.
Fingerprinting, not just IP reputation. PerimeterX scores TLS/JA3 fingerprints, header order, and JavaScript-execution signals, not only the IP. A plain requests.get with default headers is flagged before the IP even matters, which is why bare HTTP clients get walled almost immediately. You need a real browser fingerprint, which means real JavaScript execution.
Datacenter IP bias. Plain datacenter ranges carry poor reputation on Wayfair and get challenged fast. Residential exit IPs blend with normal shopper traffic and last far longer. Rotating the exit IP per request is the difference between a scraper that runs for ten minutes and one that runs for a week.
| Signal | What you'll see | How to handle it |
|---|---|---|
| PerimeterX challenge | "Press & Hold" / "Robot or human?" body, on 200, 403, or 429 | Detect in body, rotate IP, retry with stealth |
| Missing JS fingerprint | Immediate challenge on a raw HTTP fetch | Render with a real browser |
| Datacenter bias | Fast challenges on plain datacenter ranges | Prefer residential exits that blend with shoppers |
| Rate limit | Bursts of 429s from one exit IP | Space requests, lower concurrency, rotate IP |
A managed scraping API absorbs the fingerprint, IP rotation, and rendering for you. The mechanics of the PerimeterX cookie and sensor are covered in depth in how to bypass PerimeterX, and the proxy-side theory of why some IPs survive is in how to avoid getting your proxy blocked.
Set up the SparkProxy Scraping API
The SparkProxy Scraping API takes a target URL and handles the proxy, rotation, browser rendering, and anti-bot layer. You send one request and get the rendered HTML back. For Wayfair, four parameters carry the weight:
render_js=true: runs the page in a real Chromium browser, which produces a legitimate JS fingerprint and returns the hydrated HTML, including a fully populated application JSON.premium_proxy=true: routes through residential IPs, which survive PerimeterX where datacenter IPs get challenged.stealth=true: adds extra anti-detection layers tuned for stacks like PerimeterX. It requiresrender_js=true.country_code=US: sets a US exit, which matters becausewayfair.comis a US storefront and geo-mismatched requests draw friction.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single header, X-API-Key. A minimal request in cURL:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.wayfair.com/furniture/pdp/example-sofa-mcrr2249.html" \
--data-urlencode "render_js=true" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "stealth=true" \
--data-urlencode "country_code=US"
The full parameter list and response fields are in the Scraping API docs. If you are weighing this against running your own proxy pool for a target this well defended, web scraping API vs self-managed proxies lays out the trade-off honestly. Wayfair runs its sibling brands (AllModern, Joss & Main, Birch Lane, Perigold) on the same platform, so the same setup transfers to those domains with a URL swap.
Scrape a single product
Start with one product. Wrap the request so every call carries the Wayfair-specific parameters, and give it a generous timeout since a rendered request drives a real browser.
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
def fetch_url(target: str, country: str = "US") -> str:
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": target,
"render_js": "true", # real JS fingerprint + hydrated HTML
"premium_proxy": "true", # residential IPs survive PerimeterX
"stealth": "true", # extra anti-detection (needs render_js)
"country_code": country, # US storefront expects US traffic
},
timeout=90,
)
resp.raise_for_status()
return resp.text
def fetch_product(pdp_url: str) -> str:
# Pass the full PDP URL; the trailing identifier resolves the listing.
return fetch_url(pdp_url)
Unlike Amazon's clean /dp/ shape, a Wayfair PDP URL bundles a slug and a trailing identifier, so keep the full URL as your input rather than trying to rebuild it from an id. The next step is reading the structured data out of that HTML.
Parse SKU, price, options, and availability
Two sources, two passes. First pull the JSON-LD Product block for the anchor fields, then walk the application JSON for the per-option detail the JSON-LD only summarizes.
import json
from selectolax.parser import HTMLParser
def extract_product_ldjson(html: str) -> dict | None:
"""Return the JSON-LD block whose @type is Product, or None."""
tree = HTMLParser(html)
for node in tree.css('script[type="application/ld+json"]'):
try:
data = json.loads(node.text())
except json.JSONDecodeError:
continue
for block in (data if isinstance(data, list) else [data]):
if isinstance(block, dict) and block.get("@type") == "Product":
return block
return None
selectolax is a fast C-backed HTML parser; install it with pip install selectolax. Reading the anchor fields is then a matter of safe .get() chains, and the one subtlety is handling both offer shapes, AggregateOffer (a range) and a single Offer:
def parse_anchor(html: str) -> dict | None:
block = extract_product_ldjson(html)
if not block:
return None
offer = block.get("offers", {}) or {}
if isinstance(offer, list):
offer = offer[0] if offer else {}
brand = block.get("brand", {}) or {}
rating = block.get("aggregateRating", {}) or {}
# AggregateOffer carries lowPrice/highPrice; a plain Offer carries price.
low = offer.get("lowPrice", offer.get("price"))
high = offer.get("highPrice", offer.get("price"))
return {
"sku": block.get("sku"),
"name": block.get("name"),
"brand": brand.get("name") if isinstance(brand, dict) else brand,
"price_low": low,
"price_high": high,
"currency": offer.get("priceCurrency"),
"offer_count": offer.get("offerCount"),
"rating": rating.get("ratingValue"),
"review_count": rating.get("reviewCount"),
"image": block.get("image"),
}
Now the part that makes a Wayfair dataset honest: the options. Grab the application JSON, then walk it to the option tree and emit one record per buyable option. The exact keys shift between page versions, so confirm them against a live page in your browser's network panel, then map them here. Keep the walker defensive so a reshuffle degrades gracefully instead of crashing.
def find_node(obj, key: str):
"""Depth-first search for the first dict containing `key`."""
if isinstance(obj, dict):
if key in obj:
return obj
for v in obj.values():
hit = find_node(v, key)
if hit is not None:
return hit
elif isinstance(obj, list):
for v in obj:
hit = find_node(v, key)
if hit is not None:
return hit
return None
def extract_app_json(html: str) -> dict | None:
"""Wayfair hydrates its React app from an inline application/json blob.
The script id changes, so keep the blob that parses and carries options."""
tree = HTMLParser(html)
for node in tree.css('script[type="application/json"]'):
try:
data = json.loads(node.text())
except (json.JSONDecodeError, ValueError):
continue
if find_node(data, "options") is not None:
return data
return None
def parse_options(html: str, parent_sku: str | None) -> list[dict]:
"""One row per buyable option SKU. Map the keys you confirmed in devtools."""
app = extract_app_json(html)
if not app:
return []
holder = find_node(app, "options") or {}
options = holder.get("options") or holder.get("optionCombinations") or []
rows = []
for opt in options:
if not isinstance(opt, dict):
continue
rows.append({
"parent_sku": parent_sku,
"option_sku": opt.get("sku") or opt.get("optionId"),
"label": opt.get("name") or opt.get("displayName"),
"price": (opt.get("price") or {}).get("value")
if isinstance(opt.get("price"), dict) else opt.get("price"),
"in_stock": bool(opt.get("inStock", opt.get("available"))),
})
return rows
Because the JSON is typed, price comes back numeric and in_stock as a boolean with no regex on "$749.00". Keep both the raw availability and a derived boolean, so a status you have not seen yet (backorder, made-to-order) does not silently read as "in stock". If a page version hides per-option prices from the JSON, fall back to a js_scenario that clicks each option and re-reads the price; the docs show the scenario syntax your plan exposes.
Detect a PerimeterX block
This is the check that separates a scraper you can trust from one that quietly stores denial pages. Because the "Press & Hold" page can return HTTP 200, raise_for_status() will not catch it, and a missing JSON-LD block does not tell you why it is missing. Scan the body for the markers, and treat a missing Product block as a soft block too:
def is_blocked(html: str) -> bool:
"""PerimeterX can serve a challenge on HTTP 200, so the status lies."""
markers = (
"press & hold",
"robot or human",
"px-captcha",
"/_px/",
"captcha",
)
lowered = html.lower()
if any(m in lowered for m in markers):
return True
# A real PDP always ships a Product JSON-LD block.
return extract_product_ldjson(html) is None
Now a fetch is honest: it either returns a real product page or tells you it was blocked so you can retry. Pair this with stealth=true and residential rotation, and PerimeterX challenges become an occasional retry rather than a wall. Keep the marker list in one place, since PerimeterX reworks its challenge copy from time to time.
Scrape ratings and reviews
The rating summary is easy: aggregateRating.ratingValue and reviewCount come straight out of the JSON-LD you already parsed, so a nightly job that only needs "how many stars, how many reviews" is done at that point.
Individual review text is a separate system. Wayfair runs its own reviews platform rather than embedding a third party like Bazaarvoice, and the review list loads from an internal Wayfair endpoint (often a GraphQL POST) keyed on the SKU and paginated. As with the options JSON, capture the live request from your network panel, because the endpoint path and field names are specific to the deployment and change. The reliable pattern is to map from whatever the payload gives you:
def parse_reviews(payload: dict) -> list[dict]:
"""
`payload` is the JSON from the reviews request the page makes (it carries
the SKU and a page offset). Field names vary; map what you see in devtools.
"""
node = find_node(payload, "reviews") or payload
rows = []
for r in node.get("reviews", node.get("results", [])):
rows.append({
"review_id": r.get("id"),
"rating": r.get("rating") or r.get("ratingStars"), # 1 to 5
"title": r.get("title"),
"text": r.get("comments") or r.get("reviewText"),
"date": r.get("date") or r.get("submittedDate"),
})
return rows
Two guardrails specific to reviews. Keep only what you need for analysis (rating distribution, recency, keyword signal) and do not republish full review text verbatim, since that is user-generated content with its own copyright and platform terms. And never attach reviewer names, cities, or any other personal detail to your dataset. Aggregate sentiment is defensible; harvesting people is not.
Discover SKUs and scale the crawl
Everything so far assumes you have a PDP URL. To build a catalog you discover them from Wayfair's category and search pages, which paginate with a &page=N parameter and expose product tiles carrying each item's PDP link. Fetch a results page through the same rendered request, pull the links, then feed them into the product pipeline.
from urllib.parse import quote_plus
from selectolax.parser import HTMLParser
def search_url(query: str, page: int) -> str:
return f"https://www.wayfair.com/keyword.php?keyword={quote_plus(query)}&curpage={page}"
def parse_listing_urls(html: str) -> list[str]:
tree = HTMLParser(html)
urls, seen = [], set()
for a in tree.css('a[href*="/pdp/"]'):
href = a.attributes.get("href") or ""
if href.endswith(".html") and href not in seen:
seen.add(href)
urls.append(href if href.startswith("http")
else f"https://www.wayfair.com{href}")
return urls
Once you have URLs, scrape their pages at volume with three habits: retry on soft blocks, back off so you do not spike a single IP, and keep concurrency modest. With a scraping API the provider rotates the exit IP per request, so your ceiling is your plan's rate limit, not the number of proxies you own. Five to fifteen workers is plenty for a target this defended.
import time
import random
import csv
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_with_retry(pdp_url: str, attempts: int = 3) -> str | None:
for i in range(attempts):
html = fetch_product(pdp_url)
if not is_blocked(html):
return html
time.sleep(2 ** i + random.random()) # exponential backoff + jitter
return None
def scrape_catalog(pdp_urls: list[str], workers: int = 8) -> list[dict]:
rows = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(fetch_with_retry, u): u for u in pdp_urls}
for fut in as_completed(futures):
html = fut.result()
if html is None:
continue
anchor = parse_anchor(html)
if not anchor:
continue
options = parse_options(html, anchor["sku"])
# One row per option SKU; fall back to the anchor if a listing
# has no separate options (a single-Offer product).
if options:
for o in options:
rows.append({**anchor, **o})
else:
rows.append(anchor)
return rows
def save_csv(rows: list[dict], path: str = "wayfair_products.csv") -> None:
if not rows:
return
fields = sorted({k for r in rows for k in r})
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
The jitter matters more than it looks: random.random() staggers retries so a batch of failures does not retry in lockstep and re-trigger the same rate limit. Persist as you go rather than holding everything in memory, so a crash at listing 40,000 does not cost you the first 39,999. For a running price tracker, add a scraped_at timestamp to each row and write to a database keyed on (option_sku, scraped_at), which gives you a clean per-option time series where every price point is comparable. The extraction differs, but the crawl scaffolding is identical to how to scrape Walmart product data, Wayfair's closest sibling on the anti-bot side, and if a stable price feed is the whole point, datacenter proxies for price comparison websites covers the wider pattern.
Frequently asked questions
FAQ
Scraping publicly accessible pages (no login) generally does not violate the US Computer Fraud and Abuse Act under hiQ v. LinkedIn (9th Cir. 2022), but it can still breach Wayfair's Terms of Use, which prohibit automated collection. Check wayfair.com/robots.txt, stick to public product data, avoid personal data from reviews, don't overload the servers, and get legal advice before any commercial use.
PerimeterX scores your browser fingerprint and IP reputation, and its challenge can return HTTP 200, so scan the response body for markers like "Press & Hold" and "px-captcha" rather than trusting the status. Reduce triggers with a real browser and rotating residential IPs: with the SparkProxy Scraping API set render_js=true, premium_proxy=true, and stealth=true, then retry on a detected block.
A Wayfair listing is a SuperSKU whose color, size, and finish options each have their own SKU, price, and stock, and the JSON-LD only gives you a lowPrice to highPrice range. Read the full option tree from the application JSON that hydrates the page and emit one row per option SKU, or drive the option selector with a js_scenario and re-read the price when the JSON hides per-option pricing.
Read them from the structured data rather than CSS selectors. The JSON-LD Product block gives you sku, name, brand, aggregateRating, and an AggregateOffer with lowPrice and highPrice; the page's application JSON gives you the per-option price and inventory. Both survive the React layout changes that break selector-based scrapers.
The aggregate rating and review count sit in the page's JSON-LD, but individual review text loads from Wayfair's own internal reviews endpoint (often a GraphQL POST), paginated and keyed on the SKU, not from a third party like Bazaarvoice. Capture the live request from your network panel, keep aggregate signals only, and never attach reviewer personal data or republish full review text verbatim.
Usually not reliably. A raw HTTP fetch fails the PerimeterX fingerprint check and often gets challenged before the IP matters, and Wayfair hydrates the page and its option JSON in the browser, so you need render_js=true to get a legitimate fingerprint and the full application JSON. Rendering costs more per request but is the difference between real data and a "Press & Hold" shell on this target.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

XPath and CSS Selectors: Scrapers That Don't Break
Scrape data with XPath and CSS selectors that survive redesigns: label-anchored XPath, stable attributes, namespace fixes, and a selector-drift detector.

Stealth Plugins for Puppeteer and Playwright: What Works
Stealth plugins patch the known automation tells in Puppeteer and Playwright. Which ones still matter, why the patch set is a signature, and how to test yours.

How to Scrape Zomato and Swiggy Data (Menus and Prices)
Scrape Zomato and Swiggy data that means something: pin the delivery coordinates, key every row by restaurant, pin and timestamp, and split the fee stack.
