kestrel actors › Ecommerce
eBay Scraper — Search, Listings, Prices & Sellers
An eBay scraper that turns search results and item pages into clean rows: price, struck-through list price, shipping cost, condition, buying format, bids and time left, units sold, watchers, seller feedback score and percentage, item location and image. No API key, no login, no browser. It reads the same public pages a shopper sees, on 18 marketplaces, and bills you per listing row it actually delivers. Use it for ebay price research, for bulk ebay product data, and as an ebay api alternative when the official Browse API's onboarding is more than your project needs.
eBay scraper pricing: what one row costs
| Event | What it is | Price | Charged for |
|---|---|---|---|
item | Listing row | $0.003 | One eBay listing from a search page or an item page: price, list price and discount, shipping, condition, buying format, bids and time left, units sold, watchers, seller feedback and location. Searches that return nothing, rows dropped by your price bounds and listings already delivered earlier in the run are all free. |
Pay per event: $0.003 per listing row, and nothing else. There is no per-run start fee and no compute charge on top.
- A search that returns nothing is free.
- Listings where eBay withholds the price ("See price") are dropped free whenever you have set a price bound.
- Rows dropped by your price bounds are free.
- A listing found by two of your keywords is delivered and billed once per run.
statusrows are free.- If your spending limit is reached mid-run, the run stops delivering rather than delivering rows it could not charge for — you never receive unpaid data and never pay for data you did not receive.
One page of 60 listings is $0.18. A daily two-page watch on five keywords is about $1.80 a month.
eBay scraper input reference
Every field of eBay Search & Listings, straight from the actor's input schema.
| Field | Type | Default | What it does |
|---|---|---|---|
queries | array | [] | Keywords to search, exactly as a shopper would type them: "mechanical keyboard", "vintage pocket watch". Each is searched across the pages set below, on the marketplace you choose. |
startUrls | array | [] | eBay search URLs (https://www.ebay.com/sch/i.html?_nkw=...) to read exactly as pasted — every filter already in the URL is kept — and/or individual item URLs (https://www.ebay.com/itm/123456789012), which return the richer item-page row. The marketplace is taken from each URL, so you can mix countries in one run. |
domain | string | "com" | eBay marketplace for the keyword searches: com, de, fr, it, es, at, nl, pl, com.au, com.sg, com.my. These are the hosts measured answering reliably on 2026-08-29; eBay UK, Ireland, Canada, Switzerland and Hong Kong currently return an anti-bot challenge on every proxy pool, so they are not offered here — paste their URLs into the field above if you want to try anyway. URLs bring their own marketplace. |
buyingFormat | string (enum) | "all" | Restrict to Buy It Now listings or to auctions. Auction cards carry the bid count and the time left; fixed-price cards carry units sold and watchers. One of: all, buy_it_now, auction. |
conditions | array | [] | Keep only these conditions. eBay grades refurbished in four separate tiers, so pick the ones you actually accept. Leave empty for every condition. |
minPrice | integer | 0 | Lower price bound in the marketplace's own currency. Sent to eBay as _udlo so the results arrive already filtered, then checked again before billing — you are never charged for a listing outside your range. Range 0-10000000. |
maxPrice | integer | 0 | Upper price bound in the marketplace's own currency. Sent to eBay as _udhi and checked again before billing. Leave at 0 for no upper bound. Range 0-10000000. |
category | string | "" | Optional eBay category id to search inside, e.g. "175672" for computer components. It is the _sacat number in any eBay category URL. Leave empty to search all of eBay. |
sortBy | string (enum) | "best_match" | How eBay orders the results. Price sorts are on price plus shipping, which is what a shopper actually compares. One of: best_match, ending_soonest, newly_listed, price_low, price_high. |
pages | integer | 1 | How many result pages to walk. Each page carries 60 listings, and eBay stops paginating a search at about 2000 items however many it reports. Range 1-34. |
maxResultsPerQuery | integer | 0 | 0 = everything the pages returned. N = the first N in eBay's order. The main cost control: the run stops fetching once each job has N. Range 0-2000. |
sessions | integer | 4 | How many proxy sessions (egress IPs) run in parallel. More is faster; each is paced separately and warmed on eBay's homepage before its first request. Range 1-16. |
perIp | number | 0.5 | Pace for each session. eBay tolerates about 0.5/s per IP; faster earns interstitials, not speed. Range 0.1-2. |
proxyConfiguration | object | {"useApifyProxy": true} | Apify Proxy is required: eBay answers unproxied and unrotated callers with an Akamai interstitial. The run starts on datacentre proxies and escalates to RESIDENTIAL by itself when eBay pushes back. |
Example input
{
"queries": ["mechanical keyboard", "keycap set"],
"domain": "com",
"pages": 2,
"maxResultsPerQuery": 60
}
{
"startUrls": [
"https://www.ebay.co.uk/sch/i.html?_nkw=lego+star+wars&_sacat=19006",
"https://www.ebay.de/sch/i.html?_nkw=nintendo+switch+oled"
],
"pages": 3
}
eBay scraper output fields
Two row types share one dataset: item (one eBay listing from a search page or an item page, charged) and status (one per keyword or URL, free).
Dataset views in the Apify Console: Overview, Demand signals, Sellers, Price research, Item pages, Run status.
| Field | Type | What it holds |
|---|---|---|
type | string | Row type: item or status. |
item_id | string | eBay's numeric listing id — the number in any /itm/ URL. |
title | string | Listing title as the seller wrote it. |
url | string | Canonical item page URL. |
query | string | Search rows: the keyword that returned this listing. |
search_url | string | Search rows: the exact eBay URL this page was read from, filters included. |
page | integer | Which results page the row came from. |
position | integer | The slot in eBay's own result order, counted across pages. |
domain | string | Marketplace domain suffix, e.g. com or co.uk. |
marketplace | string | Marketplace country name. |
price | number | Price as a number. For a price range, the low bound — what a shopper compares on. Null when eBay withholds the price (a MAP-restricted listing shows "See price" until checkout); price_display then carries eBay's own wording, and a run with price bounds set drops those rows free rather than billing them. |
price_display | string | Price exactly as eBay printed it, range and currency prefix included. |
currency | string | Currency read from the rendered price, not assumed from the marketplace. |
list_price | number | Struck-through original price when eBay shows one. |
list_price_display | string | Original price exactly as printed. |
discount_pct | number | How far below the list price the current price sits, in percent. |
shipping_cost | number | Shipping charge as a number; 0 when delivery is free. |
shipping_display | string | Shipping line exactly as printed, delivery estimate included. |
free_shipping | boolean | True when eBay shows free delivery. |
condition | string | Condition label: Brand New, Pre-Owned, Open Box, Certified - Refurbished and so on. |
buying_format | string | buy_it_now or auction. |
bids | integer | Auction rows: how many bids have been placed. |
time_left | string | Auction rows: eBay's own time-left text, e.g. "9m" or "1d 4h". |
best_offer | boolean | True when the seller accepts offers. |
units_sold | integer | How many units this listing has sold — the closest public signal to real demand. |
watchers | integer | How many shoppers are watching this listing. |
returns | string | Returns line when eBay shows one, e.g. "Free returns" or "30 days returns". |
seller | string | Seller username or store name. |
seller_feedback_pct | number | Seller's positive-feedback percentage. |
seller_feedback_count | integer | Seller's feedback score, expanded from eBay's abbreviated form (40.6K -> 40600). |
location | string | Where the item ships from. |
image | string | Listing image URL. |
rating | number | Product star rating out of 5, when the listing is matched to an eBay catalogue product. |
ratings_count | integer | How many product ratings that catalogue product has. |
results_total | integer | How many listings eBay reports for the whole search. |
quantity_available | string | Item-page rows: availability text, e.g. "Last one" or "More than 10 available". |
breadcrumbs | array | Item-page rows: eBay's category path for this listing. |
category | string | Item-page rows: the leaf category from the breadcrumb. |
item_specifics | object | Item-page rows: the seller's specifics table — brand, model, MPN, colour, size and the rest. |
seller_url | string | Item-page rows: the seller's eBay store or profile URL. |
status | string | Status rows: ok, no_results or error. |
target | string | Status rows: the keyword or URL this job was given. |
kind | string | Status rows: search, url or item. |
items | integer | Status rows: listing rows delivered by this job. |
filtered | integer | Status rows: rows dropped by your price bounds, never charged. |
duplicates | integer | Status rows: listings another job in the run already delivered. |
pages | integer | Status rows: how many pages were requested. |
error | string | Status rows: why the job failed, null when it did not. An eBay refusal lands here, never as an empty result. |
fetched_at | string | UTC timestamp when the row was fetched. |
eBay scraper in Python, JavaScript and curl
Python
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("kestrel/ebay-search-scraper").call(run_input={
"queries": ["mechanical keyboard"],
"pages": 2,
"minPrice": 30,
"maxPrice": 200,
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
if row["type"] == "item":
print(row["price"], row["currency"], row["units_sold"], row["title"])
JavaScript (Node)
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });
const run = await client.actor('kestrel/ebay-search-scraper').call({
queries: ['keycap set'],
pages: 1,
sortBy: 'price_low',
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items.filter((r) => r.type === 'item').length, 'listings');
curl
curl -X POST "https://api.apify.com/v2/acts/kestrel~ebay-search-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'content-type: application/json' \
-d '{"queries":["vintage pocket watch"],"buyingFormat":"auction","pages":1}'
The same three calls are runnable files in the examples repository: examples/ebay-search-scraper.py|.js|.sh.
Limits and honest notes
- No sold or completed listings. eBay's
LH_Sold=1&LH_Complete=1view is behind a sign-in gate: a logged-out request is redirected tosignin.ebay.<tld>. Verified on 2026-08-29 across seven marketplaces, datacentre and residential proxies in seven countries, eight URL routes and three browser fingerprints — not one of roughly 115 attempts returned sold results. A headless browser does not change it, because the block is an HTTP redirect rather than a JavaScript challenge. The honest substitute isunits_sold, the units a live listing has already sold at its current price. - Sixty listings per page, which is eBay's own ceiling (
_ipg=60), and eBay stops paginating a single search at roughly 2,000 items whateverresults_totalreports — which is whypagesis capped at 34. - There is no
sponsoredcolumn. eBay prints the Sponsored disclosure on every card — 60 of 60 on four searches across two marketplaces and both buying formats — so a column that is always true was left out and the finding documented instead. - Auction cards carry no seller line on the results page, so those rows carry the seller from the item page only.
FAQ
How many listings are on each eBay page?
Sixty. The actor requests _ipg=60, which is eBay's ceiling. pages: 3 therefore reads up to 180 listings per job. eBay stops paginating any single search at roughly 2000 items however many it reports in results_total, which is why pages is capped at 34.
Why did my search return no rows?
Either eBay genuinely found nothing, or the run was refused. The two are never confused: a genuine miss produces a status row with status: "no_results" and costs nothing, and a refusal produces status: "error" with the reason. This matters more than it sounds — eBay's zero-result page still renders about twenty "results matching fewer words" cards from other searches, and a scraper that delivers those bills you for listings you never asked for. This one drops them.
Do I need a proxy?
Yes, and Apify Proxy is the default. eBay is behind Akamai Bot Manager and refuses unrotated callers with a /splashui/challenge interstitial. The run opens several proxy sessions, warms each one against the marketplace homepage, paces every session separately, retries on a different IP whenever a page is refused, and escalates from datacentre to residential proxies by itself once eBay has pushed back twice. Measured at one request per second on 2026-08-29: datacentre answered 5 of 10 searches and residential 8 of 10, so with retries a run completes reliably on either.
Are sponsored listings flagged?
There is deliberately no sponsored column. eBay's current results page prints the (deliberately obfuscated) "Sponsored" disclosure on every card — 60 of 60 on four different searches across two marketplaces and both buying formats, all from the same organic results module. A column that is always true is worse than no column, so we left it out and documented the finding instead.
Can I track prices over time?
Yes — schedule the run and keep the datasets. Each row carries fetched_at, item_id and price, which is everything a time series needs. A daily run of the same keywords, appended to one table and grouped by item_id, gives you an ebay price tracker with the listing's units_sold and watchers moving alongside the price, which is far more informative than the price alone.
What about eBay Kleinanzeigen or eBay Motors?
Kleinanzeigen is a separate site with its own markup and is not covered. Motors listings are ordinary eBay listings and come back like any other, though vehicle-specific attributes live in item_specifics on item-page rows rather than in dedicated columns.
Is scraping eBay legal?
This actor reads only publicly available pages — the same search results and item pages any visitor sees without logging in — and collects no personal data beyond the seller usernames and store names eBay itself publishes next to every listing. Public-data scraping of this kind is broadly lawful in the US and the EU, and courts have repeatedly declined to treat access to a public web page as unauthorised access. That is not the whole story, though: eBay's own Terms of Service restrict automated access, so scraping can be a contractual matter between you and eBay even where it is not a legal one. You are responsible for your own use — respect robots directives and rate limits, do not republish listing content wholesale, keep collection proportionate to your purpose, and take your own legal advice for anything commercial. We do not scrape anything behind a login, and we will not: that is exactly why the sold-listings view above is absent rather than faked.
How do I find an eBay category id?
Open any eBay category in a browser and read the _sacat number out of the URL, or click into a category from a search and copy it from the address bar. Pass it as category and every keyword in the run is searched inside that category only.
How to use eBay Scraper
Related scrapers
Run eBay Scraper
The actor lives on the Apify Store, where the pricing tab carries the live per-row price and the input form has every field above: apify.com/kestrel/ebay-search-scraper