DEV Community

Cover image for eBay sold prices without the paid API: I built my own sold-comps engine and measured it
Christian Anderson
Christian Anderson

Posted on Edited on

eBay sold prices without the paid API: I built my own sold-comps engine and measured it

The paid meter was running out

I had a paid eBay sold-comparables service. 250 requests a month. Some products
need more than two hundred listings before you get a usable price cluster, so
one wrong query burns a week's ration. I started wondering: do I actually still
need it, or could I grow my own?

That question is empirical. The repo that came out of it is
sold-shadow.

The premise (and the thing that made it possible)

eBay's completed-listing search has been dead to automation for years. Free
alternatives show asking prices, which are opinions. Paid services resell
closed-access sold data, and they're metered.

But an ended listing does not disappear from the Browse API. A GET
/item/{itemId}
on a listing that finished last week returns HTTP 200 with the
itemEndDate field set, and the payload tells you whether anything actually
sold. The signal is straightforward:

OUT_OF_STOCK, estimatedRemainingQuantity 0, estimatedSoldQuantity >= 1  -> SOLD
IN_STOCK,     estimatedRemainingQuantity 1, estimatedSoldQuantity 0     -> ended unsold
Enter fullscreen mode Exit fullscreen mode

Tested on thirty real listings re-fetched 6-8 days after first capture: 30/30
returned 200, two had ended, one of those had sold. That proved the data doesn't
evaporate.

This uses a client-credentials token — the Browse API application-level flow.
No user consent, no scraping, nothing that touches the API License Agreement
edge. The same credentials that run publishing and orders.

How it works

You tell it what to track. Not categories, not everything — the API quota is
finite, and a corpus of products you never sell is worth zero. Each track
command stores one search phrase.

A daily sweep re-fetches every open listing, checks whether it ended, and
records the outcome if it did. Self-capped at 600 calls per day against a 5,000
Browse allowance. When a sale arrives, the listing becomes a permanent record:
item ID, title, sold price or best-offer ask, the query that found it, and a
flag for whether the listing accepted offers.

You can also feed it your paid service's API key and run compare, which
fetches the same queries from the paid source. It caches responses for 24 hours
so only the first run each day costs real quota, and it shows you exactly how
far your own corpus is from matching the one you pay for.

What the numbers said after a week

After about seven days of daily sweeps on one install (measured evening of
2026-09-12):

  • 3,190 listings tracked across 54 queries
  • 14 confirmed sold
  • 9 of those were best-offer listings (upper bounds, not real prices)
  • 5 genuinely usable sold comparables
  • 5 queries out of 54 had at least one usable comp — 9% coverage

The switch-off bar I wrote into shadow.py is 80% coverage within 15% of the
paid source's median. The honest verdict: not close. One query had a real
cluster (four Casio FX-CG50 sales in four days). The rest were n=1.

That is not a soft disclaimer. A corpus is either mature or it is nothing, and
you cannot know which it is without measuring it against truth. The compare
harness was built for that purpose, and putting it in the same repo forces the
honest conversation: can I stop paying yet? Most of the time, right now, no.

The things that bite (four of them)

No backfill, ever. The search endpoint returns active listings only. Any
sale that completed before your first sweep is invisible. The highest-value
action you can take with this repo is start it today and walk away for a month.
Time is the only thing that grows a corpus.

Best-offer sales are ceilings, not prices. eBay never exposes the accepted
offer on a best-offer listing. The ended item still shows the ask. On the data
above, 9 of 14 sales were flagged as best-offer listings — excluding them loses
64% of your comps, including them prices against a number nobody paid. They are
stored with a flag, excluded from sold_comps() by default, and returned
separately by best_offer_comps() so the caller can decide.

The flag itself is permissive: it is set when BEST_OFFER appears in
buyingOptions, which means the listing accepted offers, not that this
particular sale went through one
. On eBay UK most used fixed-price listings are
best-offer. One product (the same Casio FX-CG50) had a clean sale at £89.99 and
three best-offer asks at £80.00, £83.90 and £88.99 — the discarded asks sat
beneath the clean sale. Excluding them was not the conservative choice it
looked like. compare therefore counts them in a separate bound column.

Auctions lie about their end date. itemEndDate is set at listing creation
for auctions, not when they finish. The assumption that "live listings have a
null end date" is true for fixed-price only. An earlier version of this code
marked every auction as ended-unsold while it was still taking bids, and never
revisited the row. Auctions could never contribute a comp. Since auctions are
the cheap end of a used market, the corpus was biased upward by construction.
The fix: a future end date now leaves the row open.

One search string finds one slice. eBay UK holds roughly twenty active
listings of any mid-volume used product at once, and no single query sees all
of them. The same case is listed as "Argon ONE M.2", "Raspberry Pi Argon One
With" and "Argon One V2 aluminium". On the same install, adding alternative
phrasings for the same product changed the count:

  • Apple Magic Mouse: 210 → 388
  • Casio FX-CG50: 18 → 104
  • Xiaomi TV Box S: 36 → 81
  • Raspberry Pi Argon ONE: 23 → 23 (no more to find — phrasings only help if the volume exists)

That was +640 listings for +100 API calls in a single sweep. Two rules from that
run: keep a phrasing to eight words or fewer (a long string ANDs every word and
returns less), and cap it at three per product so a sweep of thirty products
stays under a hundred searches.

What the GitHub repo gives you

Python 3.11+, stdlib only — no dependencies. Three commands: track to
enrol a search phrase, sweep as a daily cron job, comps to see what has
matured. A compare harness that talks to a paid service's API so you can
measure the gap. An MIT licence.

The repo is
https://github.com/casareanderson/sold-shadow.

The takeaway

This is not a story about replacing a paid tool. It is a story about building a
thing, being forced to measure it honestly, and discovering that the hard
constraint is not the API quota — it is calendar time. You cannot backfill a
used-goods market from the Browse API. You can only watch and wait.

The sold-shadow code works. The corpus, after a week, does not. Both of those
statements are visible in the same output, and that is the point.

Start the sweeps today. Check back in a month.


🤖 Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.

Top comments (0)