DEV Community

Sam Smith
Sam Smith

Posted on Originally published at compsapi.com

Your card pricing API is returning the median of a junk drawer

Here is a measurement I ran on 17 October 2026 against my own API, which does not flatter it.

Search three years of completed eBay sales for charizard base set psa 10. Take the raw feed of sold listings, keep only the rows that sold a single unit, take the median:

$14.73, over 239 listings, with individual sales running from $1.30 to $30,000.

Run the same words through an endpoint that drops lots, mystery packs, accessories, replicas and keyword stuffed titles before it calculates anything:

$545.00, over 60 matching sales, middle half between $236.77 and $2,861.08.

Same words. Same window. Same marketplace. A 37x difference in the answer.

If you are building anything that prices collectibles, this is the whole problem in one example, and it is not specific to my data. Any tool reading the same eBay search sees the same listings.

Why the first number happens

eBay matches on words in the title, and it matches generously. A search for charizard base set psa 10 returns every listing whose title holds those words in any arrangement:

  • a card sleeve titled "fits PSA 10 Charizard Base Set"
  • a bulk lot of commons advertised as "Charizard Base Set PSA 10 chase"
  • a raw card described as a "PSA 10 contender"
  • a custom fan art proxy

None of those is the card. All of them are cheap, so they drag the middle of the distribution to the floor.

The difference between pricing tools is entirely in what they throw away after retrieval. Most throw away nothing and show you a median anyway.

The trap that looks like the fix

The obvious response is to put more words in the query. Add the card number:

q=charizard base set psa 10 4/102   ->  median $555.50
Enter fullscreen mode Exit fullscreen mode

Looks like it worked. It did not work, it moved. The range is still $4.00 to $35,000, which tells you the set is still full of things that are not the card.

Two things go wrong when you push identity into the query string:

  1. More words makes eBay widen, not narrow. You get a different population of wrong listings rather than a clean population of right ones.
  2. Card numbers are reused across sets. 4/102 is Base Set Charizard and it is also a later anniversary reprint. Pinning the number without pinning the set pins nothing.

What actually works: filter on structure, not words

Parse the title into fields, then filter the fields. Grading company, grade, graded status and a junk classifier do almost all of the work:

curl -G https://api.compsapi.com/v1/comps \
  -H "Authorization: Bearer YOUR_KEY" \
  --data-urlencode "q=charizard base set" \
  --data-urlencode "graded=true" \
  --data-urlencode "grader=PSA" \
  --data-urlencode "grade=10" \
  --data-urlencode "days=1095"
Enter fullscreen mode Exit fullscreen mode
{
  "stats": {
    "count": 60,
    "used_for_stats": 57,
    "median": 545.0,
    "p25": 236.77,
    "p75": 2861.08,
    "outliers_removed": 3
  }
}
Enter fullscreen mode Exit fullscreen mode

The classifier flags that matter in practice, each one a listing class that will quietly wreck a median:

Flag What it catches
lot More than one card sold together
mystery Repacks and "chance to win a PSA 10" listings
accessory Cases, sleeves, binders, display stands
replica Customs, proxies, fan art
keyword_stuffing Several graders named with no grade, to catch searches
not_graded_claim Raw cards described as a "PSA 10 contender"

To pin a specific card inside a set, filter after retrieval rather than before it. A post-search filter can only ever remove rows, so the effect is auditable. Text in the query changes which rows come back at all, so it is not.

Three rules if you are building this

Never ship a median without its sample size. A median of three sales is an anecdote. Return count next to it and let the caller decide.

Return the spread. In the cleaned set above the middle half ran $236.77 to $2,861.08. That is not noise to average away, it is the market. A nominally identical slab does not fetch the same price in a quiet week as it does with good photos and a seller with feedback. p25 and p75 are cheap to compute and they stop your users trusting a point estimate they should not.

Treat an empty window as missing data, not a low price. eBay's public sold filter reaches back about 90 days. For anything scarce that window is often empty, and the reflex is to loosen the query until something appears. What appears is a different item.

The part nobody can see at all

One more thing that silently biases every price model built on eBay sold data: when a sale completes as an accepted Best Offer, the public sold page keeps showing the asking price, not what the buyer paid.

I matched 5,859 accepted offer sales against what the public page displayed for the same listing. 99.0% read high, by a median of 14.9%, with the top tenth over 40%. Full numbers and method: compsapi.com/best-offer-gap.

That error is one directional. It never cancels out, and the data itself will never show it to you.

Reproduce it

The free tier is 100 requests a month, no card, every filter on. The numbers above reproduce: compsapi.com/docs.

And if you only take one thing from this: ask any pricing tool how many sales its number rests on, over what window, and what it discarded. If it cannot answer all three, it is showing you the median of a junk drawer.

Top comments (0)