Every tutorial for this is the same twelve lines. I ran them today, logged every status code, and the interesting part is not that it failed. It is where it failed, which turns out to be a different place than the tutorials assume.
All numbers below are from 17 October 2026, from one residential address, in the order shown.
Attempt 1: the twelve lines
import requests
url = "https://www.ebay.com/sch/i.html"
params = {"_nkw": "incredible hulk 181", "LH_Sold": 1, "LH_Complete": 1}
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/129.0.0.0 Safari/537.36"}
r = requests.get(url, params=params, headers=headers, timeout=30)
print(r.status_code, len(r.content)) # 403 1832
403, 1,832 bytes. Not a captcha, not a login redirect. A page titled Error Page | eBay carrying <meta name="robots" content="noindex,nofollow">.
The useful control: the same request with the sold flags removed, against the ordinary active search, also returned 403, at 1,831 bytes.
So the first block has nothing to do with sold data. It is the request not looking like a browser.
Attempt 2: look like a browser
Copy a real request's headers. accept, accept-language, the sec-ch-ua client hints, the sec-fetch-* set, upgrade-insecure-requests. This is where it gets interesting, because the same headers produced different outcomes for the two URLs.
| Request | Result |
|---|---|
| User agent only, active search | 403, 1,831 bytes |
| User agent only, sold search | 403, 1,832 bytes |
| Full header set, active search | 200, 198,706 bytes, 67 listings |
| Full header set, sold search | 302, empty body |
Identical headers. The active search handed over a real page with 67 listing ids in it. The sold search did not.
That is the actual shape of the problem. Active listings are public and indexable. Completed sales are the dataset eBay sells research subscriptions against.
Attempt 3: it stops working at all
Retrying the sold URL a minute later, following redirects this time, returned 403 rather than the 302.
Then I ran ten more trials, alternating active and sold, ten seconds apart. 403 on all ten, including the active search that had worked minutes earlier.
Total requests before the address was finished: about fifteen.
So there are two findings here, not one:
- Sold search is gated harder than active search.
- The dominant effect over any real workload is address reputation, not headers.
Point 2 is the one that kills projects. Your scraper works on your laptop for an afternoon and dies the moment it does anything useful.
The selector in every tutorial is also wrong
Worth knowing if you do get a page back. Essentially every tutorial and Stack Overflow answer parses .s-item__price.
In the 200 response above, that selector matched zero elements. The prices were in .s-card__price, 70 of them.
eBay reworked the search markup. The tutorials did not. If your scraper quietly returns empty lists, check the class names before you check your parsing.
Why there is no API to fall back to
The usual next question is why not use eBay's own API. The short version:
-
findCompletedItems, the Finding API call that returned sold listings, is retired. - The Browse API is open to everyone and returns active listings only. There is no sold filter, because sold items are not in the index it reads.
- The Marketplace Insights API does carry sold data. It is a limited release restricted to approved partners, and it caps at 90 days.
- Terapeak, now Product Research in Seller Hub, has three years of it and no API, no export, and requires a signed in seller session.
That is the whole reason a third party market exists for this data.
What it actually costs
The parser is an afternoon. The real cost is sessions. eBay has required a signed in account to see sold search results since around the middle of 2026, so the job becomes account management: real accounts, keeping them signed in, addresses that hold up, handling challenges, re-auth when they fail. That is an operations problem with a monthly bill, and no tutorial covers it because it is not fun to write about.
I work on an API that runs that layer, so take the conclusion with the appropriate amount of salt. But the status codes above are reproducible in about five minutes, and I would rather publish the control case (active search, same headers, 200) than imply eBay blocks everything, because it does not.
If you are starting this project: budget for the session layer rather than the parser, and test from the address your code will actually run on, not from your laptop.
Top comments (2)
That first 302 is the only sold response that still had a Location header. The writeup follows redirects a minute later and gets 403, without saying where the 302 pointed. Was it a sign-in page, or a challenge URL?
"The block is not where you think" is the whole lesson — and the 403 is a perfect specimen of a status code describing the server's decision, not your situation. 403 here doesn't mean "forbidden," it means "you don't look like a browser," which is a completely different failure with a completely different fix. I live in this gap: I spent a week trusting an SES 200 OK before I learned 200 means "accepted," not "delivered" — the email died silently downstream. Same shape with If-Match: a 412 tells you exactly which precondition failed, but only if the server bothers to be honest. The interesting follow-up in your data: did the 1,832-byte error page carry any machine-readable signal at all, or is the body just decoration on a lie?