Integration recipes

Working code for the apps that use SoldStack most: a donation-value app, a crosslister, a home-inventory app, an AI agent and a spreadsheet. Each has the exact request, the fields to read, caching, errors and what it costs.

Get a free key Read the docs

100 lookups a month free, no card. Paid plans from $9/month.

Before you start

Each recipe is one kind of app: what to call, the exact request, the fields to read, and code you can paste and run. They all start the same way:

  1. Get a key on the home page (free, 100 lookups a month, no card) and put it in an environment variable, SOLDSTACK_KEY, on your server. Don't ship it in a browser or mobile app, where anyone can read it: call SoldStack from your backend.
  2. Use the helper. Every sample starts with a small soldstack() function that sends the key, waits out a busy 503 for the seconds in Retry-After, and raises with SoldStack's own message on a 429. The Python samples need pip install requests; the JavaScript ones need Node 18 or newer and nothing else.
Repeats are free
/v1/sold and /v1/verdict cache each search for 6 hours. Asking for the same search again in that time doesn't count, and neither does changing its filters (size, condition, days…). So cache by query: key your own cache on the source and q, not on the filters. usage.charged and the X-Lookup-Charged header say whether a call counted.
Except lists
POST /v1/value is one lookup per item every time, cached or not. Save its answer with the record it belongs to and show that again instead of calling again.
429
Out of lookups this month, or more than 120 requests a minute. detail says which, in a sentence you can show your user as is. Don't retry in a loop.
503
Busy: fresh searches are queued ahead of yours. Wait the seconds in Retry-After and send the same request again. If it already waited its turn, the retry comes back from cache and isn't counted twice. Sending one request at a time avoids it.

1. Donation-value app

A donor lists what they gave; your app shows a fair-market value for each item, with the sales behind it and a dated paragraph they can keep with their tax records. Send the whole list in one POST /v1/value (up to 100 items; split longer lists), with each item's own condition grade and days: 365 so values come from the last 12 months.

Request

HTTP
POST https://soldstack.dev/v1/value
X-API-Key: ss_your_key
Content-Type: application/json

{
  "days": 365,
  "items": [
    {
      "description": "womens jeans",
      "condition": "used_like_new"
    },
    {
      "description": "womens jeans",
      "condition": "used_good"
    },
    {
      "description": "womens handbag",
      "brand": "coach",
      "condition": "used_good"
    },
    {
      "description": "kids snow pants",
      "condition": "used_good",
      "quantity": 2
    }
  ]
}

Response (trimmed)

200 OK · application/json
{
  "currency": "USD",
  "sold_after": "2025-10-22",
  "items": [
    {
      "description": "womens jeans",
      "condition": "used_like_new",
      "condition_matched": "used_like_new",
      "quantity": 1,
      "value": 28.0,
      "total": 28.0,
      "sales_used": 212,
      "matched_on": "category + department",
      "broadened": false,
      "confidence": "medium",
      "reason": "Median of 212 women's jeans sales in like new condition sold since 2025-10-22; the brand isn't known to this value.",
      "by_condition": [
        {"condition": "used_like_new", "label": "Like new", "count": 212, "median": 28.0, "p10": 15.0, "p90": 52.0},
        {"condition": "used_good", "label": "Good", "count": 174, "median": 22.0, "p10": 12.0, "p90": 40.0}
      ]
    },
    {
      "description": "kids snow pants",
      "condition": "used_good",
      "condition_matched": "used",
      "quantity": 2,
      "value": 20.0,
      "total": 40.0,
      "sales_used": 31,
      "matched_on": "item description",
      "broadened": true,
      "confidence": "medium",
      "reason": "Only 6 of these sales are in good condition, fewer than 10, so every used grade is used. 31 sales of 'kids snow pants' in used condition sold since 2025-10-22 matched after cleaning."
    }
  ],
  "items_valued": 4,
  "items_without_value": 0,
  "total": 229.0,
  "appraisal_may_be_needed": false,
  "method": "Values were computed by SoldStack (soldstack.dev) on 2026-10-22 from completed sales on Poshmark (US). Each value is the median (middle) sold price of comparable items in the same condition group ...",
  "disclaimer": "This is resale market data, not tax or legal advice. For US donors, the IRS requires a qualified appraisal (Form 8283, Section B) when a single item, or a group of similar items, is claimed at over $5,000.",
  "usage": {"used": 4, "limit": 15000, "remaining": 14996, "plan": "growth", "charged": true}
}

Python

donation.py
import os, time
import requests

API = "https://soldstack.dev"
KEY = os.environ["SOLDSTACK_KEY"]  # keep the key on your server, never in a browser or app


def soldstack(method, path, **kw):
    """Call SoldStack. Waits out a busy 503; raises with SoldStack's own message on a 429 or any other error."""
    for _ in range(3):
        r = requests.request(method, API + path, headers={"X-API-Key": KEY}, timeout=60, **kw)
        if r.status_code == 503 and r.headers.get("Retry-After"):
            time.sleep(int(r.headers["Retry-After"]))  # busy: wait the seconds it says, then retry
            continue
        if r.status_code == 429:  # out of lookups this month, or over 120 requests a minute
            raise RuntimeError(r.json()["detail"])
        if not r.ok:
            raise RuntimeError(f"SoldStack {r.status_code}: {r.text[:200]}")
        return r.json()
    raise RuntimeError("SoldStack is still busy. Try again in a minute.")


donation = {"days": 365, "items": [  # the donor's list; each item has its own condition grade
    {"description": "womens jeans", "condition": "used_like_new"},
    {"description": "womens jeans", "condition": "used_good"},
    {"description": "womens handbag", "brand": "coach", "condition": "used_good"},
    {"description": "kids snow pants", "condition": "used_good", "quantity": 2},
]}
result = soldstack("POST", "/v1/value", json=donation)  # up to 100 items per call

for item in result["items"]:
    if item["value"] is None:  # nothing to go on: never guess, show why
        print(f"{item['description']}: no value. {item['reason']}")
        continue
    print(f"{item['description']} x{item['quantity']}: ${item['total']:.2f} "
          f"({item['sales_used']} sales, matched on {item['matched_on']})")
    if item["broadened"]:  # valued from wider sales than described: say so
        print("   ", item["reason"])
    grades = {row["label"]: row["median"] for row in item["by_condition"]}
    print("    by grade:", grades)  # let the donor see other grades without another call

print(f"Total: ${result['total']:.2f}")
print(result["method"])  # dated; show it and save it with the donor's records
print(result["disclaimer"])
if result["appraisal_may_be_needed"]:
    print("Over $5,000: the IRS requires a qualified appraisal (Form 8283, Section B).")

JavaScript

donation.mjs
// Node 18+. Save as recipe.mjs and run: SOLDSTACK_KEY=ss_your_key node recipe.mjs
const API = "https://soldstack.dev";
const KEY = process.env.SOLDSTACK_KEY; // keep the key on your server, never in a browser or app

// Call SoldStack. Waits out a busy 503; throws with SoldStack's own message on a 429 or any other error.
async function soldstack(path, body) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(API + path, {
      method: body ? "POST" : "GET",
      headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
      body: body ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(60000),
    });
    const wait = Number(res.headers.get("Retry-After"));
    if (res.status === 503 && wait) { // busy: wait the seconds it says, then retry
      await new Promise(done => setTimeout(done, wait * 1000));
      continue;
    }
    if (res.status === 429) throw new Error((await res.json()).detail); // out of lookups, or over 120 a minute
    if (!res.ok) throw new Error(`SoldStack ${res.status}: ${(await res.text()).slice(0, 200)}`);
    return res.json();
  }
  throw new Error("SoldStack is still busy. Try again in a minute.");
}

const result = await soldstack("/v1/value", { // up to 100 items per call
  days: 365,
  items: [ // the donor's list; each item has its own condition grade
    { description: "womens jeans", condition: "used_like_new" },
    { description: "womens jeans", condition: "used_good" },
    { description: "womens handbag", brand: "coach", condition: "used_good" },
    { description: "kids snow pants", condition: "used_good", quantity: 2 },
  ],
});

for (const item of result.items) {
  if (item.value === null) { // nothing to go on: never guess, show why
    console.log(`${item.description}: no value. ${item.reason}`);
    continue;
  }
  console.log(`${item.description} x${item.quantity}: $${item.total.toFixed(2)} ` +
    `(${item.sales_used} sales, matched on ${item.matched_on})`);
  if (item.broadened) console.log("   ", item.reason); // valued from wider sales than described: say so
  const grades = Object.fromEntries(item.by_condition.map(row => [row.label, row.median]));
  console.log("    by grade:", JSON.stringify(grades)); // let the donor see other grades without another call
}

console.log(`Total: $${result.total.toFixed(2)}`);
console.log(result.method); // dated; show it and save it with the donor's records
console.log(result.disclaimer);
if (result.appraisal_may_be_needed) {
  console.log("Over $5,000: the IRS requires a qualified appraisal (Form 8283, Section B).");
}

What to show the donor

value / total
value is one item's fair-market value (the median sold price of the sales used); total is value × quantity. When value is null, show reason and let them edit the item. Never fill in a guess.
matched_on / broadened
How close the sales are to the item. matched_on is brand + item, item description, category + department or category, all departments. When broadened is true the value comes from wider sales than the item as described (too few sales of that brand, department or grade), and reason says which: show it next to the value.
by_condition
The same item's sales split by grade, best first: condition, label, count, median, and p10 / p90 from 3 sales. Use it to show what a different grade would be worth without another call.
method
A dated paragraph saying how the values were found. Show it on the receipt or summary and save it with the donation, word for word.
IRS appraisal note
Show disclaimer: this is resale market data, not tax or legal advice. appraisal_may_be_needed is true when the list total is over $5,000 (USD), the line where the IRS requires a qualified appraisal (Form 8283, Section B) for a single item or a group of similar items. When it's true, tell the donor before they file.

Caching, errors and cost

Caching
Each item is a lookup every time you send it, so store the response with the donation and show the stored copy on later visits. When the donor edits the list, send only the items that changed.
Errors
A list bigger than the lookups left this month is refused whole with a 429 and nothing is counted: show detail, which suggests a shorter list or more lookups. More than 100 items is a 422. /v1/value never answers with a busy 503: if the marketplace is slow, the affected items fall back to their category and their reason says so.
Cost
4,000 items a month is 4,000 lookups: Growth, $29 a month (15,000). Starter's 2,500 is too few; the $5 one-month pass (1,000) covers a pilot.

2. Crosslister or listing app

When a seller creates a listing, prefill the price from what the same item sold for in their size and condition, and show how fast it sells. At sourcing time, the same search gives a buy or pass for the tag price. For a Canadian seller, add source=poshmark_ca: prices come back in CAD (stats.currency) and median_net_payout uses Poshmark Canada's fee.

Request: at listing time

HTTP
GET https://soldstack.dev/v1/sold?q=lululemon+align+leggings&size=8&condition=used&days=365&limit=200
X-API-Key: ss_your_key

Response (trimmed)

200 OK · application/json
{
  "query": "lululemon align leggings",
  "source": "poshmark",
  "stats": {
    "count": 14,
    "currency": "USD",
    "median": 42.0,
    "p10": 28.0,
    "p90": 58.0,
    "median_days_to_sell": 6.5,
    "median_net_payout": 33.6,
    "oldest_sold": "2025-11-30",
    "newest_sold": "2026-10-18"
  },
  "by_condition": [
    {"condition": "used_like_new", "label": "Like new", "count": 6, "median": 46.0, "p10": 38.0, "p90": 58.0},
    {"condition": "used_good", "label": "Good", "count": 5, "median": 36.0, "p10": 28.0, "p90": 44.0}
  ],
  "cleaning": {
    "found": 48,
    "used": 14,
    "removed": {"filters": 31, "other_item_types": 2, "bundles": 1},
    "confidence": "medium",
    "confidence_note": "14 matching sales. Add a model, size or condition to narrow it."
  },
  "items": [
    {
      "title": "Lululemon Align High-Rise Pant Size 8",
      "size": "8",
      "condition": "used_like_new",
      "price": 48.0,
      "currency": "USD",
      "sold_at": "2026-10-14T19:02:11-07:00",
      "days_to_sell": 3.2,
      "url": "https://poshmark.com/listing/..."
    }
  ],
  "usage": {"used": 41, "limit": 2500, "remaining": 2459, "plan": "starter", "charged": true}
}

Request: at sourcing time

HTTP
GET https://soldstack.dev/v1/verdict?q=lululemon+align+leggings&cost=12&days=365&limit=0
X-API-Key: ss_your_key

Response (trimmed)

200 OK · application/json
{
  "query": "lululemon align leggings",
  "verdict": "buy",
  "reason": "Sells for about $40 in about 7 days; at $12 you'd keep about $20.00 after fees.",
  "cost": 12.0,
  "expected_sale_price": 40.0,
  "expected_net_payout": 32.0,
  "expected_profit": 20.0,
  "max_buy": 10.67,
  "days_to_sell": 7.0,
  "confidence": "high",
  "usage": {"used": 41, "limit": 2500, "remaining": 2459, "plan": "starter", "charged": false}
}

Python

crosslist.py
import os, time
import requests

API = "https://soldstack.dev"
KEY = os.environ["SOLDSTACK_KEY"]  # keep the key on your server, never in a browser or app


def soldstack(method, path, **kw):
    """Call SoldStack. Waits out a busy 503; raises with SoldStack's own message on a 429 or any other error."""
    for _ in range(3):
        r = requests.request(method, API + path, headers={"X-API-Key": KEY}, timeout=60, **kw)
        if r.status_code == 503 and r.headers.get("Retry-After"):
            time.sleep(int(r.headers["Retry-After"]))  # busy: wait the seconds it says, then retry
            continue
        if r.status_code == 429:  # out of lookups this month, or over 120 requests a minute
            raise RuntimeError(r.json()["detail"])
        if not r.ok:
            raise RuntimeError(f"SoldStack {r.status_code}: {r.text[:200]}")
        return r.json()
    raise RuntimeError("SoldStack is still busy. Try again in a minute.")

import math


def quantile(sorted_prices, f):
    """Linear interpolation between the two nearest sales, the same way stats.p10 and p90 are computed."""
    i = (len(sorted_prices) - 1) * f
    lo, hi = math.floor(i), math.ceil(i)
    return sorted_prices[lo] + (sorted_prices[hi] - sorted_prices[lo]) * (i - lo)


# When the seller creates a listing: what this item sold for in their size and condition.
listing = {"q": "lululemon align leggings", "size": "8", "condition": "used", "days": 365, "limit": 200}
# listing["source"] = "poshmark_ca"  # Canadian seller: Poshmark Canada, prices and fees in CAD
data = soldstack("GET", "/v1/sold", params=listing)
stats, cleaning = data["stats"], data["cleaning"]

if stats["count"] == 0:  # stats is just {"count": 0}: don't suggest a price
    print("No sales to price from yet.", cleaning["confidence_note"])
else:
    prices = sorted(sale["price"] for sale in data["items"])  # limit=200 returns every clean sale
    p25, p75 = quantile(prices, 0.25), quantile(prices, 0.75)
    cur = stats["currency"]
    print(f"Suggested price: {stats['median']:.0f} {cur}")
    print(f"Half sold between {p25:.0f} and {p75:.0f} {cur}; most between {stats['p10']:.0f} and {stats['p90']:.0f}")
    if stats["median_days_to_sell"] is not None:
        print(f"Sells in about {stats['median_days_to_sell']:.0f} days")
    print(f"You keep about {stats['median_net_payout']:.0f} {cur} after fees")
    if cleaning["confidence"] == "low":
        print("Rough guess:", cleaning["confidence_note"])

# At sourcing time: buy or pass at the tag price. Same search as above, so free within 6 hours.
verdict = soldstack("GET", "/v1/verdict", params={"q": "lululemon align leggings", "cost": 12, "days": 365, "limit": 0})
print(f"{verdict['verdict'].upper()}: {verdict['reason']}")

JavaScript

crosslist.mjs
// Node 18+. Save as recipe.mjs and run: SOLDSTACK_KEY=ss_your_key node recipe.mjs
const API = "https://soldstack.dev";
const KEY = process.env.SOLDSTACK_KEY; // keep the key on your server, never in a browser or app

// Call SoldStack. Waits out a busy 503; throws with SoldStack's own message on a 429 or any other error.
async function soldstack(path, body) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(API + path, {
      method: body ? "POST" : "GET",
      headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
      body: body ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(60000),
    });
    const wait = Number(res.headers.get("Retry-After"));
    if (res.status === 503 && wait) { // busy: wait the seconds it says, then retry
      await new Promise(done => setTimeout(done, wait * 1000));
      continue;
    }
    if (res.status === 429) throw new Error((await res.json()).detail); // out of lookups, or over 120 a minute
    if (!res.ok) throw new Error(`SoldStack ${res.status}: ${(await res.text()).slice(0, 200)}`);
    return res.json();
  }
  throw new Error("SoldStack is still busy. Try again in a minute.");
}

// Linear interpolation between the two nearest sales, the same way stats.p10 and p90 are computed.
function quantile(sorted, f) {
  const i = (sorted.length - 1) * f, lo = Math.floor(i), hi = Math.ceil(i);
  return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
}

// When the seller creates a listing: what this item sold for in their size and condition.
const listing = { q: "lululemon align leggings", size: "8", condition: "used", days: 365, limit: 200 };
// listing.source = "poshmark_ca"; // Canadian seller: Poshmark Canada, prices and fees in CAD
const data = await soldstack("/v1/sold?" + new URLSearchParams(listing));
const { stats, cleaning } = data;

if (stats.count === 0) { // stats is just {count: 0}: don't suggest a price
  console.log("No sales to price from yet.", cleaning.confidence_note);
} else {
  const prices = data.items.map(sale => sale.price).sort((a, b) => a - b); // limit=200: every clean sale
  const p25 = quantile(prices, 0.25), p75 = quantile(prices, 0.75);
  const cur = stats.currency;
  console.log(`Suggested price: ${stats.median.toFixed(0)} ${cur}`);
  console.log(`Half sold between ${p25.toFixed(0)} and ${p75.toFixed(0)} ${cur}; ` +
    `most between ${stats.p10.toFixed(0)} and ${stats.p90.toFixed(0)}`);
  if (stats.median_days_to_sell !== null) console.log(`Sells in about ${stats.median_days_to_sell.toFixed(0)} days`);
  console.log(`You keep about ${stats.median_net_payout.toFixed(0)} ${cur} after fees`);
  if (cleaning.confidence === "low") console.log("Rough guess:", cleaning.confidence_note);
}

// At sourcing time: buy or pass at the tag price. Same search as above, so free within 6 hours.
const verdict = await soldstack("/v1/verdict?" + new URLSearchParams({
  q: "lululemon align leggings", cost: 12, days: 365, limit: 0,
}));
console.log(`${verdict.verdict.toUpperCase()}: ${verdict.reason}`);

What to show the seller

Suggested price
stats.median: half sold for more, half for less. Check stats.count first: when it's 0, stats has nothing else in it.
Range
stats gives p10 and p90 (four in five sold between them). For the middle half, p25 to p75, take the quartiles of items[].price as the samples do (stats has no p25 or p75): with limit=200 every clean sale comes back, since one page has at most 48.
Sells in
stats.median_days_to_sell, as "sells in about 7 days". It can be null; hide the hint then.
You keep
stats.median_net_payout: the median after the marketplace fee.
How sure
cleaning.confidence (high, medium, low) and cleaning.confidence_note, a sentence ready to show. A size filter on a rare item can leave a handful of sales: say it's a rough guess.
Buy or pass
verdict is buy, maybe or pass; reason explains it in a sentence. expected_profit is after fees; max_buy is the most to pay.

Caching, errors and cost

Caching
Cache responses by source and q for up to 6 hours. A repeat inside that window is free on SoldStack's side too, and so is changing size, condition or days, and a /v1/verdict for a search you just priced.
Errors
On a 429, show detail (it says whether the month's lookups are used up or the key is sending too fast) and let the seller type a price. On a busy 503, the helper waits Retry-After seconds and tries again. An unknown condition is a 400 that lists the accepted values.
Cost
One lookup per new search. 2,000 new listings a month is at most 2,000 lookups, with sourcing checks on the same searches free: Starter, $9 a month (2,500).

3. Home-inventory or claims app

A home-inventory or claims app values what's in a closet: what each item would sell for used, the range, and how many sales and which dates the number comes from, so a report can cite them. Use POST /v1/value with days: 365, the same call as the donation recipe.

Used value is not replacement cost

SoldStack's numbers are used resale value: what the same item, in that condition, sold for secondhand in the window. Replacement cost is what a new one costs at retail today, which for clothing is usually much higher. SoldStack has no retail prices, so don't label its numbers as replacement cost. Call the column "used resale value". For a policy that pays actual cash value it's a reasonable reference point; for one that pays replacement cost, the user also needs the retail price of a new equivalent. Either way the insurer decides; this is market data, not an appraisal.

Request

HTTP
POST https://soldstack.dev/v1/value
X-API-Key: ss_your_key
Content-Type: application/json

{
  "days": 365,
  "items": [
    {
      "description": "mens wool coat",
      "brand": "j.crew",
      "condition": "used_excellent"
    },
    {
      "description": "womens ankle boots",
      "condition": "used_good"
    },
    {
      "description": "womens handbag",
      "brand": "coach",
      "condition": "used_like_new"
    }
  ]
}

Response (trimmed)

200 OK · application/json
{
  "currency": "USD",
  "sold_after": "2025-10-22",
  "sold_before": null,
  "items": [
    {
      "description": "womens handbag",
      "brand": "coach",
      "condition": "used_like_new",
      "value": 185.0,
      "low": 140.0,
      "high": 230.0,
      "p10": 120.0,
      "p90": 260.0,
      "total": 185.0,
      "sales_used": 34,
      "oldest_sold": "2026-04-30",
      "newest_sold": "2026-10-13",
      "matched_on": "brand + item",
      "broadened": false,
      "confidence": "high",
      "reason": "34 sales of 'coach womens handbag' in like new condition sold since 2025-10-22 matched after cleaning.",
      "examples": [
        {"title": "Coach Tabby 26 Pebbled Leather", "price": 185.0, "sold_at": "2026-10-09T16:20:41+00:00", "condition": "used_like_new", "url": "https://poshmark.com/listing/..."}
      ]
    }
  ],
  "total": 342.0,
  "method": "Values were computed by SoldStack (soldstack.dev) on 2026-10-22 from completed sales on Poshmark (US). ..."
}

Python

inventory.py
import os, time
import requests

API = "https://soldstack.dev"
KEY = os.environ["SOLDSTACK_KEY"]  # keep the key on your server, never in a browser or app


def soldstack(method, path, **kw):
    """Call SoldStack. Waits out a busy 503; raises with SoldStack's own message on a 429 or any other error."""
    for _ in range(3):
        r = requests.request(method, API + path, headers={"X-API-Key": KEY}, timeout=60, **kw)
        if r.status_code == 503 and r.headers.get("Retry-After"):
            time.sleep(int(r.headers["Retry-After"]))  # busy: wait the seconds it says, then retry
            continue
        if r.status_code == 429:  # out of lookups this month, or over 120 requests a minute
            raise RuntimeError(r.json()["detail"])
        if not r.ok:
            raise RuntimeError(f"SoldStack {r.status_code}: {r.text[:200]}")
        return r.json()
    raise RuntimeError("SoldStack is still busy. Try again in a minute.")


closet = {"days": 365, "items": [
    {"description": "mens wool coat", "brand": "j.crew", "condition": "used_excellent"},
    {"description": "womens ankle boots", "condition": "used_good"},
    {"description": "womens handbag", "brand": "coach", "condition": "used_like_new"},
]}
result = soldstack("POST", "/v1/value", json=closet)
cur = result["currency"]

print("Used resale value (not replacement cost)")
for item in result["items"]:
    if item["value"] is None:
        print(f"- {item['description']}: no value. {item['reason']}")
        continue
    print(f"- {item['description']}: {item['value']:.0f} {cur} "
          f"(most sold between {item['p10']:.0f} and {item['p90']:.0f}). "
          f"Based on {item['sales_used']} Poshmark sales, {item['oldest_sold']} to {item['newest_sold']}; "
          f"confidence {item['confidence']}.")
print(f"Total used resale value: {result['total']:.0f} {cur}")
print("How these were found:", result["method"])  # dated; put it in the report

JavaScript

inventory.mjs
// Node 18+. Save as recipe.mjs and run: SOLDSTACK_KEY=ss_your_key node recipe.mjs
const API = "https://soldstack.dev";
const KEY = process.env.SOLDSTACK_KEY; // keep the key on your server, never in a browser or app

// Call SoldStack. Waits out a busy 503; throws with SoldStack's own message on a 429 or any other error.
async function soldstack(path, body) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(API + path, {
      method: body ? "POST" : "GET",
      headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
      body: body ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(60000),
    });
    const wait = Number(res.headers.get("Retry-After"));
    if (res.status === 503 && wait) { // busy: wait the seconds it says, then retry
      await new Promise(done => setTimeout(done, wait * 1000));
      continue;
    }
    if (res.status === 429) throw new Error((await res.json()).detail); // out of lookups, or over 120 a minute
    if (!res.ok) throw new Error(`SoldStack ${res.status}: ${(await res.text()).slice(0, 200)}`);
    return res.json();
  }
  throw new Error("SoldStack is still busy. Try again in a minute.");
}

const result = await soldstack("/v1/value", {
  days: 365,
  items: [
    { description: "mens wool coat", brand: "j.crew", condition: "used_excellent" },
    { description: "womens ankle boots", condition: "used_good" },
    { description: "womens handbag", brand: "coach", condition: "used_like_new" },
  ],
});
const cur = result.currency;

console.log("Used resale value (not replacement cost)");
for (const item of result.items) {
  if (item.value === null) {
    console.log(`- ${item.description}: no value. ${item.reason}`);
    continue;
  }
  console.log(`- ${item.description}: ${item.value.toFixed(0)} ${cur} ` +
    `(most sold between ${item.p10.toFixed(0)} and ${item.p90.toFixed(0)}). ` +
    `Based on ${item.sales_used} Poshmark sales, ${item.oldest_sold} to ${item.newest_sold}; ` +
    `confidence ${item.confidence}.`);
}
console.log(`Total used resale value: ${result.total.toFixed(0)} ${cur}`);
console.log("How these were found:", result.method); // dated; put it in the report

What to put in the report

value
The used resale value of one item: the median sold price.
p10 / p90
The range: four in five of the sales were between them. low / high are the 25th and 75th percentiles if you want the narrower middle half.
sales_used / oldest_sold / newest_sold
How many sales the value comes from and their date range. Cite them next to every value: "based on 34 Poshmark sales, 2026-04-30 to 2026-10-13".
confidence / reason
How far to trust the value, and why, in a sentence. matched_on and broadened say how close the sales are to the item, as in the donation recipe.
method
A dated paragraph on how the values were found. Put it at the end of the report.

Caching, errors and cost

Caching
Each item is one lookup every time it's valued. Value an item once, store the result with its date, and re-value on the user's request or once a year, not on every page view.
Errors
Same as the donation recipe: a list bigger than the lookups left is a 429 with nothing counted (show detail), and there is no busy 503 on this endpoint.
Cost
A 60-item closet is 60 lookups. 40 closets a month (2,400) fits Starter, $9 a month (2,500); 250 closets (15,000) is Growth, $29.

4. AI agent (MCP)

If your product is an AI agent, or a marketplace where agents list and buy, connect SoldStack as a remote MCP server. The agent gets three tools and decides when to call them; nothing to install, and every tool call is metered like the REST API.

Connect

MCP client config
{
  "mcpServers": {
    "soldstack": {
      "type": "http",
      "url": "https://soldstack.dev/mcp",
      "headers": { "X-API-Key": "ss_your_key" }
    }
  }
}

Claude Code:

Terminal
claude mcp add --transport http soldstack https://soldstack.dev/mcp --header "X-API-Key: ss_your_key"

For claude.ai and the Claude Desktop app, see MCP in the docs: add a custom connector with the URL and an X-API-Key request header.

The three tools

sold_comps
What an item sold for: stats, by_condition, cleaning and the matching sales under sales. Takes query, and optionally size, condition, days, limit (0–50, default 10), source and more.
buy_or_pass
buy, maybe or pass for an item at a cost, with the reason, expected_profit after fees, stats and up to 5 sales.
value_donation_list
Values a whole list in one call, like POST /v1/value: items (each with a description, and optionally brand, condition, quantity…), days and examples_per_item (0–5, default 2). Returns each item's value, range, matched_on, broadened and reason, plus the list total and method.

A tool call

Agents send this for you. To call a tool from your own backend, POST it to /mcp; the server is stateless, so no session setup is needed:

HTTP
POST https://soldstack.dev/mcp
X-API-Key: ss_your_key
Content-Type: application/json
Accept: application/json, text/event-stream

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "sold_comps",
    "arguments": {
      "query": "coach tabby 26",
      "days": 365,
      "limit": 1
    }
  }
}

The answer is {"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "…"}], "isError": false}}, where text is this JSON:

result.content[0].text (trimmed)
{
  "query": "coach tabby 26",
  "source": "poshmark",
  "stats": {"count": 44, "currency": "USD", "median": 245.0, "p10": 150.0, "p90": 417.5, "median_days_to_sell": 2.1, "median_net_payout": 196.0},
  "by_condition": [
    {"condition": "used_like_new", "label": "Like new", "count": 9, "median": 230.0, "p10": 180.0, "p90": 300.0}
  ],
  "cleaning": {"used": 44, "confidence": "high", "confidence_note": "44 matching sales with consistent prices."},
  "sales": [
    {"title": "Coach Tabby Shoulder Bag 26 In Signature Canvas", "condition": "used_like_new", "price": 187.0, "sold_at": "2026-10-15T17:51:49-07:00", "url": "https://poshmark.com/listing/..."}
  ],
  "usage": {"used": 12, "limit": 100, "remaining": 88, "plan": "free", "charged": true}
}

Python

agent.py
import json, os, re, time
import requests

API = "https://soldstack.dev"
KEY = os.environ["SOLDSTACK_KEY"]  # keep the key on your server


def call_tool(name, arguments):
    """Call one SoldStack MCP tool over plain HTTP, no SDK needed. Waits out busy; raises on anything else."""
    body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}}
    for _ in range(3):
        r = requests.post(API + "/mcp", json=body, timeout=60,
                          headers={"X-API-Key": KEY, "Accept": "application/json, text/event-stream"})
        r.raise_for_status()
        result = r.json()["result"]
        text = result["content"][0]["text"]
        if not result.get("isError"):
            return json.loads(text)
        busy = re.search(r"Try again in (\d+) seconds", text)
        if not busy:  # out of lookups, a bad key or a bad argument: the message says which
            raise RuntimeError(text)
        time.sleep(int(busy.group(1)))
    raise RuntimeError("SoldStack is still busy. Try again in a minute.")


comps = call_tool("sold_comps", {"query": "coach tabby 26", "days": 365, "limit": 1})
if comps["stats"]["count"]:
    print(f"Sells for about {comps['stats']['median']} {comps['stats']['currency']}.",
          comps["cleaning"]["confidence_note"])

pick = call_tool("buy_or_pass", {"query": "coach tabby 26", "cost": 40})
print(f"{pick['verdict']}: {pick['reason']}")

# A whole list is one call, not one per item. Each item is still one lookup.
listing = call_tool("value_donation_list", {"days": 365, "examples_per_item": 1, "items": [
    {"description": "womens handbag", "brand": "coach", "condition": "used_good"},
    {"description": "mens suit", "condition": "used_excellent"},
]})
print(f"List total ${listing['total']:.2f}: {listing['items_valued']} of {len(listing['items'])} items valued")

JavaScript

agent.mjs
// Node 18+. Save as agent.mjs and run: SOLDSTACK_KEY=ss_your_key node agent.mjs
const API = "https://soldstack.dev";
const KEY = process.env.SOLDSTACK_KEY; // keep the key on your server

// Call one SoldStack MCP tool over plain HTTP, no SDK needed. Waits out busy; throws on anything else.
async function callTool(name, args) {
  const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } };
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(API + "/mcp", {
      method: "POST",
      headers: { "X-API-Key": KEY, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(60000),
    });
    if (!res.ok) throw new Error(`SoldStack ${res.status}: ${(await res.text()).slice(0, 200)}`);
    const { result } = await res.json();
    const text = result.content[0].text;
    if (!result.isError) return JSON.parse(text);
    const busy = text.match(/Try again in (\d+) seconds/);
    if (!busy) throw new Error(text); // out of lookups, a bad key or a bad argument: the message says which
    await new Promise(done => setTimeout(done, Number(busy[1]) * 1000));
  }
  throw new Error("SoldStack is still busy. Try again in a minute.");
}

const comps = await callTool("sold_comps", { query: "coach tabby 26", days: 365, limit: 1 });
if (comps.stats.count) {
  console.log(`Sells for about ${comps.stats.median} ${comps.stats.currency}.`, comps.cleaning.confidence_note);
}

const pick = await callTool("buy_or_pass", { query: "coach tabby 26", cost: 40 });
console.log(`${pick.verdict}: ${pick.reason}`);

// A whole list is one call, not one per item. Each item is still one lookup.
const listing = await callTool("value_donation_list", {
  days: 365, examples_per_item: 1, items: [
    { description: "womens handbag", brand: "coach", condition: "used_good" },
    { description: "mens suit", condition: "used_excellent" },
  ],
});
console.log(`List total $${listing.total.toFixed(2)}: ${listing.items_valued} of ${listing.items.length} items valued`);

Caching, errors and cost

value_donation_list once per list
Call it once with the whole list (up to 100 items), not once per item. The lookups are the same, one per item, but one call is far faster than dozens and is the only way to get the list's total and method.
Caching
sold_comps and buy_or_pass share the REST cache: the same query within 6 hours is free, with any filters. value_donation_list counts every item every call.
Errors
Errors come back as a tool result with isError: true and a sentence the agent can read. Busy says how many seconds to wait and to call again; the samples do that. A monthly limit or a bad key isn't worth retrying: show the message. Tell agents to call the tools one item at a time, not in parallel.
Cost
Each tool call is one lookup (a list, one per item). An agent marketplace pricing 400 new listings a day (about 12,000 a month) fits Growth, $29 a month (15,000); Scale is 60,000 a month.

5. Google Sheets

For a team that lives in a spreadsheet: three formulas price a whole column. Install the script once from the Google Sheets page (it puts your key in for you), then type the item names in column A.

The formulas

Google Sheet
A1  Item                         B1  Paid
A2  coach tabby 26               B2  40
A3  nike dunk low                B3  25
A4  lululemon align leggings     B4  12

C2  =SOLDPRICE(A2:A4)
D2  =BUYORPASS(A2:A4, B2:B4)
E1  =SOLDSTATS_HEADERS()
E2  =SOLDSTATS(A2:A4)
ItemPaid=SOLDPRICE=BUYORPASS
coach tabby 26$40$245BUY
nike dunk low$25$68MAYBE
lululemon align leggings$12$30MAYBE

Real results from October 4, 2026. =SOLDSTATS(A2:A4) fills 7 cells across each row: Typical price, Low, High, Days to sell, You keep, Sales used, Confidence. For coach tabby 26 that row was 245, 150, 417.5, 2.1, 196, 44, high.

=SOLDPRICE(item, [size], [condition])
What it typically sells for: stats.median from /v1/sold.
=SOLDSTATS(item, [size], [condition])
Typical price, low (p10), high (p90), days to sell, what you keep after fees, sales used and confidence.
=BUYORPASS(item, cost, [size], [condition], [min_profit])
BUY, MAYBE or PASS from /v1/verdict.
Free and paid limits
Each item looked up for the first time is one lookup. The same item again within 6 hours is free, and so is changing its size, condition or cost. Free keys get 100 lookups a month, enough for a 100-row column once. The $5 one-month pass gives 1,000 for 30 days with no subscription; Starter is $9 a month for 2,500, Growth $29 for 15,000, Scale 60,000. Anyone who can edit the sheet can see the key in the script.

The same columns from code

Outgrowing the sheet, or filling it from a script? These print the SOLDSTATS columns and the buy-or-pass call for the same 3 rows as CSV, with the formulas' rule: the last year of sales, or every sale when a year has fewer than 8.

Python

sheet_rows.py
import os, time
import requests

API = "https://soldstack.dev"
KEY = os.environ["SOLDSTACK_KEY"]  # keep the key on your server, never in a browser or app


def soldstack(method, path, **kw):
    """Call SoldStack. Waits out a busy 503; raises with SoldStack's own message on a 429 or any other error."""
    for _ in range(3):
        r = requests.request(method, API + path, headers={"X-API-Key": KEY}, timeout=60, **kw)
        if r.status_code == 503 and r.headers.get("Retry-After"):
            time.sleep(int(r.headers["Retry-After"]))  # busy: wait the seconds it says, then retry
            continue
        if r.status_code == 429:  # out of lookups this month, or over 120 requests a minute
            raise RuntimeError(r.json()["detail"])
        if not r.ok:
            raise RuntimeError(f"SoldStack {r.status_code}: {r.text[:200]}")
        return r.json()
    raise RuntimeError("SoldStack is still busy. Try again in a minute.")

import csv, sys

ROWS = [("coach tabby 26", 40), ("nike dunk low", 25), ("lululemon align leggings", 12)]


def soldstats(item):
    """The 7 cells =SOLDSTATS(item) fills: the last year of sales, or every sale when a year has fewer than 8."""
    data = soldstack("GET", "/v1/sold", params={"q": item, "days": 365, "limit": 1})
    if data["cleaning"]["used"] < 8:  # same search without the days filter: free, it's cached
        everything = soldstack("GET", "/v1/sold", params={"q": item, "limit": 1})
        if everything["cleaning"]["used"] > data["cleaning"]["used"]:
            data = everything
    s, c = data["stats"], data["cleaning"]
    if not c["used"]:
        return ["No sales found"] + [""] * 6
    return [s["median"], s["p10"], s["p90"], s["median_days_to_sell"], s["median_net_payout"], c["used"], c["confidence"]]


out = csv.writer(sys.stdout)  # paste the CSV back into a sheet, or write it to a file
out.writerow(["Item", "Paid", "Buy or pass", "Typical price", "Low", "High", "Days to sell", "You keep",
              "Sales used", "Confidence"])
for item, paid in ROWS:
    verdict = soldstack("GET", "/v1/verdict", params={"q": item, "cost": paid, "days": 365, "limit": 0})
    out.writerow([item, paid, verdict["verdict"].upper()] + soldstats(item))

JavaScript

sheet_rows.mjs
// Node 18+. Save as recipe.mjs and run: SOLDSTACK_KEY=ss_your_key node recipe.mjs
const API = "https://soldstack.dev";
const KEY = process.env.SOLDSTACK_KEY; // keep the key on your server, never in a browser or app

// Call SoldStack. Waits out a busy 503; throws with SoldStack's own message on a 429 or any other error.
async function soldstack(path, body) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(API + path, {
      method: body ? "POST" : "GET",
      headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
      body: body ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(60000),
    });
    const wait = Number(res.headers.get("Retry-After"));
    if (res.status === 503 && wait) { // busy: wait the seconds it says, then retry
      await new Promise(done => setTimeout(done, wait * 1000));
      continue;
    }
    if (res.status === 429) throw new Error((await res.json()).detail); // out of lookups, or over 120 a minute
    if (!res.ok) throw new Error(`SoldStack ${res.status}: ${(await res.text()).slice(0, 200)}`);
    return res.json();
  }
  throw new Error("SoldStack is still busy. Try again in a minute.");
}

const ROWS = [["coach tabby 26", 40], ["nike dunk low", 25], ["lululemon align leggings", 12]];

// The 7 cells =SOLDSTATS(item) fills: the last year of sales, or every sale when a year has fewer than 8.
async function soldstats(item) {
  let data = await soldstack("/v1/sold?" + new URLSearchParams({ q: item, days: 365, limit: 1 }));
  if (data.cleaning.used < 8) { // same search without the days filter: free, it's cached
    const everything = await soldstack("/v1/sold?" + new URLSearchParams({ q: item, limit: 1 }));
    if (everything.cleaning.used > data.cleaning.used) data = everything;
  }
  const { stats: s, cleaning: c } = data;
  if (!c.used) return ["No sales found", "", "", "", "", "", ""];
  return [s.median, s.p10, s.p90, s.median_days_to_sell ?? "", s.median_net_payout, c.used, c.confidence];
}

const csv = cells => cells.map(v => `"${String(v).replaceAll('"', '""')}"`).join(",");
console.log(csv(["Item", "Paid", "Buy or pass", "Typical price", "Low", "High", "Days to sell", "You keep",
  "Sales used", "Confidence"]));
for (const [item, paid] of ROWS) { // one at a time: kinder to the busy queue than all at once
  const verdict = await soldstack("/v1/verdict?" + new URLSearchParams({ q: item, cost: paid, days: 365, limit: 0 }));
  console.log(csv([item, paid, verdict.verdict.toUpperCase(), ...(await soldstats(item))]));
}
Caching
The script keeps each answer for 6 hours, the same as the server cache. To refresh, re-enter the formula after that. The fallback to every sale is the same search with a different filter, so it's free.
Errors
A cell that says #ERROR! shows the reason on hover: usually the key. Past the monthly limit, new rows say "This month's lookups are used up" instead of a price (a column keeps its other rows); too many at once says to wait a minute and re-enter the formula.
Cost
A 300-row inventory priced once a month is 300 lookups: Starter, $9 a month (2,500), with room for 8 sheets that size. A one-off 1,000-row sheet: the $5 one-month pass.

Plans

PlanLookups / monthPrice
Free100$0
Starter2,500$9/mo
Growth15,000$29/mo
Scale60,000$79/mo
One-month pass1,000 for 30 days$5 once

Repeating a search within 6 hours is free, and so is changing its filters. Failed lookups don't count.

Just need it for a month? $5 one-month pass. 1,000 lookups for 30 days in Google Sheets, the API or AI agents. One payment, no subscription, nothing to cancel. Get the $5 pass