Getting eBay sold listings into a CSV takes a search request, a few cleaning rules, and an export that preserves the context behind each price.
In this tutorial, you will use Python and SoldFetch to search for used Sony WH-1000XM5 headphones, separate likely matches from rows that need review, and save the results for a spreadsheet or another application. The script uses Python’s standard library, so there are no packages to install.
You will create three files: raw.json for the original API responses and warnings, candidates.csv for rows that pass your rules, and review.csv for rows that need a closer look.
Understand the sold listing data
SoldFetch’s search endpoint returns JSON with a top-level items array. For a sold search, the price field is soldPrice, and its currency is soldCurrency.
Keep these distinctions in your export:
| Field | How to use it |
|---|---|
itemId and url |
Identify the listing, remove duplicates, and revisit the source. |
soldPrice and soldCurrency |
A displayed listing price and its currency. The amount is not a verified accepted-offer payment. |
shippingPrice and shippingCurrency |
Preserve unknown shipping as blank. Explicit free shipping can be 0.00. |
totalPrice |
Use the supplied total when available. Leave it blank when the components cannot establish a total. |
endedAt |
A supplier-reported date that has not been independently verified. |
scrapedAt and fetchedAt |
Retain observation timing so you can recognize older data. |
quality and warnings |
Keep the limits on price interpretation, dates, and product matching. |
The example is a sample of returned listings for one query and marketplace. It does not establish every sale of that product or a complete historical price series.
Set up Python and your API key
Use Python 3.9 or newer. Create a SoldFetch API key in your dashboard, then set it in the terminal where you will run the script:
export SOLDFETCH_API_KEY="YOUR_SOLDFETCH_API_KEY"
Replace the placeholder with your own key. Keep it in your local environment and out of source control.
The example uses ebay.com and USD. Check current capabilities before changing marketplaces, and keep the currency and cleaning rules aligned with your new search.
Save the Python script
Download the complete Python script and save it as sold_listings_csv.py, or copy the code below.
The first run requests one page of up to 200 results. The request sets sold=true and itemCondition=used; the local cleaner then checks the model text, currency, price basis, and a small set of accessory or parts phrases.
"""Search SoldFetch sold listings, keep an audit trail, and export CSV."""
import argparse
import csv
import json
import os
import re
import time
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import HTTPRedirectHandler, Request, build_opener
SITE = "ebay.com"
KEYWORD = "sony wh-1000xm5"
CURRENCY = "USD"
COUNT = 200
MODEL = re.compile(r"\bwh[-\s]?1000xm5\b", re.I)
EXCLUDE = re.compile(
r"\b(for parts|parts only|not working|broken|empty box|box only|"
r"ear\s?pads?|replacement|case only|carrying case)\b", re.I
)
FIELDS = [
"ebaySite", "itemId", "title", "url", "condition", "displayedPrice",
"currency", "shippingPrice", "shippingCurrency", "totalPrice",
"endedAt", "scrapedAt", "priceBasis", "saleDateVerified",
"exactProductMatchVerified", "acceptedOfferAmount", "reviewReason",
]
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def fetch_page(page):
key = os.environ.get("SOLDFETCH_API_KEY", "").strip()
if not key:
raise ValueError("Set SOLDFETCH_API_KEY before running this script.")
params = {
"keyword": KEYWORD, "ebaySite": SITE, "sold": "true",
"itemCondition": "used", "count": COUNT, "page": page,
"sortOrder": "endedRecently", "exactMatch": "true",
}
request = Request(
"https://soldfetch.com/v1/scrape?" + urlencode(params),
headers={"Authorization": "Bearer " + key, "Accept": "application/json"},
)
try:
with build_opener(NoRedirect()).open(request, timeout=65) as response:
data = json.load(response)
except HTTPError as error:
try:
detail = json.loads(error.read())
except (ValueError, UnicodeError):
detail = {}
raise RuntimeError(
f"HTTP {error.code}; code={detail.get('code', 'unknown')}; "
f"requestId={error.headers.get('x-request-id', 'unavailable')}"
) from None
except (URLError, TimeoutError):
raise RuntimeError("Network failure; check request history before retrying.") from None
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
raise RuntimeError("Unexpected search response: expected top-level items.")
return data
def collect(max_pages, get_page=fetch_page, checkpoint=None):
if not 1 <= max_pages <= 3:
raise ValueError("This tutorial permits one to three pages.")
pages, seen = [], set()
for page in range(1, max_pages + 1):
data = get_page(page)
pages.append(data)
if checkpoint is not None:
checkpoint(pages)
keys = {str(row["itemId"]) for row in data["items"] if row.get("itemId")}
if not keys - seen:
break
seen.update(keys)
if data.get("hasNextPage") is False:
break
if page < max_pages:
time.sleep(6.1)
return pages
def clean(pages):
candidates, review, seen = [], [], set()
for page in pages:
for item in page["items"]:
item_id = str(item.get("itemId") or "")
quality = item.get("quality") or {}
reasons = []
if not item_id:
reasons.append("missing_item_id")
elif (SITE, item_id) in seen:
reasons.append("duplicate_item")
seen.add((SITE, item_id))
title = item.get("title") or ""
if not MODEL.search(title):
reasons.append("model_not_in_title")
if EXCLUDE.search(title):
reasons.append("accessory_or_parts_phrase")
if item.get("listingType") != "sold":
reasons.append("not_a_sold_listing")
if item.get("soldCurrency") != CURRENCY:
reasons.append("unexpected_currency")
if quality.get("priceBasis") != "displayed_listing_price":
reasons.append("unusable_price_basis")
amount = None
try:
amount = Decimal(str(item.get("soldPrice")))
if not amount.is_finite() or amount < 0:
raise ValueError
except (InvalidOperation, ValueError):
amount = None
reasons.append("missing_or_invalid_price")
row = {
"ebaySite": SITE, "itemId": item_id, "title": title,
"url": item.get("url"), "condition": item.get("condition"),
"displayedPrice": format(amount, "f") if amount is not None else None,
"currency": item.get("soldCurrency"),
"shippingPrice": item.get("shippingPrice"),
"shippingCurrency": item.get("shippingCurrency"),
"totalPrice": item.get("totalPrice"),
"endedAt": item.get("endedAt"),
"scrapedAt": item.get("scrapedAt") or page.get("fetchedAt"),
"priceBasis": quality.get("priceBasis"),
"saleDateVerified": quality.get("saleDateVerified"),
"exactProductMatchVerified": quality.get("exactProductMatchVerified"),
"acceptedOfferAmount": quality.get("acceptedOfferAmount"),
"reviewReason": ";".join(reasons),
}
(review if reasons else candidates).append(row)
return candidates, review
def csv_cell(value):
if value is None:
return ""
text = str(value)
if text.lstrip().startswith(("=", "+", "-", "@")) or text.startswith(("\t", "\r", "\n")):
return "'" + text
return text
def write_csv(path, rows):
with path.open("w", newline="", encoding="utf-8-sig") as handle:
writer = csv.DictWriter(handle, fieldnames=FIELDS)
writer.writeheader()
writer.writerows({key: csv_cell(row.get(key)) for key in FIELDS} for row in rows)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pages", type=int, choices=(1, 2, 3), default=1)
args = parser.parse_args()
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
folder = Path("soldfetch-export-" + stamp)
folder.mkdir()
print(f"Saving results in {folder}")
def checkpoint(pages):
manifest = {"keyword": KEYWORD, "ebaySite": SITE, "count": COUNT, "responses": pages}
(folder / "raw.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
pages = collect(args.pages, checkpoint=checkpoint)
candidates, review = clean(pages)
write_csv(folder / "candidates.csv", candidates)
write_csv(folder / "review.csv", review)
print(f"{len(pages)} successful pages; {len(candidates)} candidates; {len(review)} review rows")
print(f"Files saved in {folder}")
if __name__ == "__main__":
try:
main()
except (ValueError, RuntimeError, OSError) as error:
raise SystemExit(str(error)) from None
Run the search and open the exported files
Run the script from your terminal:
python3 sold_listings_csv.py
It creates a new folder whose name begins with soldfetch-export-, followed by the run time. Each run uses a separate folder so your previous results remain available.
| File | Contents |
|---|---|
raw.json |
Search context and the original responses, including warnings and timestamps. Saved after each successful page. |
candidates.csv |
Rows that pass the example’s model, price, currency, and duplicate checks. |
review.csv |
Rows diverted by a check, with one or more reasons in reviewReason. |
A successful empty search creates CSV files containing headers without data rows. A failed request stops the script. If a later page fails, raw.json retains the successful pages already saved; the completed CSV exports are written after collection finishes.
Open a working copy of the CSV in your spreadsheet. Import item IDs as text to preserve the identifiers. The writer uses UTF-8 with a byte-order mark for common spreadsheet import workflows, quotes fields containing commas or line breaks, and prefixes formula-like cell values with an apostrophe. The raw JSON retains the original values.
Review what the cleaner keeps
The rules are deliberately specific to this headphone example. Before adapting the script to another product, update KEYWORD, MODEL, EXCLUDE, and the expected CURRENCY together.
A candidate is a row to inspect, not a verified comparable sale. A title can contain the model number while describing a charging cable, an incomplete bundle, or another accessory. The phrase list catches some obvious cases and leaves the remaining product judgment to you.
The cleaner uses Decimal to parse price strings. Missing, non-finite, and negative prices go to the review file. It also diverts unexpected currencies and range lower bounds rather than using them as ordinary single prices.
Unknown shipping stays blank. A blank shipping value does not become free shipping, and USD and GBP amounts stay distinct. The source’s unknown accepted-offer amount remains unknown.
Duplicate detection uses the marketplace and item ID. If a listing appears again on a later page, its repeated row goes to review.csv with duplicate_item. That leaves a record of what was removed.
Review the candidates against the original listing links before calculating a median or using the file in a pricing feature. Even exactMatch=true only excludes results explicitly marked as loose matches; it does not establish exact product identity. See SoldFetch’s data-quality guidance for the field semantics.
Collect more pages with a request budget
After reviewing a one-page export, you can permit a second page:
python3 sold_listings_csv.py --pages 2
This tutorial caps a run at three pages. It keeps the query, filters, marketplace, and count=200 fixed while incrementing page. The collector stops at the page budget, an empty page, a page without unseen item IDs, or hasNextPage=false. It does not assume a short page means the search is complete.
Each successful page consumes one request, including an empty response or cache hit. A two-page run therefore uses at most two requests with this script. Failed data requests are uncharged.
The short pause between pages spaces the script’s own requests. Other work using the same workspace can still reach shared rate or concurrency limits.
Sold search results are cached for up to six hours under the current policy; empty searches use a shorter five-minute cache. Retain the source timestamps when comparing runs. A fresh HTTP response can contain the same underlying observation.
Handle errors before running again
The script uses a fixed SoldFetch origin, declines redirects, applies a timeout, and stops on an HTTP or network failure. It does not retry automatically.
| Response | Next action |
|---|---|
| 400 | Check the query and remove invalid or unsupported parameters. |
| 401 or 403 | Check the API key and workspace access. |
429 with rate_limited |
Reduce shared request pressure and respect Retry-After when supplied. |
429 with quota_exceeded |
Check the remaining allowance before making more requests. |
| 502, 503, or 504 | Inspect the error code, request history, and service status before deciding whether to retry. |
An error is a failed fetch, not an empty market. Keep it separate from a successful search that returned no matches.
A network interruption can leave the outcome uncertain. Check your request history before repeating the operation. For an application that needs managed retries, add deliberate idempotency handling rather than retrying every failure in a loop.
Use the CSV in your next workflow
Once you have reviewed the candidate rows, the CSV can support inventory research, a pricing worksheet, or the input to a larger data pipeline. Preserve the listing link, marketplace, currency, condition, observation time, and quality fields as the data moves between tools.
When a selected listing needs more context, use the item lookup guide to enrich it. For repeated observations and a history store, continue with Build an eBay Price Tracking Workflow.
Get your SoldFetch API key, run one sold search, and inspect the raw response beside the candidate and review files before expanding the collection.



