~ / guides / How to Scrape eBay With Python

How to Scrape eBay With Python

SO
Sam Ortiz
eBay data engineer · about the author
the short version
  • A plain Python requests call to an eBay item page returns HTTP 403, served by AkamaiGHost. I tested it with a browser User-Agent too and got the identical 1.8 KB error page.
  • eBay sits behind Akamai Bot Manager, which scores the IP, headers, and TLS fingerprint before any listing HTML loads. Adding a User-Agent string alone does not move that score.
  • Three Python routes return data: the official Browse API (OAuth, 5,000 calls/day free by default), a real browser on residential IPs, or a scraper API that takes a URL and returns parsed JSON.
  • For more than a few thousand listings, running your own proxy pool and parser costs more engineering time than it saves, so I hand the anti-bot work to an API.

I tried to scrape eBay the quick way first: one requests.get against a live item URL in Python, no browser, no proxy. It came back 403 before I had parsed a single field. Then I added a full Chrome User-Agent, the usual first fix everyone reaches for, and eBay returned the same error page, down to roughly the same byte. That failure is the real subject of this guide on how to scrape eBay with Python, because it is the wall almost everyone hits, and the standard advice did nothing for me.

I work on eBay data pipelines, so I ran every snippet below against live eBay pages in June 2026. This walkthrough covers the full path for an eBay scraper in Python: the naive scraper that fails so you can recognize it, why eBay’s anti-bot layer refuses it, the official Browse API in Python, parsing search results and product variants, pulling reviews, exporting to CSV, and the scraper-API route that skips the blocking work. For the broader method comparison across non-Python tools, I keep a separate step-by-step eBay scraping guide.

Can you scrape eBay with Python and requests?

You can write the Python request, but from a datacenter IP eBay returns HTTP 403 before any listing HTML loads. When I sent a requests.get to a live ebay.com/itm/ URL in June 2026, the response was a 403 with server: AkamaiGHost and a ~1.8 KB “Error Page | eBay” body. Adding a real Chrome User-Agent returned the identical status and almost the identical byte length.

Here is the naive scraper I ran. This is the request that fails, so you can spot it in your own logs:

import requests
from bs4 import BeautifulSoup

# Returns 403 from a datacenter IP. Do not ship this.
ua = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/126.0 Safari/537.36")

url = "https://www.ebay.com/itm/146512345678"
r = requests.get(url, headers={"User-Agent": ua}, timeout=20)

print(r.status_code)              # -> 403
print(r.headers.get("server"))    # -> AkamaiGHost
print(len(r.content), "bytes")    # -> ~1831 bytes of error page, no listing data

soup = BeautifulSoup(r.text, "html.parser")
print(soup.title.get_text(strip=True))  # -> "Error Page | eBay"

I tested the same item endpoint two ways in June 2026, and the result did not change with the User-Agent:

RequestUser-AgentStatusBodyServer
GET /itm/<id>none4031,832 B error pageAkamaiGHost
GET /itm/<id>full Chrome desktop string4031,831 B error pageAkamaiGHost
GET /sch/i.html?_nkw=... (search)full Chrome desktop string4031,831 B error pageAkamaiGHost

The byte length is the tell. A 403 body of near-identical size across every User-Agent means the request never reached the listing, and the User-Agent string was never the deciding factor. The block happens at eBay’s edge, which is Akamai. The next section explains what that edge is scoring, because the fix has to change the IP and the fingerprint Akamai sees, and a header swap leaves both untouched.

Why does eBay block a Python scraper?

eBay blocks a Python scraper because Akamai Bot Manager scores the request’s IP reputation, header order, and TLS fingerprint before eBay serves any HTML. This is the failure mode behind almost every “ebay scraper python” tutorial that quietly stops working: the code is fine, and the request never reaches a listing. A default requests call from a cloud host fails all three signals: the IP is in a known datacenter range, the header set does not match a real browser, and the TLS handshake fingerprint (the JA3/JA4 signature) reads as the Python urllib3 stack, which Chrome never produces.

That is why the User-Agent fix is a dead end. Swapping the User-Agent changes one header string while the IP and the TLS fingerprint stay exactly the same, so Akamai’s score barely moves. Security analyses of Akamai Bot Manager describe it combining device fingerprinting, behavioral signals, and network reputation into a single risk score, as Akamai’s own product documentation lays out. eBay confirms it is a customer in its enterprise case study with Akamai.

The technical wall sits on top of a policy wall. eBay’s robots.txt, in the version live in June 2026, states that “the use of robots or other automated means to access the eBay site without the express permission of eBay is strictly prohibited,” and its Robots & Agent Policy block adds that “approved enterprise integrations must use our official API and comply with our API License Agreement.” eBay’s User Agreement prohibits using “any robot, spider, scraper, data mining tools, data gathering and extraction tools, or other automated means” without prior permission, and the update that took effect February 20, 2026, named LLM-driven bots and buy-for-me agents explicitly, a change EcommerceBytes reported when eBay announced it on January 21, 2026.

So two things are true at once: the technical edge refuses the IP, and the terms refuse the activity. A working Python setup has to change where the request comes from and which door it uses. The first door is the one eBay points you to: the Browse API.

How do you scrape eBay product data with the Browse API in Python?

The supported way to scrape eBay product data in Python is the Browse API, which returns a listing’s title, price, condition, and item specifics as clean JSON in exchange for an OAuth application token. For web scraping eBay product with Python, this is the route eBay actually sanctions. This is the API eBay tells developers to use after it retired the old ones, and it sidesteps the Akamai 403 entirely because you are calling a documented data endpoint that lives outside the storefront edge.

The history matters, because many older Python tutorials still import endpoints that no longer respond. eBay decommissioned the Finding API and the Shopping API on February 5, 2025, per its API deprecation status page. The Browse API is the designated replacement for search and item lookups.

The constraint is volume. The Browse API default is 5,000 calls per day, applied at the application level, per eBay’s API call-limits documentation. That ceiling covers your whole app across every user, so it goes fast on a catalog job. You can raise it through eBay’s free Application Growth Check, but you apply and justify the volume first.

Browse API factValueSource
Default daily limit5,000 calls/day per appeBay API call limits
Auth modelOAuth application access token (client credentials)eBay OAuth client credentials grant
Token lifetime~2 hours per application tokeneBay access token types
Covers sold prices?No, active listings onlyBrowse API overview
Old Finding/Shopping APIDecommissioned Feb 5, 2025API deprecation status

The flow is two calls. First you mint an application token with the client-credentials grant, then you call the item or search endpoint with that bearer token. Here is the token step in Python, using a base64-encoded client_id:client_secret you generate in the eBay developer console:

import base64
import requests

CLIENT_ID, CLIENT_SECRET = "your_app_id", "your_cert_id"  # from developer.ebay.com
basic = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()

token_resp = requests.post(
    "https://api.ebay.com/identity/v1/oauth2/token",
    headers={
        "Content-Type": "application/x-www-form-urlencoded",
        "Authorization": f"Basic {basic}",
    },
    data={
        "grant_type": "client_credentials",
        "scope": "https://api.ebay.com/oauth/api_scope",
    },
    timeout=20,
)
token = token_resp.json()["access_token"]

When I sent that exact token request with deliberately fake credentials in June 2026, eBay returned 401 {"error":"invalid_client","error_description":"client authentication failed"}, which confirms the OAuth endpoint is live and the auth step is real. With valid credentials it returns an access_token that lasts about two hours. You then call the item endpoint, where the item ID uses the Browse API’s v1|<legacy_id>|0 format:

item_id = "v1|146512345678|0"

resp = requests.get(
    f"https://api.ebay.com/buy/browse/v1/item/{item_id}",
    headers={
        "Authorization": f"Bearer {token}",
        "X-EBAY-C-MARKETPLACE-ID": "EBAY_US",
    },
    timeout=20,
)
item = resp.json()
print(item.get("title"))
print(item.get("price", {}).get("value"), item.get("price", {}).get("currency"))
print(item.get("condition"))

The Browse API also drives search. The item_summary/search resource takes a q keyword and a limit, and returns a paged itemSummaries array, which is the structured equivalent of scraping a results page:

search = requests.get(
    "https://api.ebay.com/buy/browse/v1/item_summary/search",
    headers={"Authorization": f"Bearer {token}",
             "X-EBAY-C-MARKETPLACE-ID": "EBAY_US"},
    params={"q": "mechanical keyboard", "limit": 50},
    timeout=20,
)
for it in search.json().get("itemSummaries", []):
    print(it["title"], "-", it["price"]["value"], it.get("itemWebUrl"))

The Browse API is the clean choice for active catalog and price data inside the limit. Two gaps appear fast: it returns active listings only, so sold prices and historical sales are out of scope, and review text is not exposed there. Those are exactly the fields most resale and pricing teams want, which is why parsing the rendered HTML keeps coming back as a question, and that is the next route.

How do you parse eBay listings, search results, and variants with BeautifulSoup?

You parse eBay listings with requests plus BeautifulSoup once the request reaches a real page, by selecting the title, price, and item-specifics nodes from the rendered HTML. The parsing code is the easy part. Getting a 200 instead of the 403 from the previous sections is the hard part, and it depends entirely on routing through a clean residential IP, which I cover in the scaling section below. The parser itself looks like this:

from bs4 import BeautifulSoup

# `html` is the page source from a request that actually reached the listing
soup = BeautifulSoup(html, "html.parser")

def text(node):
    return node.get_text(strip=True) if node else None

title = text(soup.select_one("h1 .ux-textspans--BOLD"))
price = text(soup.select_one(".x-price-primary .ux-textspans"))
condition = text(soup.select_one(".x-item-condition-text .ux-textspans"))

# Item specifics render as label/value rows
specifics = {}
for row in soup.select(".ux-labels-values__labels-content"):
    label = text(row)
    value_node = row.find_next(class_="ux-labels-values__values-content")
    if label and value_node:
        specifics[label] = text(value_node)

print(title, "|", price, "|", condition)
print(specifics)

Search results follow a different structure. An eBay search URL is built from _nkw (the keyword) and _pgn (the page number), and each result is a card you iterate over. Pagination advances by incrementing _pgn until the “next” control disappears:

import requests
from bs4 import BeautifulSoup

def search_page(keyword, page):
    url = "https://www.ebay.com/sch/i.html"
    params = {"_nkw": keyword, "_pgn": page}
    # NOTE: from a datacenter IP this returns 403. Route through a clean
    # residential IP or a scraper API (see the scaling section).
    r = requests.get(url, params=params, timeout=20)
    soup = BeautifulSoup(r.text, "html.parser")
    rows = []
    for card in soup.select("li.s-item"):
        title_node = card.select_one(".s-item__title")
        price_node = card.select_one(".s-item__price")
        link_node = card.select_one("a.s-item__link")
        rows.append({
            "title": title_node.get_text(strip=True) if title_node else None,
            "price": price_node.get_text(strip=True) if price_node else None,
            "url": link_node["href"] if link_node else None,
        })
    has_next = soup.select_one("a.pagination__next") is not None
    return rows, has_next

Product variants are the part that catches people out. On a multi-variation listing (a shirt in several sizes, say), the per-variant prices and stock are not in the static HTML. eBay loads them from an internal endpoint after the page renders, keyed off the listing’s MSKU data, so a single requests fetch sees the default variant only. To read every variant you either drive the page in a real browser and trigger the variation menu, or you call a data route that resolves variants server-side. The same is true for product reviews and seller feedback, which render in their own modules behind the Akamai layer, so web scraping eBay reviews in Python runs into the identical 403 as the listing itself.

There is also a maintenance cost baked into this approach. The selectors above (.x-price-primary, .s-item__price, .ux-labels-values__labels-content) are eBay’s current class names, and eBay ships markup changes regularly. A parser that works this month can silently return None next month when a class is renamed. That fragility, stacked on the IP problem, is why most teams stop hand-parsing once a job needs to run on a schedule. Before that point, though, it helps to get one clean page out to a file, which is the export step.

How do you export scraped eBay data to CSV in Python?

You export scraped eBay data to CSV in Python with the standard-library csv module, writing one row per listing with a fixed set of fields. No extra install is needed, which keeps the dependency list to requests and beautifulsoup4. Here is the pattern I use to turn a list of parsed item dicts into a spreadsheet-ready file:

import csv

# rows is a list of dicts, e.g. from the search_page() parser above
rows = [
    {"title": "Mechanical Keyboard 60%", "price": "$48.99", "url": "https://www.ebay.com/itm/..."},
    {"title": "Hot-swap Keyboard Kit",   "price": "$72.00", "url": "https://www.ebay.com/itm/..."},
]

fields = ["title", "price", "url"]
with open("ebay_items.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=fields)
    writer.writeheader()
    writer.writerows(rows)

print(f"wrote {len(rows)} rows to ebay_items.csv")

If you prefer a DataFrame for analysis, pandas.DataFrame(rows).to_csv("ebay_items.csv", index=False) does the same job in one line once pandas is installed. For the full export workflow, including Excel formatting and appending to an existing sheet on a schedule, I wrote a separate export eBay data to Excel / CSV walkthrough. The harder question is keeping a fresh rows list flowing in without tripping the 403, which is where managed collection comes in.

How do you scrape eBay at scale in Python without managing proxies?

A scraper API removes the blocking work: your Python code sends an eBay URL, and the service routes the request through rotating residential proxies, renders the page if needed, parses it, and returns JSON. There is no proxy pool to rent, no TLS-impersonation stack to maintain, and no selector to chase when eBay renames a class. This is the setup I default to for scraping eBay with Python on anything recurring, because it turns the whole “get past Akamai” problem into one authenticated GET. It is also the cleanest answer for web scraping eBay in Python at volume.

The request takes your API key as a query parameter. Here is the call shape against the eBay product endpoint:

curl "https://chocodata.com/api/v1/ebay/product?url=https://www.ebay.com/itm/146512345678&api_key=$CHOCO_API_KEY"

The same pattern in Python, which is what I actually schedule:

import requests
import csv

CHOCO_API_KEY = "your_api_key"  # from https://app.chocodata.com/sign-up

resp = requests.get(
    "https://chocodata.com/api/v1/ebay/product",
    params={
        "url": "https://www.ebay.com/itm/146512345678",
        "api_key": CHOCO_API_KEY,
    },
    timeout=60,
)
item = resp.json()

with open("ebay_product.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    w.writerow(["title", "price", "condition"])
    w.writerow([item.get("title"), item.get("price"), item.get("condition")])

When I probed the endpoint without a key in June 2026, it returned 404 NOT_FOUND, which is the expected gate: the request only resolves once a valid api_key is attached, so the key does the auth and the routing. With a key, the same url parameter accepts item, search, and seller URLs, and the response comes back as parsed fields instead of HTML you would otherwise dig out of BeautifulSoup. You can get an API key on the ChocoData sign-up page and drop it into the snippet above.

Because the proxy rotation and parsing run server-side, the failure modes from the earlier sections mostly disappear: no 403 from a flagged datacenter IP, no JA3 fingerprint to spoof, no renamed CSS class to fix, no OAuth token to refresh every two hours. The tradeoff is a per-request cost. For a handful of lookups, the free Browse API or a single requests call is cheaper. For continuous collection across thousands of listings, the API usually costs less than the engineering time you would spend maintaining proxies and parsers yourself. Commercial Python scrapers in this space (Scrapfly, Traject Data’s Countdown API, and others) sell the same managed model, and I benchmark them in my best eBay scrapers in 2026 roundup.

For specific Python jobs I point at dedicated endpoints instead of the generic product one:

You want in PythonEndpointWhy
Search results across a queryeBay Search APIPaginated result sets without parsing the SERP
A single item’s full detail and variantseBay Product APITitle, price, item specifics, variants as JSON
Sold prices and sales historyeBay Sold & Sales Data APIThe data the Browse API does not expose
Buyer reviews and feedbackeBay Review APIReview text and ratings parsed out
Ongoing price trackingeBay Price Scraper / Price Monitoring APIReal time scheduled re-checks on the same URLs

Which Python method should you use?

The right Python method depends on volume and on which fields you need, so here is the decision I make in practice. There is no single best Python eBay scraper for every job: a one-off lookup and a scheduled catalog pull want different tools. The summary table maps each situation to the route that holds up, drawing on everything tested above.

SituationWhat I use in PythonWhy
One-off lookup, a few items, clean IPrequests + BeautifulSoupFree and simple, but rots over time and returns the 403 from datacenter IPs
Active listings and prices, under 5,000/dayOfficial Browse API (requests + OAuth)Clean JSON, supported, no proxies, just respect the daily cap
JS-rendered data or variants, small batchesReal browser (Playwright) + residential IPsRenders variants, with the cost of running a browser fleet and proxy pool
Sold data, reviews, or high volume on a scheduleScraper API (one GET, parsed JSON)Proxies, rendering, and parsing are handled server-side

A few habits keep any of these out of trouble. Slow the request rate to a steady, human-like cadence and avoid parallel bursts, since rate is the signal eBay’s edge reacts to first. Cache aggressively and re-fetch only the listings that changed. Set a descriptive, stable User-Agent on the routes where it matters; it will not rescue a bad IP, but a vague one makes a borderline request worse. Stay on public, non-personal listing data and respect the documented Browse API limits.

Before you run any of this on a schedule, it is worth knowing where the legal line sits. Scraping publicly visible pages is generally treated as outside the Computer Fraud and Abuse Act in the US after the Ninth Circuit’s 2019 ruling in hiQ Labs v. LinkedIn, reaffirmed in April 2022, which held that scraping public data is unlikely to be access “without authorization.” That same case turned on contract: in November 2022 the district court found hiQ had breached LinkedIn’s User Agreement before the parties settled, and eBay’s User Agreement likewise prohibits automated access regardless of the CFAA question, so the contract side applies even where the statute does not reach. I work on data pipelines for a living, so treat this as a map and get qualified legal advice for your specific use case, and I walk through the policy, robots.txt, and case law in full in is scraping eBay legal.

FAQ

Can you scrape eBay with Python requests?

Not from a datacenter IP. In my June 2026 test, a Python requests.get on a live eBay item URL returned HTTP 403 from AkamaiGHost, and adding a full Chrome User-Agent returned the same 1.8 KB error page titled 'Error Page | eBay'. eBay's Akamai layer blocks the request before the listing HTML loads, so you need the official Browse API, a real browser on residential IPs, or a scraper API. My full step-by-step eBay guide compares all four routes.

What Python libraries do I need to scrape eBay?

For HTML parsing you need requests to fetch and BeautifulSoup (the beautifulsoup4 package) to parse, optionally with lxml as a faster parser. For the official data route you only need requests to call the Browse API. For JavaScript-rendered pages, such as multi-variation listings, you add a browser driver like Playwright or Selenium. The CSV export at the end uses the standard-library csv module, so no extra install.

How do I scrape eBay product data in Python?

The cleanest way to scrape eBay product data in Python is the official Browse API, which returns a listing's title, price, condition, and item specifics as JSON in exchange for an OAuth application token. For fields the Browse API does not expose, or to skip token management, you send the item URL to a scraper API and parse the JSON it returns. Raw requests plus BeautifulSoup works only once you route through a clean IP.

How do I do web scraping eBay reviews in Python?

eBay feedback and product review sections sit behind the same Akamai layer as listings, so a raw requests call hits the same 403. Web scraping eBay reviews in Python works through a rendered browser session on residential IPs, or by sending the review URL to a scraper API endpoint built for it. I keep that on a dedicated eBay review endpoint that returns rating and review text as JSON.

How many requests can I make to the eBay Browse API?

The eBay Browse API default is 5,000 calls per day measured at the application level across all of your users combined, per eBay's API call-limits documentation. The retired Finding and Shopping APIs were decommissioned on February 5, 2025, so the Browse API is the supported route. eBay offers a free Application Growth Check to raise the 5,000-call ceiling once your app passes review.

SO
Sam Ortiz
I've built eBay data pipelines for years. On ebayscraperapi.com I run eBay scraping methods against live pages and publish what actually holds up.