Not a tutorial. A run log. Every status code below came back today, from one residential address, in the order shown.
Published 17 October 2026 by CompsAPI
Search for how to do this and you get some shape of the following. Requests, a desktop user agent, the sold and completed flags on the search URL, BeautifulSoup over the result.
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
That is the whole idea, and it returns HTTP 403 with a 1,832 byte body. Not a captcha, not a login page. An error page titled Error Page | eBay carrying <meta name="robots" content="noindex,nofollow">. The same request without the sold flags, against the ordinary active search, returned 403 too, at 1,831 bytes.
So the first thing to understand is that the initial block is not about sold data at all. It is about the request not looking like a browser.
The next step everybody takes is to copy a real request's headers: accept, accept-language, the sec-ch-ua client hints, the sec-fetch-* set and upgrade-insecure-requests. That changed the outcome, and it changed it differently 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 |
With identical headers, the active search handed over a real page and the sold search did not. That is the moment the actual problem shows itself. Active listings are public and indexable. Completed sales are the data eBay sells research subscriptions against.
Retrying the sold URL a minute later, following redirects this time, returned 403 again rather than the 302. After roughly fifteen requests in total, the address was done: ten more trials, alternating active and sold, ten seconds apart, returned 403 every single time, active included.
So the honest summary of the day is two findings, not one. Sold search is gated harder than active search. And the dominant effect over any real workload is address reputation, which means your scraper works on your laptop for an afternoon and dies the moment it does anything useful.
Worth noting for anyone who does 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 and the tutorials did not. If your scraper quietly returns empty lists, check the class names before you check anything else.
The parser is an afternoon. What sold data actually costs is sessions. eBay has required a signed in account to view sold search results since around the middle of 2026, which turns the job into account management: real accounts, keeping them signed in, residential addresses that hold up, handling the challenge when it appears, and re auth when it does not. That is an operations problem with a monthly bill, and it is the part no tutorial covers because it is not interesting to write about.
It is also the reason this market exists at all. eBay's own APIs do not sell you this: findCompletedItems is retired, the Browse API returns active listings, and Marketplace Insights is approved partners with a 90 day window.
We run that session layer and expose the result as an ordinary API. One GET, JSON back, up to 240 completed sales a request, up to 1,095 days of history, and on Best Offer sales the amount the buyer actually paid rather than the asking price.
import requests
r = requests.get(
"https://api.compsapi.com/v1/comps",
headers={"Authorization": "Bearer YOUR_KEY"},
params={"q": "incredible hulk 181 CGC", "days": 1095},
timeout=30,
)
print(r.status_code, r.json()["stats"]["median"]) # 200 2000.0
That is the same search this post opened with, and it returns the same way every time, which is the entire pitch. A free key is 100 requests a month with every filter enabled.
If you would rather keep scraping, nothing here is meant to talk you out of it. Just budget for the session layer rather than the parser, and test from the address your code will actually run on, not from your laptop.
100 free requests a month · every filter on · no card
Get a free key