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](https://docs.soldfetch.com/api-reference/search) 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](https://soldfetch.com/dashboard), then set it in the terminal where you will run the script:

```bash
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](https://soldfetch.com/v1/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](/downloads/blog/ebay-sold-listings-python.py) 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.

```python
"""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:

```bash
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](https://docs.soldfetch.com/concepts/data-quality) for the field semantics.

## Collect more pages with a request budget

After reviewing a one-page export, you can permit a second page:

```bash
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](https://soldfetch.com/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](https://soldfetch.com/blog/ebay-item-api-guide) to enrich it. For repeated observations and a history store, continue with [Build an eBay Price Tracking Workflow](https://soldfetch.com/blog/ebay-price-tracking-api-guide).

[Get your SoldFetch API key](https://soldfetch.com/dashboard), run one sold search, and inspect the raw response beside the candidate and review files before expanding the collection.