Price a Whole Inventory With an AI Agent: Queries, Confidence, Cost and Retries

Have the agent turn each item into a specific eBay-style query, then run a batch script against GET /v1/sold with a fixed limit. Each call costs 20 credits plus 1 per sale returned, so limit=20 costs at most 40 and stats still use every match. Write back the median, p25, p75 and counted sales; flag rows with under 10 sales or a wide middle range for review. Cache results, retry 429 and 502 with backoff, and stop on 402 or 503 daily_capacity_reached.
A Python script calling the SoldComps API for a search and printing the median sold price

A store buyout, a storage unit, a consignment intake, a catalog that needs re-pricing every week: at some point "what's this worth?" becomes 500 rows. An AI agent is good at the part people find tedious (turning messy item descriptions into precise searches, then reviewing the odd ones) and bad at the part a script does better (calling an API 500 times with fixed settings).

So split it. The agent writes queries and reviews flags. A short Python script does the lookups. This guide covers both halves, plus the cost math, caching and retries. It builds on Automate sold comps, which has a simpler script and real example output.

The workflow

  1. Normalize. The agent reads the inventory and writes one eBay-style query per row, plus a condition when known.
  2. Estimate. Count the rows, pick a limit, check your balance with GET /v1/usage (free).
  3. Run. The script calls GET /v1/sold once per row, caches each result, and writes the median, middle range, counted sales and a review flag.
  4. Review. The agent (or a person) looks only at the flagged rows: sharpens the query and re-runs, or marks them for manual pricing.

You can drive all four steps from Claude Code, Cursor or any coding agent: give it the CSV, this guide and the agent playbook.

Step 1: write good queries

The query decides the price. A vague query mixes different products, and the median comes out wrong with high confidence. Ask the agent to write each query like an eBay search, using what changes the price:

Inventory row saysBetter queryWhy
"Old Canon camera, works"canon ae-1 program 50mmModel and lens, not "camera"
"Switch, used"nintendo switch oled + condition=usedOLED, Lite and original sell differently
"Charizard card graded 9"charizard 4/102 "psa 9" -lotCard number, quoted grade, no bundles
"iPhone 13"iphone 13 128gb unlockedStorage and carrier move the price
"Levi's jeans"levis 501 made in usa 32x30Model, origin and size

Rules worth putting in the agent's instructions:

  • One item per query. Bundles go in as separate rows.
  • Include model or reference numbers, storage, size, edition, set number and grade.
  • Quote exact phrases ("psa 10") and exclude junk with a minus (-lot, -broken, -box).
  • Set condition when the item's condition is known: new, open_box, refurbished, used or for_parts.
  • Don't invent details. If the row doesn't say the storage size, leave it out and let the review step catch a wide range.

Matching is strict on numbers: every number in your query must appear in the listing title as a whole number. A wrong model or card number finds nothing, rather than something close.

Step 2: pick limit deliberately

Every successful call costs 20 credits plus 1 per sale returned. limit caps how many sales are returned, so it sets the price. The stats (median, p25, p75, min, max) are computed from every matching sale, up to 200, whatever limit is.

limitMax credits per item500 itemsUse when
12110,500You only need the stats
103015,000Stats plus a few sales to spot-check
204020,000Stats plus enough sales to review flagged rows
50 (default)7035,000You store recent sales per item
200220110,000You need every sale the stats used

These are maximums: a search that finds fewer sales than limit costs less, and a search with no sales costs 20. Failed calls and /v1/usage are free.

For scale: the free plan's 2,000 credits a month covers 50 items at limit=20. Starter (50,000 credits) covers 500 items at limit=20 with room to re-run. Plans and prepaid credits are on the pricing page.

Step 3: the script

Plain Python with requests, no LLM SDK. Your CSV, inventory.csv:

sku,query,condition,site
A101,canon ae-1 program 50mm,used,EBAY_US
A102,nintendo switch oled,used,EBAY_US
A103,"charizard 4/102 ""psa 9"" -lot",,EBAY_US
A104,levis 501 made in usa 32x30,used,EBAY_US

condition and site are optional; empty means any condition on eBay US.

import csv
import json
import os
import sys
import time

import requests

API = "https://www.soldcomps.io/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SOLDCOMPS_API_KEY']}"}
LIMIT = 20          # sales returned per call: each call costs 20 + up to LIMIT credits
CACHE_FILE = "comps_cache.json"
CACHE_DAYS = 7      # re-use a result for this long before paying for a fresh one
MIN_SALES = 10      # fewer counted sales than this: flag for review
WIDE = 1.5          # p75 more than this many times p25: flag for review


class Stop(Exception):
    """An error that retrying won't fix (out of credits, daily capacity reached)."""


def error_of(r):
    try:
        return r.json().get("error", {})
    except ValueError:
        return {}


def fetch(q, condition, site):
    params = {"q": q, "site": site, "limit": LIMIT}
    if condition:
        params["condition"] = condition
    for attempt in range(5):
        r = requests.get(f"{API}/sold", params=params, headers=HEADERS, timeout=60)
        if r.ok:
            return r.json()
        err = error_of(r)
        code = err.get("code")
        if r.status_code == 402:
            raise Stop(f"out of credits: needs {err.get('credits_needed')}, has {err.get('credits_remaining')}")
        if code == "daily_capacity_reached":
            raise Stop("free-tier daily capacity reached; re-run after midnight UTC (cached rows are kept)")
        if r.status_code == 429 or r.status_code >= 500:
            # rate limited or upstream hiccup: never billed, safe to retry
            wait = int(r.headers.get("Retry-After", "5")) if r.status_code == 429 else 5 * 2 ** attempt
            time.sleep(wait)
            continue
        return {"error": code or f"http_{r.status_code}", "param": err.get("param")}  # 400/401: fix and re-run
    return {"error": "gave_up_after_retries"}


def review_flag(d):
    if "error" in d:
        return f"error: {d['error']}" + (f" ({d['param']})" if d.get("param") else "")
    s = d["stats"]
    counted = d["count"] - s["excluded"]
    if s["median"] is None:
        return "no sales"
    if counted < MIN_SALES:
        return "few sales"
    if s["p25"] and s["p75"] and s["p75"] > WIDE * s["p25"]:
        return "wide range"
    return ""


def main(src="inventory.csv", dst="priced.csv"):
    try:
        with open(CACHE_FILE) as f:
            cache = json.load(f)
    except FileNotFoundError:
        cache = {}

    with open(src, newline="") as f:
        rows = list(csv.DictReader(f))

    u = requests.get(f"{API}/usage", headers=HEADERS, timeout=30)  # free
    u.raise_for_status()
    usage = u.json()
    worst = len(rows) * (20 + LIMIT)
    print(f"{len(rows)} rows, at most {worst} credits before cache hits; {usage['total_remaining']} remaining")

    spent = 0
    try:
        with open(dst, "w", newline="") as out:
            w = csv.writer(out)
            w.writerow(["sku", "query", "condition", "site", "counted", "p25", "median", "p75", "currency", "review"])
            for row in rows:
                q, cond = row["query"].strip(), (row.get("condition") or "").strip()
                site = (row.get("site") or "").strip() or "EBAY_US"
                key = f"{site}|{cond}|{q.lower()}"
                hit = cache.get(key)
                if hit and time.time() - hit["at"] < CACHE_DAYS * 86400:
                    d = hit["data"]
                else:
                    d = fetch(q, cond, site)
                    if "error" not in d:
                        cache[key] = {"at": time.time(), "data": d}
                        spent += d["credits"]["charged"]
                flag = review_flag(d)
                s = d.get("stats", {})
                counted = d["count"] - s["excluded"] if "error" not in d else ""
                w.writerow([row["sku"], q, cond, site, counted, s.get("p25"), s.get("median"), s.get("p75"), s.get("currency"), flag])
                print(row["sku"], s.get("median"), flag or "ok")
    except Stop as e:
        print("stopped:", e, file=sys.stderr)
    finally:
        with open(CACHE_FILE, "w") as f:
            json.dump(cache, f)
        print(f"credits spent this run: {spent}")


if __name__ == "__main__":
    main(*sys.argv[1:])

Run it:

pip install requests
export SOLDCOMPS_API_KEY=sh_live_...
python price_inventory.py inventory.csv priced.csv

priced.csv has one row per item. The values below are placeholders showing the shape, not real prices:

skuqueryconditionsitecountedp25medianp75currencyreview
A101canon ae-1 program 50mmusedEBAY_US<n><p25><median><p75>USD
A104levis 501 made in usa 32x30usedEBAY_US<n><p25><median><p75>USDwide range

For real numbers on a similar CSV, see Automate sold comps.

Step 4: review the flags

The script sorts the inventory into "priced" and "look at this". Each flag has a usual fix:

  • few sales (under 10 counted): the median is a rough guide, not a price. Broaden the query (drop a word, remove a quote) or price it by hand. How many sold comps you need explains the cutoff.
  • wide range (p75 more than about 1.5x p25): the search probably mixes versions, sizes or conditions. Add the detail that separates them, or set condition. Tighten the threshold for electronics, loosen it for vintage.
  • no sales: nothing clean matched in 90 days. Check the query for a wrong number, then broaden it once.
  • error: invalid_param (q): the parameter in brackets was rejected, usually an empty or too-long query (2 to 200 characters).

An agent is good at this step. Give it priced.csv and the agent playbook, have it rewrite only the flagged queries, and re-run. Unchanged rows come from the cache and cost nothing.

Caching

The API bills every successful search, including repeats. Your own cache is what saves credits:

  • Key by site, condition and query, as the script does.
  • Pick a freshness window by category. Cards, GPUs and new consoles move week to week; kitchenware and vintage household goods barely move in a month.
  • Keep the dated results. After a few runs you have your own price history per item.

The script also keeps the cache when it stops early, so a re-run picks up where it left off.

Retries and errors

Errors are never billed. The script handles them like this:

Statuserror.codeScript does
429rate_limitedWaits for Retry-After, retries
502upstream_unavailableBacks off (5, 10, 20, 40 seconds), retries
503daily_capacity_reachedStops: free-tier capacity for new searches is used up until midnight UTC. Paid plans and credits aren't affected
402quota_exceededStops and prints credits_needed and credits_remaining
400invalid_paramRecords the error on that row and moves on

Rate limits are per key: 30 a minute on Free, up to 300 on Scale. A sequential script rarely hits them; if you parallelize, keep the 429 handling. Full details are in the API docs.

Frequently asked questions

Can an AI agent price a whole inventory from eBay sold data?

Yes. The agent's job is writing a specific eBay-style query for each item and reviewing the rows a script flags. A batch script calls GET /v1/sold once per item and writes back the median, middle range and counted sales.

How many credits does it take to price 500 items?

Each call costs 20 credits plus 1 per sale returned. At limit=20 that's at most 40 credits per item, so at most 20,000 credits for 500 items, which fits the Starter plan's 50,000. Cached rows and failed calls cost nothing.

Which rows should a person review?

Rows with fewer than 10 counted sales, rows where the 75th percentile is more than about 1.5 times the 25th, rows with no sales at all, and anything high-value or easy to fake. The script flags the first three automatically.

Should I retry when the API returns an error?

Retry 429 after the Retry-After header and 502 with backoff. Don't retry 402 quota_exceeded (out of credits) or 503 daily_capacity_reached (free-tier capacity is used up until midnight UTC). Errors are never billed.

Why use the API instead of the MCP tool for a big list?

The MCP tool is built for conversational lookups and lists up to 20 sales. For a spreadsheet you want a script: a fixed limit for predictable cost, filters for price range and buying format, your own cache, and results written straight to a file.