How to Scrape Etsy Product Data
Scrape Etsy product data the ethical way: pull title, price, shop, rating, reviews, sales count, tags, and variations via the Open API or a scraping API.

To scrape Etsy product data cleanly, you have two real paths, and picking the wrong one wastes weeks. Etsy runs an official developer API, so before you write a single parser you should know when the sanctioned route covers your use case and when public-page scraping is the only thing that does. This guide walks the full pipeline for public listing data: which fields to pull, the single most reliable place to read price and rating from, how to catch a bot challenge that returns HTTP 200, how currency and geo quietly corrupt a price dataset, and how to paginate search at volume. Every code sample uses the SparkProxy Scraping API, so the anti-bot layer is one request parameter instead of an infrastructure project you babysit.
Is scraping Etsy legal?
Get the framing right before you write code, because "the data is public" answers only one of two separate legal questions.
In the United States, the Ninth Circuit's ruling in hiQ Labs v. LinkedIn (2022) held that scraping data that's 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 license to do anything with the data. Etsy's Terms of Use separately prohibit automated collection, so scraping public pages can still be a breach of contract even where it isn't a CFAA violation. Those are two different questions, and the second one doesn't disappear because the first one is settled.
Guardrails that keep a project defensible:
- Collect public listing data only: title, price, shop name, rating, review count, tags. Never anything behind a login, and never personal data about buyers or the makers themselves.
- Rate-limit yourself and back off on errors. Etsy shops are small businesses, and hammering their pages is both rude and a fast way to get flagged.
- Don't republish copyrighted assets. Product photos on Etsy belong to the sellers, so treat image URLs as references, not content to redistribute.
- If the data feeds a commercial product, run it past a lawyer. This is engineering guidance, not legal advice.
Price monitoring, category research, and competitive intelligence are common, legitimate uses of public marketplace data. For the business context around that, we cover it in How E-commerce Companies Use Proxies for Competitive Intelligence.
The Etsy Open API vs scraping tradeoff
Etsy publishes a real developer API, the Etsy Open API v3, and it should be your first stop. It's the sanctioned path, it returns clean structured JSON, and it won't fight you with bot challenges. The catch is what it's for.
The Open API is built to let apps manage a shop the owner controls, or a shop that has authorized your app over OAuth 2.0. Private data and write actions require an OAuth token with the right scope. A few read endpoints work with only your app's API key, and the useful one for public product data is getListing, which returns a single active listing by its numeric id:
import requests
listing_id = 1234567890
r = requests.get(
f"https://openapi.etsy.com/v3/application/listings/{listing_id}",
headers={"x-api-key": "YOUR_ETSY_APP_KEY"},
params={"includes": "Images,Inventory"},
timeout=30,
)
listing = r.json() # title, price, currency_code, quantity, tags, ...
That's the cleanest possible read for a listing you already have an id for. The friction shows up at scale: Etsy applies per-app rate quotas and its API Terms limit how long you may cache and how you may reuse what you pull. There's no public endpoint that dumps a competitor's whole catalog, and building a rival marketplace from API data is expressly against those terms. So the official API is excellent for your own shop and for authorized integrations, and it's a poor fit for broad market research across thousands of shops you don't control.
That gap is where scraping public pages earns its place. Here's the honest comparison:
| Dimension | Etsy Open API v3 | Scraping public pages |
|---|---|---|
| Status | Official, sanctioned | Public data only, subject to Etsy's ToS |
| Auth | `x-api-key` (+ OAuth for private/write) | API key on the scraper, none on Etsy |
| Coverage | Your shop, authorized shops, `getListing` by id | Any public listing or search page |
| Data shape | Clean structured JSON | Price/rating from page JSON-LD; sales/tags/variations from HTML |
| Limits | Per-app quota + caching rules in API Terms | Provider rate limit, rotate IPs to survive |
| Best for | Managing your own shop, authorized apps | Competitive and category research at scale |
Use the API when the shop is yours or has authorized you. Reach for scraping when you need public, cross-shop data the API deliberately doesn't hand out. If you're deciding between a managed scraper and running your own proxy pool for that second job, Web Scraping API vs Self-Managed Proxies lays out the tradeoff.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
What product data you can extract (fields reference)
A public Etsy listing lives at https://www.etsy.com/listing/, and the numeric listing_id is the anchor for everything. Store it as your primary key and every other field hangs off it. The important thing to know up front: Etsy embeds a Product block on every listing page (it powers Google's rich results), and that block is a far more stable source for the core fields than any CSS selector. Here's the reference set worth pulling, with the best place to read each one:
| Field | Where it lives | Best source | Notes |
|---|---|---|---|
| Title | Listing heading / `name` | JSON-LD | The `name` field in the Product block |
| Price | Buy box / `offers.price` | JSON-LD | Plain number, always pair it with the currency |
| Currency | `offers.priceCurrency` | JSON-LD | ISO 4217 code (USD, GBP, EUR) |
| Shop | `brand.name` / shop header | JSON-LD | `brand.name` is the shop's name |
| Rating | `aggregateRating.ratingValue` | JSON-LD | e.g. `4.8875`, round it downstream |
| Reviews | `aggregateRating.reviewCount` | JSON-LD | Item-level review count |
| Sales count | Shop header text ("10,482 sales") | HTML | Not in the JSON-LD, sits near the shop link |
| Tags | Tag links near the page bottom | HTML | Each links to an Etsy search or market page |
| Variations | Option dropdowns (size, color) | HTML (render_js) | ` |
| Availability | `offers.availability` | JSON-LD | `InStock` or `OutOfStock` |
| Image | `image` | JSON-LD | Hi-res listing image URL |
Roughly two thirds of what you want is in one JSON blob. The rest (sales count, tags, variations) has to come from the HTML, and those parts are where selectors drift.
Why Etsy is hard to scrape
Etsy is friendlier than Amazon but it is not soft, and a naive Etsy scraper breaks on four things.
Bot-management challenges. When Etsy suspects automation it serves an interstitial ("Please verify you're a human") or a CAPTCHA instead of the listing. The trap is that a challenge page can come back with HTTP 200 or a 403, so code that trusts response.ok saves the challenge and thinks it got a product. You have to inspect the body.
Rate limiting. Fire a burst of requests from one IP and Etsy starts returning 429 and challenge pages. The threshold isn't published and it moves.
Datacenter IP flagging. Plain datacenter ranges draw challenges quickly on Etsy, so a single static proxy dies fast. Residential IPs blend in and last far longer, and rotating the exit IP per request is the difference between a run that lasts an hour and one that lasts a week.
JavaScript-rendered variations. Price and rating are in the server-rendered JSON-LD, so a raw fetch gets those. Variation dropdowns (size, color, personalization) are populated by JavaScript, so if you need those you have to render the page.
| Signal | What you'll see | How to handle it |
|---|---|---|
| Bot challenge | 200 or 403, body says "verify you're a human" | Detect in the body, rotate IP, retry |
| Rate limit | `429` after a burst from one IP | Space requests, back off, lower concurrency |
| IP flag | Persistent challenges on one IP | Fresh residential IP per request |
| JS-only fields | Empty variation dropdowns | Render the page with `render_js=true` |
A managed scraping API absorbs the first three for you. The rendering need is a flag you flip. For the proxy-side theory behind ban avoidance, How to Avoid Getting Your Proxy Blocked goes deep.
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 for you. You send one request and get the HTML back. For Etsy, two parameters carry the weight, and a third helps when you need variations:
premium_proxy=true: routes through residential IPs, which survive Etsy's challenges where datacenter IPs get flagged.country_code: the ISO alpha-2 code of the region you want to appear from (US,GB,DE). It sets the exit country, which drives the default currency Etsy shows.render_js=true: run a real Chromium browser. You can skip it for price and rating (those come from server-rendered JSON-LD), but you need it when you're reading variation dropdowns.
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.etsy.com/listing/1234567890/handmade-mug" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "country_code=US"
Full parameter list and response fields live in the Scraping API docs.
Scrape a single Etsy listing
Start with one listing. Wrap the request so every call carries the Etsy-specific parameters, and give it a generous timeout when you render.
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
def fetch_listing(url: str, country: str = "US", render: bool = False) -> str:
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": url,
"premium_proxy": "true", # residential IPs survive Etsy's challenges
"country_code": country, # exit region drives default currency
"render_js": "true" if render else "false",
},
timeout=90,
)
resp.raise_for_status()
return resp.text
Before you trust the HTML, check whether Etsy handed you a challenge instead of a listing. Because that page can return 200, raise_for_status() won't catch it. Scan the body for the telltale markers:
def is_blocked(html: str) -> bool:
"""Etsy's bot challenge can return HTTP 200, so the status code lies."""
markers = (
"verify you're a human",
"Please verify you are a human",
"unusual traffic",
"captcha-delivery",
"px-captcha",
)
low = html.lower()
return any(m.lower() in low for m in markers)
Now a single fetch is honest: it either returns a real listing or tells you it was blocked so you can retry.
Read price and rating from the embedded JSON-LD
This is the move most Etsy scraping tutorials miss, and it's the one that makes an etsy scraper stop breaking every time Etsy ships a redesign. Etsy maintains a Product JSON-LD block on each listing page for search engines, and it holds title, price, currency, shop, rating, review count, availability, and image in one clean object. Read that instead of chasing CSS classes that change weekly.
Pull the JSON-LD, find the block whose @type is Product, and account for the fact that a page can carry a single object, a bare list, or an @graph array:
import json
import re
from selectolax.parser import HTMLParser
def extract_product_jsonld(html: str) -> dict | None:
tree = HTMLParser(html)
for node in tree.css('script[type="application/ld+json"]'):
raw = node.text()
if not raw:
continue
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
candidates = data if isinstance(data, list) else data.get("@graph", [data])
for obj in candidates:
if isinstance(obj, dict) and obj.get("@type") == "Product":
return obj
return None
Then map it onto the fields you care about. Offers can be a dict or a list, so normalize:
def extract_listing_id(url: str) -> str | None:
m = re.search(r"/listing/(\d+)", url)
return m.group(1) if m else None
def parse_listing(html: str, url: str) -> dict:
product = extract_product_jsonld(html) or {}
offer = product.get("offers", {})
if isinstance(offer, list):
offer = offer[0] if offer else {}
rating = product.get("aggregateRating", {}) or {}
brand = product.get("brand", {}) or {}
return {
"listing_id": extract_listing_id(url),
"title": product.get("name"),
"price": offer.get("price"),
"currency": offer.get("priceCurrency"),
"availability": offer.get("availability"),
"shop": brand.get("name") if isinstance(brand, dict) else None,
"rating": rating.get("ratingValue"),
"review_count": rating.get("reviewCount"),
"image": product.get("image"),
}
Two things worth knowing. The ratingValue comes through at full precision (4.8875), so round it where you display it, not where you store it. And reviewCount in the Product block is the item-level review count, which is not the same as the shop's total sales, so keep those two numbers in separate columns.
If you'd rather not ship a parser at all, the SparkProxy Scraping API can extract server-side with extract_rules. For the HTML-only fields it saves you a step:
import json
rules = {
"sales": ".wt-text-caption a[href*='#reviews']",
"tags": {"selector": "a[href*='/market/']", "type": "list"},
}
# pass params={..., "extract_rules": json.dumps(rules)} and read resp.json()
Check the docs for the exact extract_rules syntax your plan exposes. For the core fields, reading the JSON-LD yourself is still the most stable path because Etsy has a strong incentive to keep that block correct.
Handle geo and currency
Here's the gotcha that silently corrupts an Etsy price dataset: Etsy localizes the displayed currency and the converted price by the visitor's region. The same listing shows 29.00 USD from a US IP and an approximate converted figure in GBP or EUR from elsewhere. If you scrape from rotating IPs across countries without pinning one, your offers.priceCurrency flips between requests and your price series jitters for reasons that have nothing to do with the seller changing the price. This is the whole trap in etsy price scraping.
Two habits fix it:
Pin the region. Set country_code to one value and hold it constant across every run. That fixes the exit country, which fixes the currency Etsy shows.
html = fetch_listing(
"https://www.etsy.com/listing/1234567890/handmade-mug",
country="US", # keep this the same for every run in a series
)
record = parse_listing(html, "https://www.etsy.com/listing/1234567890/handmade-mug")
Store the currency next to the price. Always persist offers.priceCurrency alongside offers.price. Even with a pinned region, recording the currency makes every row self-describing, so a later comparison never silently mixes USD and EUR. A converted price is Etsy's estimate anyway, so the honest number to track over time is the shop's native price in its own currency.
Pick one canonical region per dataset and never mix. If you're building a price-comparison feed, this consistency is the entire job, and we get into it in Datacenter Proxies for Price Comparison Websites.
Scrape and paginate search results
Search pages let you discover listing ids by keyword. Etsy search lives at https://www.etsy.com/search?q=, and every result card links to /listing/. The simplest parse that survives Etsy's redesigns is to collect every listing link and dedupe on the id, which sidesteps the churn in card class names:
from urllib.parse import quote_plus
def search_url(query: str, page: int) -> str:
return f"https://www.etsy.com/search?q={quote_plus(query)}&page={page}"
def parse_search(html: str) -> list[dict]:
tree = HTMLParser(html)
rows, seen = [], set()
for a in tree.css('a[href*="/listing/"]'):
href = a.attributes.get("href", "")
m = re.search(r"/listing/(\d+)", href)
if not m:
continue
lid = m.group(1)
if lid in seen:
continue
seen.add(lid)
rows.append({"listing_id": lid, "url": href.split("?")[0]})
return rows
To walk every page, request &page=N and stop when a page returns no new cards:
def scrape_search(query: str, max_pages: int = 10) -> list[dict]:
results = []
for page in range(1, max_pages + 1):
html = fetch_listing(search_url(query, page))
if is_blocked(html):
continue
rows = parse_search(html)
if not rows:
break
results.extend(rows)
return results
One limit to plan around: Etsy caps how deep search pagination usefully goes, and broad queries like "mug" thin out and repeat before you reach the whole catalog. The fix is to narrow. Split by category and by Etsy's own filters (color, price band, material), run each narrow query to its depth, then dedupe listing ids across queries. Ten targeted searches surface far more distinct listings than one broad one. Once you have the ids, fetch each with fetch_listing and parse_listing to enrich it into full etsy product data.
Scale without getting blocked
At volume, three things keep the pipeline healthy: retries on soft blocks, backoff so you don't spike a single IP, and modest concurrency. With a scraping API the provider rotates the exit IP for you, so your ceiling is your plan's rate limit, not the number of proxies you own. Keep worker counts sane and let retries absorb the occasional challenge.
import time
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_with_retry(url: str, attempts: int = 3) -> str | None:
for i in range(attempts):
html = fetch_listing(url)
if not is_blocked(html) and "application/ld+json" in html:
return html
time.sleep(2 ** i + random.random()) # exponential backoff + jitter
return None
def scrape_listings(urls: list[str], workers: int = 8) -> list[dict]:
out = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(fetch_with_retry, u): u for u in urls}
for fut in as_completed(futures):
html = fut.result()
if html is None:
continue
out.append(parse_listing(html, futures[fut]))
return out
The jitter matters more than it looks. Without random.random(), a batch of failures retries in lockstep and re-triggers the same rate limit. Persist as you go rather than holding everything in memory, so a crash at listing 40,000 doesn't cost you the first 39,999:
import csv
def save_csv(rows: list[dict], path: str = "etsy_listings.csv") -> None:
if not rows:
return
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
For a running price tracker, append a scraped_at timestamp and the country_code you pinned to each row, and key the store on (listing_id, scraped_at). That gives you a clean time series where every price point is comparable because the region, and therefore the currency, was held constant. If you're weighing this managed approach against running your own proxy pool at this volume, Web Scraping API vs Self-Managed Proxies breaks down the economics.
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 Etsy's Terms of Use, which prohibit automated collection. Stick to public listing data, avoid personal data about buyers or sellers, don't overload shop pages, and get legal advice before any commercial use.
Yes, the Etsy Open API v3 at openapi.etsy.com/v3. It's the sanctioned path and returns clean JSON. The getListing endpoint reads a public listing by id with only your app's x-api-key, while private and write endpoints need OAuth 2.0. It's built for managing your own or an authorized shop, though, so it doesn't offer a bulk cross-shop catalog, which is where scraping public pages fills the gap.
Read the Product JSON-LD block that Etsy embeds on every listing page for search engines. It carries offers.price, offers.priceCurrency, aggregateRating.ratingValue, and aggregateRating.reviewCount in one object, and it's far more stable than CSS selectors because Etsy maintains it for Google rich results. Parse the tag and pick the block whose @type is Product.
Etsy serves bot challenges that can return HTTP 200 or 403, so scan the response body, not just the status code. Reduce triggers with rotating residential IPs and, when you need variation data, a real browser: with the SparkProxy Scraping API set premium_proxy=true (and render_js=true for variations), detect the challenge markers in the body, and retry with exponential backoff plus jitter.
Etsy localizes the displayed currency and the converted price by the visitor's region, so the same listing shows USD from a US IP and a converted GBP or EUR figure from elsewhere. Pin country_code to one value and hold it constant, and always store priceCurrency next to price so every row is self-describing and your series never mixes currencies.
Those three aren't in the JSON-LD, so read them from the HTML. Sales count is text like "10,482 sales" near the shop header, tags are the links to Etsy search near the page bottom (up to 13 per listing), and variations are option dropdowns that are populated by JavaScript, so fetch with render_js=true before reading the options.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon — claim it before it's gone
Related articles

How to Scrape Airbnb Listings and Prices
Learn how to scrape Airbnb listings and prices: extract fields from Airbnb's embedded JSON, handle date-based pricing, map pagination, and anti-bot defenses.

How to Scrape GraphQL APIs
Learn to scrape GraphQL API data: find the /graphql endpoint, read the query and variables in DevTools, then replay your own queries with cursor pagination.

How to Bypass reCAPTCHA When Web Scraping
How to bypass reCAPTCHA when web scraping the ethical way: how v2 and v3 scoring work, how to raise your reCAPTCHA score, and solvers as a last resort.
