compsapi.com / blog

Price a spreadsheet of items from real sales

A script you can run today, and the three things that go wrong when you price in bulk.

Published 17 October 2026 by CompsAPI

Pricing one item is a lookup. Pricing four hundred is a different job, and the things that bite are rate limits, duplicate rows, items that genuinely have no market, and numbers that look fine but rest on two sales.

Here is a script that handles all four. It reads a CSV with an item column and writes the same rows back with a median, a 25th to 75th percentile band, the number of sales behind each figure, and a confidence label.

The script

import argparse, csv, json, os, sys, time, urllib.error, urllib.parse, urllib.request

API = "https://api.compsapi.com/v1/comps"
MIN_SAMPLE = 8        # below this a median is an anecdote, so we label it

def price(item, key, days, condition=None, timeout=90):
    params = {"q": item, "days": days, "size": 240}
    if condition:
        params["condition"] = condition
    req = urllib.request.Request(
        API + "?" + urllib.parse.urlencode(params),
        headers={"Authorization": "Bearer " + key, "User-Agent": "bulk-pricer/1.0"})
    for attempt in range(4):
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return json.loads(r.read().decode("utf-8")), None
        except urllib.error.HTTPError as e:
            if e.code == 429:                      # quota or burst: back off
                time.sleep(20 * (attempt + 1)); continue
            if e.code == 503 and attempt < 3:      # transient on our side
                time.sleep(4 * (attempt + 1)); continue
            return None, f"http {e.code}"
        except Exception as e:
            if attempt < 3:
                time.sleep(4 * (attempt + 1)); continue
            return None, type(e).__name__
    return None, "retries exhausted"

The loop that uses it caches by item text, paces itself, and labels confidence rather than hiding it:

cache, out = {}, []
for row in rows:
    item = (row.get("item") or "").strip()
    if item in cache:                          # same text twice costs nothing
        stats, err = cache[item]
    else:
        data, err = price(item, key, days, condition)
        stats = (data or {}).get("stats") or {}
        cache[item] = (stats, err)
        time.sleep(60.0 / rpm)
    count = stats.get("count") or 0
    row.update({
        "median": stats.get("median") or "",
        "p25": stats.get("p25") or "",
        "p75": stats.get("p75") or "",
        "sales": count,
        "confidence": "none" if not count else ("low" if count < MIN_SAMPLE else "ok"),
        "error": err or "",
    })
    out.append(row)

What it produces

Run against a four row file:

item,sku,median,p25,p75,sales,confidence,error
nintendo switch oled console,A1,120.0,15.96,185.0,189,ok,
fluke 87v multimeter,A2,229.0,160.78,299.99,160,ok,
hp 15c calculator,A3,65.0,33.9,108.09,131,ok,
nonexistent widget zzz,A4,,,,0,none,

Four requests, real output, run on 17 October 2026.

Read that first row again

The Switch OLED comes back with a median of $120.00 and a 25th percentile of $15.96. A console does not sell for sixteen dollars. That band is telling you the search matched cases, docks, screen protectors and joy con straps alongside consoles.

The median survived it because the console listings outnumber the accessories. On a thinner item it would not have, and you would have shipped a price for a dock.

This is the single most useful habit in bulk pricing: treat a wide band as a matching problem, not as market volatility. If p25 is a small fraction of the median, your query is matching more than one kind of thing. Narrow it with condition, a category, or words that accessories do not carry, and the band tightens. We wrote up the extreme version of this failure on trading cards, where it moves a median by 37x.

Three things that go wrong at volume

1. Rate limits. Paid plans allow 120 requests a minute and the free tier allows 10. The script paces from a rpm setting rather than sleeping a fixed amount, and treats a 429 as a signal to back off rather than retry immediately. Hammering a 429 turns a short pause into a long one.

2. Duplicates. Real inventory files repeat the same item text constantly. Caching on the exact string is a one line change that routinely cuts request count by a third on a messy file, and it costs nothing. Results are also cached our side for six hours, so a rerun of the same file is cheap even without it.

3. Silent failures. The script separates three states that look the same if you only read the median: no sales found, a result too thin to trust, and a request that errored. Collapsing those into a blank cell is how a pricing run quietly puts zeroes into a catalogue.

What to do with the thin rows

Do not loosen the query until a number appears. That is the reflex and it produces a comp for a different item.

Widen the window first, keeping every filter the same: --days 1095 on a paid key reaches three full years, which is what rare items need. Anything still thin after that genuinely sells rarely, and the right output for those rows is a range and a flag, not a price. More on that here.

Cost

One request per unique item per run. A 500 item file with some repetition is roughly 350 requests, so it fits inside the $9 plan's 5,000 a month with room to rerun it weekly. The free tier is 100 a month with no card, which is enough to try this on a sample before committing anything.

Full parameter reference, and the same data is available as spreadsheet formulas if you would rather not run a script at all.

Price from what things
actually sold for

100 free requests a month · every filter on · no card

Get a free key