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.
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:
- 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. - Use the helper. Every sample starts with a small
soldstack()function that sends the key, waits out a busy503for the seconds inRetry-After, and raises with SoldStack's own message on a429. The Python samples needpip install requests; the JavaScript ones need Node 18 or newer and nothing else.
- Repeats are free
/v1/soldand/v1/verdictcache 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 andq, not on the filters.usage.chargedand theX-Lookup-Chargedheader say whether a call counted.- Except lists
POST /v1/valueis 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.
detailsays 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-Afterand 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
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)
{
"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
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
// 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
valueis one item's fair-market value (the median sold price of the sales used);totalisvalue×quantity. Whenvalueisnull, showreasonand let them edit the item. Never fill in a guess.- matched_on / broadened
- How close the sales are to the item.
matched_onisbrand + item,item description,category + departmentorcategory, all departments. Whenbroadenedistruethe value comes from wider sales than the item as described (too few sales of that brand, department or grade), andreasonsays which: show it next to the value. - by_condition
- The same item's sales split by grade, best first:
condition,label,count,median, andp10/p90from 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_neededistruewhen 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'strue, 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
429and nothing is counted: showdetail, which suggests a shorter list or more lookups. More than 100 items is a422./v1/valuenever answers with a busy503: if the marketplace is slow, the affected items fall back to their category and theirreasonsays 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
GET https://soldstack.dev/v1/sold?q=lululemon+align+leggings&size=8&condition=used&days=365&limit=200
X-API-Key: ss_your_keyResponse (trimmed)
{
"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
GET https://soldstack.dev/v1/verdict?q=lululemon+align+leggings&cost=12&days=365&limit=0
X-API-Key: ss_your_keyResponse (trimmed)
{
"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
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
// 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. Checkstats.countfirst: when it's 0,statshas nothing else in it.- Range
statsgivesp10andp90(four in five sold between them). For the middle half, p25 to p75, take the quartiles ofitems[].priceas the samples do (statshas no p25 or p75): withlimit=200every 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 benull; hide the hint then.- You keep
stats.median_net_payout: the median after the marketplace fee.- How sure
cleaning.confidence(high,medium,low) andcleaning.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
verdictisbuy,maybeorpass;reasonexplains it in a sentence.expected_profitis after fees;max_buyis the most to pay.
Caching, errors and cost
- Caching
- Cache responses by source and
qfor up to 6 hours. A repeat inside that window is free on SoldStack's side too, and so is changingsize,conditionordays, and a/v1/verdictfor a search you just priced. - Errors
- On a
429, showdetail(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 busy503, the helper waitsRetry-Afterseconds and tries again. An unknownconditionis a400that 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
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)
{
"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
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
// 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/highare 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_onandbroadenedsay 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
429with nothing counted (showdetail), and there is no busy503on 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
{
"mcpServers": {
"soldstack": {
"type": "http",
"url": "https://soldstack.dev/mcp",
"headers": { "X-API-Key": "ss_your_key" }
}
}
}Claude Code:
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,cleaningand the matching sales undersales. Takesquery, and optionallysize,condition,days,limit(0–50, default 10),sourceand more. - buy_or_pass
buy,maybeorpassfor an item at acost, with thereason,expected_profitafter fees,statsand up to 5 sales.- value_donation_list
- Values a whole list in one call, like
POST /v1/value:items(each with adescription, and optionallybrand,condition,quantity…),daysandexamples_per_item(0–5, default 2). Returns each item'svalue, range,matched_on,broadenedandreason, plus the listtotalandmethod.
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:
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:
{
"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
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
// 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
totalandmethod. - Caching
sold_compsandbuy_or_passshare the REST cache: the same query within 6 hours is free, with any filters.value_donation_listcounts every item every call.- Errors
- Errors come back as a tool result with
isError: trueand 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
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)| Item | Paid | =SOLDPRICE | =BUYORPASS |
|---|---|---|---|
| coach tabby 26 | $40 | $245 | BUY |
| nike dunk low | $25 | $68 | MAYBE |
| lululemon align leggings | $12 | $30 | MAYBE |
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.medianfrom/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
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
// 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
| Plan | Lookups / month | Price |
|---|---|---|
| Free | 100 | $0 |
| Starter | 2,500 | $9/mo |
| Growth | 15,000 | $29/mo |
| Scale | 60,000 | $79/mo |
| One-month pass | 1,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