DEV Community

Sam Smith
Sam Smith

Posted on Originally published at compsapi.com

Your eBay sold-price data is 14.9% too high and the response looks fine

If your code reads eBay sold prices, it has a bug you cannot see. Not a parsing bug. The number comes back well formed, plausible, in the right currency, and wrong.

I run CompsAPI, so I hold both the asking price and the accepted price on the same sale. That let me measure the gap instead of guessing at it. Here is what came out, and why no amount of better scraping fixes it.

The failure mode

When an eBay listing sells through an accepted Best Offer, eBay's public sold page shows the asking price. The amount the buyer actually paid is not on that page. There is no field holding it, no query parameter that reveals it, and no header that hints the row is affected.

So the sale closed at $15,500, the page says $24,995, and your parser returns $24,995 with no error. Every tool reading that page returns the same wrong number, which is worse than disagreement, because when two sources agree you stop checking.

What I measured

Over the 90 days to 10 October 2026: 5,859 completed Best Offer sales across 5,840 distinct listings, from 93 searches in 12 categories. Every pair is matched on listing id, so the asking price and the accepted price belong to the same item on the same sale. No row was discarded.

  • 99.0% read high on the public sold page. 0.7% match within half a percent, 0.4% read low.
  • Median overstatement 14.9%. A typical Best Offer sale shows about a seventh above what it closed at.
  • p90 40.8%. One sale in ten is overstated by more than two fifths.
  • Money weighted 14.2%, across every dollar that changed hands, so this is not an artifact of cheap items.
  • Median accepted price $240, against a median asking price of $284.

How the tail behaves, which matters more than the median if you are setting a ceiling:

Overstated by more than Share of accepted Best Offer sales
5% 89.2%
10% 68.9%
25% 23.4%
50% 5.9%
100% 0.8%

By category

Every one of the 12 categories sits above 11% money weighted. The ones where sellers list high and expect to negotiate are the worst.

Category Sales matched Read high Median gap p90 Money weighted
Auto parts 228 96.9% 12.2% 51.3% 23.9%
Sneakers 857 99.9% 20.0% 50.0% 22.0%
Graded trading cards 390 99.7% 15.7% 40.0% 17.3%
Jewellery 652 99.1% 17.6% 55.6% 16.8%
Designer handbags 723 98.9% 17.5% 46.7% 15.8%
Video games and consoles 446 99.8% 14.7% 36.4% 15.7%
Phones and tablets 300 99.7% 10.0% 35.9% 15.3%
Golf clubs 599 99.3% 13.7% 33.3% 14.5%
Musical instruments 365 98.4% 13.2% 33.3% 14.4%
Coins and bullion 362 98.1% 11.1% 30.0% 13.2%
Luxury watches 655 98.0% 11.2% 31.2% 12.1%
Cameras and lenses 282 97.9% 11.1% 25.0% 11.5%

Why a better scraper does not help

A Best Offer is a private negotiation. eBay publishes that the item sold and what it was listed at, and keeps the accepted amount off the public record. This is not a rendering quirk or a lazy loaded element. The number is not in the page, so there is nothing to select.

That is the part worth internalising if you maintain a pricing pipeline: this is not a coverage problem you can close with more requests. It is a field that does not exist on the surface you are reading.

Why it does not average out

The instinct with noisy data is that errors cancel. These do not. A Best Offer is almost never accepted above the asking price, so the error is one sided and every affected comp pushes your estimate the same direction.

# The quiet version of this bug in a repricing loop.
comps = fetch_sold_comps(query)          # some read the public sold page
target = median(c["price"] for c in comps)
list_price = target * 0.97               # "just under market"
Enter fullscreen mode Exit fullscreen mode

If a third of those comps were accepted offers reading a median 14.9% high, target is not the market, it is above it, and 0.97 does not save you. You sit high, the item does not move, and nothing in the data looks wrong. Four places it bites:

  • Repricing. You list against a level nobody paid.
  • Buying. You pay to a ceiling that was never real, and the margin you modelled was not there.
  • Valuation and insurance. Values come out high in exactly the categories people insure: graded cards 17.3% and jewellery 16.8% money weighted.
  • Anything trained on the data. A model fed asking prices learns a price level that never cleared.

Getting the accepted figure

price is what the sale closed at, and best_offer flags which rows were negotiated, so you can measure your own category rather than taking my median for it:

curl -G https://api.compsapi.com/v1/sold \
  -H "Authorization: Bearer YOUR_KEY" \
  --data-urlencode "q=charizard psa 10" \
  --data-urlencode "days=1095" \
  --data-urlencode "best_offer=1"
Enter fullscreen mode Exit fullscreen mode

best_offer=only returns negotiated sales alone, which is the query that produced the tables above. In Python, the gap on your own data is about six lines:

import requests, statistics

r = requests.get("https://api.compsapi.com/v1/sold",
                 headers={"Authorization": "Bearer YOUR_KEY"},
                 params={"q": "charizard psa 10", "days": 1095, "best_offer": "only"})
sales = r.json()["items"]
print(len(sales), "negotiated sales")
print("median accepted", statistics.median(s["price"] for s in sales))
Enter fullscreen mode Exit fullscreen mode

There is also an opt in best_offer=verify mode that returns a best_offer_confidence of confirmed, likely, unlikely or unknown per row, plus the asking_price and asking_gap_pct it reasoned from, so you can set your own threshold instead of inheriting mine. It costs extra upstream reads, so it is off by default.

Free key is 100 requests a month, no card. That is enough to reproduce this on one category.

What this does not say

  • It is 93 searches in 12 categories over 90 days, not a census of eBay. Other categories will differ.
  • It measures accepted Best Offer sales only. It says nothing about what share of all eBay sales go through an offer, which varies enormously by category. The 14.9% applies to affected rows, not to your whole dataset.
  • Three rows of 5,859 have an accepted price under $5 and are almost certainly an opening bid or a bad parse. Dropping every row under $10 moves the median from 14.9% to 14.8% and the money weighted figure from 14.2% to 14.0%, so nothing rests on them.
  • 0.4% of pairs read low. I report that rather than filtering it out.
  • Where one listing sold several units in the window, the accepted figure is the average of that listing's accepted offers. In these categories almost every listing is a single item.

Full tables and method: compsapi.com/best-offer-gap.

If you maintain something that prices off eBay comps, the useful takeaway is not my number. It is that the error exists, it is one sided, and it is invisible in the response, so it is worth measuring on your own categories rather than assuming it is small.

Top comments (0)