If you resell sneakers, or you're just deciding whether to sell a pair you own, StockX is the price reference most people check. The number on the product tile is not the whole story, though. Every size has its own lowest ask, highest bid, and sales pace, and the gap between them is where you either make money or don't.
People usually start by searching for "StockX API". There is an official one, but it's gated and built for sellers. This post covers what StockX officially offers, what a plain script gets from stockx.com today, and then builds stockx.mjs, one Node.js file with no npm dependencies (it uses the built-in fetch and node:sqlite, so Node 22.5+). It has five commands:
-
probechecks what stockx.com and the official API return to a script -
searchruns a product search with sort and filters -
sizesshows every size's ask, bid, spread, and what you'd be paid at the bid -
historysummarizes the price chart for a product or one size -
tracksaves per-size snapshots in SQLite and reports bid/ask moves
I ran everything on November 9, 2026 from a cloud server with Node 24.22. The outputs below are copied from those runs.
Read this first. StockX's Terms and Conditions of Use (dated 2 July 2026) say you will not "use any robot, spider, scraper, or other automated or manual means to access our Services, or copy any information thereon, for any purpose without our express written permission". The same list rules out using the Services "for benchmarking, competitive analysis", and Section 27 says you may not use their market and pricing data ("Platform Data") for any commercial purpose, including resale or putting it into a third-party product, without written consent. The terms also say they apply to bots and other automated software acting for you. So scraping StockX goes against their terms, whether you do it yourself or through a third-party service, and you should review those terms and your own legal risk before you run anything. If you're an approved seller, use the official API (next section). If you go ahead anyway, keep it to a few products you're actually buying or selling, go slowly, and don't republish or resell the data.
Is there a StockX API?
Yes. StockX runs a developer portal at developer.stockx.com with Catalog, Seller (listings), and Order APIs. From the portal's integration guide, getting access looks like this:
- Create a StockX account and verify your email.
- Fill in the developer sign-up form and wait for an approval email.
- Get your API key from the portal and create an app for a client ID and secret.
- Do the OAuth 2.0 authorization-code flow (
accounts.stockx.com), and refresh the access token every 12 hours. - Send
Authorization: Bearer <token>andx-api-keyon every call tohttps://api.stockx.com/v2/....
The catalog search is GET /v2/catalog/search?query=...&pageNumber=1&pageSize=10 (keyword, GTIN, or style ID, up to 50 per page). Market data is per product or per variant: GET /v2/catalog/products/{productId}/market-data returns lowest ask, highest bid, and StockX's suggested "sell faster" and "earn more" prices per size.
What I didn't find in the API reference is a sales-history or price-chart endpoint, and the API is built around managing your own listings, not market research across products you don't sell. Without credentials, it answers exactly as you'd expect:
GET https://api.stockx.com/v2/catalog/search?query=jordan
401 {"statusCode":401,"message":"Unauthorized"}
| You want | Use |
|---|---|
| Manage your own asks and orders, look up product and variant IDs | Official API (approval + OAuth + API key) |
| Bid/ask per size for products you list | Official API market-data endpoints |
| Price history, search with filters, products you don't sell | No official option. That's what the rest of this post does, and it's what the terms forbid. |
Step 1: Probe stockx.com before you write a parser
Lots of StockX tutorials start with fetch("https://stockx.com/<product>") and a regex for __NEXT_DATA__, or with the old stockx.com/api/browse route. Before you invest in that, check what your machine actually receives:
// stockx.mjs - StockX product search, size-level bids/asks, price history,
// and a SQLite resale tracker in Node.js.
// Node 22.5+ (built-in fetch and node:sqlite), no npm dependencies.
// Read StockX's Terms first: they prohibit scrapers and automated access
// without written permission. See the article for details.
import { DatabaseSync } from "node:sqlite";
const API = "https://api.scrapeatlas.com";
const KEY = process.env.SCRAPEATLAS_API_KEY ?? "";
const UA = process.env.SX_UA ?? "stockx-research-script/1.0 (+mailto:you@example.com)";
// ---------- Step 1: what does stockx.com give a plain script? ----------
async function probe() {
const targets = [
["robots.txt", "https://stockx.com/robots.txt"],
["product page", "https://stockx.com/air-jordan-4-retro-black-cat-2025"],
["search page", "https://stockx.com/search?s=jordan%204"],
["official API, no auth", "https://api.stockx.com/v2/catalog/search?query=jordan"],
];
const browserUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36";
for (const [label, url] of targets) {
for (const [uaName, ua] of [["script UA", UA], ["chrome UA", browserUA]]) {
const r = await fetch(url, { headers: { "user-agent": ua, accept: "text/html,application/json" } });
const body = await r.text();
const title = body.match(/<title>([^<]*)<\/title>/)?.[1] ?? "";
console.log(`${label.padEnd(22)} ${uaName.padEnd(10)} ${String(r.status).padEnd(4)} ${String(body.length).padStart(7)} B ${title || body.slice(0, 45).replace(/\s+/g, " ")}`);
await new Promise((res) => setTimeout(res, 2000));
}
}
}
The real result from my server:
$ node stockx.mjs probe
robots.txt script UA 200 7321 B User-Agent: * Allow: /about/buying Allow: /ab
robots.txt chrome UA 200 7321 B User-Agent: * Allow: /about/buying Allow: /ab
product page script UA 403 76301 B Error
product page chrome UA 403 76301 B Error
search page script UA 403 76301 B Error
search page chrome UA 403 76301 B Error
official API, no auth script UA 401 43 B {"statusCode":401,"message":"Unauthorized"}
official API, no auth chrome UA 401 43 B {"statusCode":401,"message":"Unauthorized"}
The 403 page is 76 KB of styled HTML, and its text is clear about what StockX wants: "Please log in to verify you are not a bot. If you are looking to access data through our API, please visit our developer portal." The responses come through Cloudflare (server: cloudflare). A Chrome User-Agent changed nothing, which is expected: bot checks look at much more than one header.
You'll also see robots.txt disallow /api/, */search*, */buy/*, */sell/*, listings pages, and filter parameters like */sort= and */below-retail= for every crawler.
I stopped there. Getting past a login wall with headless browsers, residential proxies, or stolen session cookies is exactly what the terms forbid, and it breaks every few weeks anyway. I'm not going to show you a parser for a page I couldn't load.
Step 2: One small API client
Since stockx.com itself is walled from a server, the rest of the script reads StockX data through ScrapeAtlas*, using the four StockX endpoints from its OpenAPI spec: /v1/stockx/search, /v1/stockx/product, /v1/stockx/product/market, and /v1/stockx/product/price-history. They're plain GET requests with an x-api-key header. A successful call costs 1 credit, and a failed one costs nothing. To be clear, this changes who makes the request, not what StockX's terms say.
// ---------- Step 2: one small API client ----------
async function sa(path, params) {
if (!KEY) throw new Error("Set SCRAPEATLAS_API_KEY first.");
const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== ""));
const r = await fetch(`${API}${path}?${qs}`, { headers: { "x-api-key": KEY }, signal: AbortSignal.timeout(90_000) });
const j = await r.json().catch(() => ({}));
if (!r.ok || j.success === false) {
throw new Error(`${path} -> HTTP ${r.status} ${j.failure?.code ?? ""} ${j.failure?.message ?? ""}`.trim());
}
for (const w of j.coverage?.warnings ?? []) console.error(`warning (${path}): ${typeof w === "string" ? w : JSON.stringify(w)}`);
return j;
}
const money = (v) => (v == null ? "-" : `$${v}`);
const pct = (v) => (v == null ? "-" : `${(v * 100).toFixed(1)}%`);
Two details. The client throws on success: false, not only on HTTP errors, so a bad parameter never turns into an empty table that looks fine. And every response has a coverage object with a warnings array. None of my calls had warnings, but if a response is partial you want to see that on stderr, not find out a week later.
Step 3: Search products
// ---------- Step 3: search ----------
export async function search(query, { sort = "most-active", market = "us", page = 1, ...filters } = {}) {
const j = await sa("/v1/stockx/search", { query, sort, market, page, ...filters });
return {
total: j.totalCount, page: j.page, pageCount: j.pageCount, currency: j.currency,
products: j.products.map((p) => ({
slug: p.slug, title: p.title, releaseDate: p.releaseDate, sizeCount: p.sizeCount,
lowestAsk: p.market?.lowestAsk ?? null, highestBid: p.market?.highestBid ?? null,
lastSale: p.market?.lastSale ?? null, sales72h: p.market?.salesLast72Hours ?? null,
premium: p.market?.annual?.pricePremium ?? null, isAd: Boolean(p.isAd),
})),
};
}
The search endpoint takes sort (featured, most-active, lowest_ask, highest_bid, release_date, last_sale, price_premium, and a few more), market (us, gb, de, fr, ca, au, jp), and filters like category, brand, gender, color, price_min/price_max, and below_retail. Each page has 40 products, up to page 25.
$ node stockx.mjs search jordan 4 --sort most-active
1000 results, page 1/25, USD
ask bid last 72h premium title
$152 $421 $165 252 -25.0% Jordan 4 Retro Toro Bravo (2026)
$277 $563 $321 200 45.9% Jordan 4 Retro Black Cat (2025)
$139 $190 $145 150 -34.1% Jordan 4 Retro Rare Air Tour Yellow (2026)
$63 $113 $82 79 -48.8% Jordan 4 Retro Toro Bravo (2026) (GS)
$143 $177 $149 82 -33.8% Jordan 4 Retro J Balvin Lemonade
$199 $292 $283 81 23.0% Jordan 4 Retro OG Nigel Sylvester Brick After Brick
$200 $282 $248 80 10.2% Jordan 4 Retro White Cement (2025)
$130 $211 $131 82 -40.5% Jordan 4 Retro OG Flight Club
$124 $261 $181 59 -19.6% Jordan 4 Retro SB Navy
$167 $250 $219 55 1.9% Jordan 4 Retro Military Blue (2024)
Look at the first row: lowest ask $152, highest bid $421. A bid above the ask would be matched at once, so that can't be one market. It's not a bug either. The product-level numbers are aggregates over all sizes: the lowest ask in any size and the highest bid in any size, usually not the same size. Don't calculate a spread from search results. Step 4 shows where those numbers really come from.
The same goes for premium. It's the annual price premium over retail, not today's. Here's a filtered search for Nike Dunks with below_retail:
$ node stockx.mjs search dunk low --brand nike --category sneakers --below_retail true --sort most-active --limit 8
1000 results, page 1/25, USD
ask bid last 72h premium title
$58 $126 $59 64 -48.7% Nike Dunk Low Retro White Black Panda
$36 $69 $37 45 -67.8% Nike Dunk Low Retro White Black Panda (Women's)
$101 $116 $109 36 -16.2% Nike Dunk Low Retro Khaki Team Red Pearl White (Women's)
$97 $100 $99 33 -17.5% Nike SB Dunk Low Pro Filbert Light Chocolate
$99 $117 $148 25 13.1% Nike Dunk Low Pegasus Pack Linen Phantom
$59 $61 $62 22 -38.0% Nike Dunk Low Cacao Wow (Women's)
$47 $75 $77 22 -33.0% Nike Dunk Low White Light Smoke Grey
$87 $145 $123 21 7.0% Nike Dunk Low Pro SB Black White Gum
"Pegasus Pack Linen Phantom" passed the below-retail filter with a +13.1% annual premium. The filter compares the current lowest ask (again, any size) with retail, while the premium column looks back over the year. Both numbers are true, they just answer different questions.
One more: totalCount came back as 1000 for both queries, which is exactly 25 pages of 40. I'd read it as "at least 1000", not as the real number of matches.
Step 4: Sizes, spreads, and payout at the bid
This is the useful part if you're selling. One call gets the product (for retail price and style code), one gets the market for every size. Then for each size:
- spread = lowest ask minus highest bid, only when both exist
- payout@bid = what you'd get selling now into the highest bid, after your fee rate and shipping
- vs retail = payout minus retail, so you see if a pair bought at retail would have been worth it
// ---------- Step 4: product + per-size market -> spreads and payouts ----------
// fee and ship are YOUR numbers (seller level, region, payment method). Defaults are placeholders.
export async function sizes(slug, { market = "us", fee = 0.1, ship = 0 } = {}) {
const [{ product: p }, m] = [await sa("/v1/stockx/product", { slug }), await sa("/v1/stockx/product/market", { slug, market })];
const retail = p.retailPrice ?? null;
const rows = m.sizes.map((s) => {
const payout = s.highestBid == null ? null : Math.round(s.highestBid * (1 - fee) - ship);
return {
size: s.size, sizeId: s.id, lowestAsk: s.lowestAsk, highestBid: s.highestBid,
asks: s.numberOfAsks, bids: s.numberOfBids, lastSale: s.lastSale, sales72h: s.salesLast72Hours,
sales90d: s.last90Days?.salesCount ?? 0,
// A real spread only exists when both sides exist and ask >= bid.
spread: s.lowestAsk != null && s.highestBid != null ? s.lowestAsk - s.highestBid : null,
payoutAtBid: payout,
vsRetail: payout != null && retail != null ? payout - retail : null,
};
});
return { title: p.title, styleId: p.styleId, retail, currency: m.currency, summary: m.summary, rows };
}
The fee and ship values are yours to set. StockX's seller fees depend on things like your seller level and region, so use the numbers from your own StockX account. The default 10% here is a placeholder, not StockX's rate.
$ node stockx.mjs sizes air-jordan-4-retro-black-cat-2025 --fee 0.1 --ship 0
Jordan 4 Retro Black Cat (2025) | style FV5029-010 | retail $220 | USD
product: lowest ask $277, highest bid $563, last sale $321, 72h sales 200
size ask bid spread asks bids 72h 90d payout@bid vs retail
3.5 - $220 - 0 5 0 0 $198 -22
4 - $245 - 0 10 0 0 $221 +1
4.5 $998 $339 659 2 8 0 1 $305 +85
5 $471 $251 220 2 9 0 0 $226 +6
5.5 - $280 - 0 6 0 0 $252 +32
6 - $563 - 0 13 0 0 $507 +287
6.5 - $358 - 0 11 0 0 $322 +102
7 $435 $325 110 28 16 2 73 $293 +73
7.5 $392 $338 54 24 19 4 85 $304 +84
8 $341 $320 21 44 33 7 326 $288 +68
8.5 $283 $279 4 68 22 17 405 $251 +31
9 $288 $283 5 97 28 19 572 $255 +35
9.5 $282 $267 15 150 27 30 704 $240 +20
10 $282 $275 7 187 33 32 745 $248 +28
10.5 $315 $273 42 162 28 20 579 $246 +26
11 $277 $265 12 161 28 32 660 $239 +19
11.5 $348 $307 41 141 26 6 214 $276 +56
12 $285 $265 20 141 32 19 499 $239 +19
12.5 $343 $298 45 34 19 5 89 $268 +48
13 $338 $301 37 91 28 5 281 $271 +51
13.5 - $303 - 0 3 0 0 $273 +53
14 $366 $358 8 34 18 4 95 $322 +102
15 $387 $336 51 43 10 2 25 $302 +82
16 $598 $335 263 15 9 0 3 $302 +82
17 $799 $311 488 5 8 0 7 $280 +60
18 $591 $274 317 6 7 0 3 $247 +27
Now the $563 "highest bid" from the search makes sense: it's one bid in size 6, a size with zero asks and zero sales in 90 days. The $277 lowest ask is size 11. Neither tells you what a normal pair sells for.
What I read from this table:
- The liquid sizes are 8 to 12. They have 44 to 187 asks each, 6 to 32 sales in 72 hours, and spreads of $4 to $42. That's a real market, and the bid is close to the last sale.
- Small and very large sizes are thin. Sizes 16 to 18 have spreads of $263 to $488 and 0 sales in 72 hours. An ask at $799 is a wish, not a price.
- Sizes with bids but no asks (3.5, 4, 5.5, 6, 6.5, 13.5) are where a seller can name a price, if they have the pair. That's also where a single odd bid can wreck any average.
- At a 10% fee, almost every size pays more than the $220 retail at the bid, but only by $19 to $56 in the busiest sizes (9 to 12). After real shipping and fees, that margin can disappear.
Step 5: Price history
// ---------- Step 5: price history ----------
export async function history(slug, { range = "1y", market = "us", size } = {}) {
const j = await sa("/v1/stockx/product/price-history", { slug, range, market, size });
const pts = j.points.filter((p) => p.price != null);
const prices = pts.map((p) => p.price);
const first = pts[0], last = pts.at(-1);
const bars = "▁▂▃▄▅▆▇█", lo = Math.min(...prices), hi = Math.max(...prices);
const step = Math.max(1, Math.ceil(pts.length / 50));
const spark = prices.filter((_, i) => i % step === 0)
.map((v) => bars[hi === lo ? 0 : Math.round(((v - lo) / (hi - lo)) * 7)]).join("");
return {
title: j.product.title, range: j.range, size: j.size?.size ?? null, currency: j.currency, points: pts.length,
from: first?.date.slice(0, 10), to: last?.date.slice(0, 10), first: first?.price, last: last?.price,
low: lo, high: hi, change: first ? (last.price - first.price) / first.price : null, spark,
};
}
range is all, 1y, 6m, 3m, or 1m, and size narrows the chart to one size. The sparkline is just so you can see the shape in a terminal.
$ node stockx.mjs history air-jordan-4-retro-black-cat-2025 --range 1y
Jordan 4 Retro Black Cat (2025) | range 1y | size all | 100 points 2025-11-09 -> 2026-11-06
first $344 last $310 low $270 high $344 change -9.9%
█▇▃▄▅▅▃▃▂▂▁▂▂▃▂▃▄▄▅▄▅▅▄▅▅▄▆▆▆▆▆▅▅▅▄▅▆▆▆▄▅▅▅▆▆▅▄▅▆▆
$ node stockx.mjs history air-jordan-4-retro-black-cat-2025 --range 1y --size 10
Jordan 4 Retro Black Cat (2025) | range 1y | size 10 | 100 points 2025-11-09 -> 2026-11-06
first $341 last $301 low $267 high $341 change -11.7%
█▇▃▄▄▅▄▃▂▂▂▃▂▂▂▃▄▃▅▃▅▅▄▄▄▃▆▆▆▅▅▄▅▄▄▄▄▅▄▃▄▆▅▅▆▆▆▆▅▆
$ node stockx.mjs history air-jordan-4-retro-black-cat-2025 --range 1m
Jordan 4 Retro Black Cat (2025) | range 1m | size all | 100 points 2026-10-09 -> 2026-11-09
first $305 last $303 low $286 high $354 change -0.7%
▃▂▃▅▃▅▂▂▁▂▃▄▄▄▂▄▂▄▅▄▆▃▅▅▅▆▆▅▆▅▃▅▅▆▇▄▇▆▅▇▄▅▅▅▅▃▄▄▃▃
Three things I noticed in the raw points:
-
Every range I asked for returned exactly 100 points. In the raw 6-month chart, after the first point, they were evenly spaced about 1 day, 20 hours, and 24 minutes apart. These are points on a chart line, not individual sales. Use them for trend and level, not for counting sales (the market endpoint's
salesLast72Hoursandlast90Days.salesCountare the sales counts). - The 1-year chart starts on November 9, 2025, before the shoe's November 28, 2025 release date. I don't know exactly what the pre-release points are made of, so I'd ignore them for any "since release" math.
- Size 10 tracks the whole product closely ($301 vs $310 at the end, -11.7% vs -9.9% over the year), because sizes 8 to 12 are most of the volume. For thin sizes, always ask for the size's own chart.
Step 6: A SQLite bid/ask tracker
Each track run saves one snapshot per product and one row per size, then compares it with the previous snapshot. It reports when an ask or bid appears, disappears, or moves by at least --min dollars (default 5).
// ---------- Step 6: SQLite tracker ----------
export function openDb(file = "stockx.db") {
const db = new DatabaseSync(file);
db.exec(`
CREATE TABLE IF NOT EXISTS snaps (
id INTEGER PRIMARY KEY, slug TEXT NOT NULL, market TEXT NOT NULL, fetched_at TEXT NOT NULL,
lowest_ask INTEGER, highest_bid INTEGER, last_sale INTEGER, sales_72h INTEGER);
CREATE TABLE IF NOT EXISTS size_rows (
snap_id INTEGER NOT NULL REFERENCES snaps(id), size TEXT NOT NULL,
lowest_ask INTEGER, highest_bid INTEGER, asks INTEGER, bids INTEGER, last_sale INTEGER);
CREATE INDEX IF NOT EXISTS size_rows_snap ON size_rows(snap_id);`);
return db;
}
export async function track(db, slug, { market = "us", minMove = 5 } = {}) {
const m = await sa("/v1/stockx/product/market", { slug, market });
const s = m.summary;
const { lastInsertRowid } = db.prepare(
`INSERT INTO snaps (slug, market, fetched_at, lowest_ask, highest_bid, last_sale, sales_72h) VALUES (?,?,?,?,?,?,?)`
).run(slug, market, m.fetchedAt, s.lowestAsk, s.highestBid, s.lastSale, s.salesLast72Hours);
const sid = Number(lastInsertRowid);
const ins = db.prepare(`INSERT INTO size_rows VALUES (?,?,?,?,?,?,?)`);
for (const z of m.sizes) ins.run(sid, z.size, z.lowestAsk, z.highestBid, z.numberOfAsks, z.numberOfBids, z.lastSale);
const prev = db.prepare(`SELECT id, fetched_at FROM snaps WHERE slug = ? AND market = ? AND id < ? ORDER BY id DESC LIMIT 1`).get(slug, market, sid);
if (!prev) return { slug, title: m.product.title, first: true, sizes: m.sizes.length };
const rows = (id) => new Map(db.prepare(`SELECT * FROM size_rows WHERE snap_id = ?`).all(id).map((r) => [r.size, r]));
const a = rows(prev.id), b = rows(sid);
const changes = [];
for (const [size, now] of b) {
const old = a.get(size);
if (!old) { changes.push({ size, what: "new size" }); continue; }
for (const [field, label] of [["lowest_ask", "ask"], ["highest_bid", "bid"]]) {
const o = old[field], n = now[field];
if (o == null && n != null) changes.push({ size, what: `${label} appeared`, to: n });
else if (o != null && n == null) changes.push({ size, what: `${label} gone`, from: o });
else if (o != null && n != null && Math.abs(n - o) >= minMove) changes.push({ size, what: `${label} ${n > o ? "up" : "down"}`, from: o, to: n });
}
}
return { slug, title: m.product.title, since: prev.fetched_at, changes };
}
Sizes are matched by their label ("10", "10.5"), not by position, so a size that appears or vanishes doesn't shift the rest.
Real runs, about 30 seconds apart:
$ node stockx.mjs track air-jordan-4-retro-black-cat-2025 air-jordan-4-retro-toro-bravo-2026
Jordan 4 Retro Black Cat (2025): first snapshot, 26 sizes
Jordan 4 Retro Toro Bravo (2026): first snapshot, 26 sizes
$ node stockx.mjs track air-jordan-4-retro-black-cat-2025 air-jordan-4-retro-toro-bravo-2026 --min 1
Jordan 4 Retro Black Cat (2025): no moves >= threshold since 2026-11-09T04:09:00.220Z
Jordan 4 Retro Toro Bravo (2026): no moves >= threshold since 2026-11-09T04:09:02.426Z
Nothing moved by even $1 in 30 seconds. To test the diff, I edited the newest stored snapshot by hand: I removed the size 8.5 ask and the size 9 bid, raised the size 10 ask by $25, and lowered the size 11 bid by $10. Then I ran it again against live data:
$ node stockx.mjs track air-jordan-4-retro-black-cat-2025
Jordan 4 Retro Black Cat (2025): 4 change(s) since 2026-11-09T04:09:28.413Z
{"size":"8.5","what":"ask appeared","to":283}
{"size":"9","what":"bid appeared","to":283}
{"size":"10","what":"ask down","from":307,"to":282}
{"size":"11","what":"bid up","from":255,"to":265}
All four kinds of change were detected, with the live values on the right. Run it a few times a day from cron for the products you hold, and you'll know when the bid in your size jumps.
The CLI
// ---------- CLI ----------
function flags(args) {
const out = { _: [] };
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith("--")) out[args[i].slice(2)] = args[++i];
else out._.push(args[i]);
}
return out;
}
async function main() {
const [cmd, ...rest] = process.argv.slice(2);
const f = flags(rest);
if (cmd === "probe") return probe();
if (cmd === "search") {
const { _, limit, ...opts } = f; // limit is ours, not an API param
const r = await search(_.join(" "), opts);
console.log(`${r.total} results, page ${r.page}/${r.pageCount}, ${r.currency}`);
console.log("ask bid last 72h premium title");
for (const p of r.products.slice(0, Number(f.limit ?? 10))) {
console.log(`${money(p.lowestAsk).padEnd(6)} ${money(p.highestBid).padEnd(6)} ${money(p.lastSale).padEnd(6)} ${String(p.sales72h ?? "-").padEnd(5)} ${pct(p.premium).padEnd(8)} ${p.title}${p.isAd ? " [ad]" : ""}`);
}
return;
}
if (cmd === "sizes") {
const r = await sizes(f._[0], { market: f.market, fee: Number(f.fee ?? 0.1), ship: Number(f.ship ?? 0) });
console.log(`${r.title} | style ${r.styleId} | retail ${money(r.retail)} | ${r.currency}`);
console.log(`product: lowest ask ${money(r.summary.lowestAsk)}, highest bid ${money(r.summary.highestBid)}, last sale ${money(r.summary.lastSale)}, 72h sales ${r.summary.salesLast72Hours}`);
console.log("size ask bid spread asks bids 72h 90d payout@bid vs retail");
for (const s of r.rows) {
console.log(`${s.size.padEnd(6)} ${money(s.lowestAsk).padEnd(6)} ${money(s.highestBid).padEnd(6)} ${String(s.spread ?? "-").padEnd(6)} ${String(s.asks).padEnd(4)} ${String(s.bids).padEnd(4)} ${String(s.sales72h).padEnd(3)} ${String(s.sales90d).padEnd(4)} ${money(s.payoutAtBid).padEnd(10)} ${s.vsRetail == null ? "-" : (s.vsRetail >= 0 ? "+" : "") + s.vsRetail}`);
}
return;
}
if (cmd === "history") {
const r = await history(f._[0], { range: f.range, market: f.market, size: f.size });
console.log(`${r.title} | range ${r.range} | size ${r.size ?? "all"} | ${r.points} points ${r.from} -> ${r.to}`);
console.log(`first ${money(r.first)} last ${money(r.last)} low ${money(r.low)} high ${money(r.high)} change ${pct(r.change)}`);
console.log(r.spark);
return;
}
if (cmd === "track") {
const db = openDb(process.env.SX_DB ?? "stockx.db");
for (const slug of f._) {
const r = await track(db, slug, { market: f.market, minMove: Number(f.min ?? 5) });
if (r.first) console.log(`${r.title}: first snapshot, ${r.sizes} sizes`);
else if (!r.changes.length) console.log(`${r.title}: no moves >= threshold since ${r.since}`);
else { console.log(`${r.title}: ${r.changes.length} change(s) since ${r.since}`); for (const c of r.changes) console.log(" ", JSON.stringify(c)); }
}
return;
}
console.log("usage: node stockx.mjs probe | search <query> [--sort ..] [--brand ..] | sizes <slug> [--fee 0.1 --ship 0] | history <slug> [--range 1y --size 10] | track <slug...>");
}
if (import.meta.url === `file://${process.argv[1]}`) main().catch((e) => { console.error(e.message); process.exit(1); });
export SCRAPEATLAS_API_KEY=...
node stockx.mjs probe
node stockx.mjs search jordan 4 --sort most-active
node stockx.mjs sizes air-jordan-4-retro-black-cat-2025 --fee 0.1 --ship 0
node stockx.mjs history air-jordan-4-retro-black-cat-2025 --range 6m --size 10
node stockx.mjs track air-jordan-4-retro-black-cat-2025 # from cron
A bug I hit while writing this: my first CLI passed every --flag to the API, including my own --limit, and the search failed with HTTP 400 invalid_request Provide valid StockX endpoint parameters. Strict parameter checks are good. Keep your own options out of the query string.
Pitfalls, in one place
- The terms say no. StockX's terms ban scrapers and automated access without written permission, ban competitive analysis, and restrict commercial use of their pricing data. That doesn't change because a different server makes the request.
- stockx.com returns 403 to scripts. From my server, product and search pages returned a 403 "verify you are not a bot" page with both a script and a Chrome User-Agent. Don't build a parser on top of a block page.
- The official API is gated. Approval, OAuth, a 12-hour token refresh, and an API key. If you sell on StockX, it's worth it.
- Product-level ask and bid come from different sizes. In search, a $421 bid next to a $152 ask is normal. Always go to per-size data before you compare.
- One bid is not a market. Sizes with no asks and no sales can show the product's highest bid. Weight by asks, bids, and sales counts.
-
Premium is annual.
annual.pricePremiumand "below retail right now" can disagree. - Charts are 100 sampled points. Not sales. And the 1-year chart can start before the release date.
- Fees are yours. Payout math needs your real fee rate and shipping. The 10% default is a placeholder.
-
totalCountsaid 1000 for both of my queries. At 40 per page and 25 pages, that's also all one query will give you. Narrow withbrand,category, or price filters. -
Markets differ.
market=gb,de,jp, and so on return local prices in local currency. Don't mix them in one table without converting. -
Credits add up.
sizesis 2 calls per product,trackis 1. Track the products you hold, not the catalog.
Wrapping up
- The official StockX API is for approved sellers. If that's you, start there.
- stockx.com blocks plain scripts. I got a 403 login wall on every page from a server, and the terms forbid working around it.
- Per-size data is the real data. Product-level numbers mix sizes and can look impossible.
- Spreads tell you where the market is. Sizes 8 to 12 of the Black Cat 4 had $4 to $42 spreads. The edges had hundreds.
- A small SQLite tracker is enough to see when the bid in your size moves.
Do you track resale prices per size, or just look at the product page? Tell me in the comments.
*I build it.
Top comments (0)