How to Scrape StockX with Node.js: Product Search, Size-Level Bids and Asks, Price History, and a Resale Tracker (2026)

2026-11-09来源 dev.to JavaScriptEnglish

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:

  1. probe checks what stockx.com and the official API return to a script
  2. search runs a product search with sort and filters
  3. sizes shows every size's ask, bid, spread, and what you'd be paid at the bid
  4. history summarizes the price chart for a product or one size
  5. track saves 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:

  1. Create a StockX account and verify your email.
  2. Fill in the developer sign-up form and wait for an approval email.
  3. Get your API key from the portal and create an app for a client ID and secret.
  4. Do the OAuth 2.0 authorization-code flow (accounts.stockx.com), and refresh the access token every 12 hours.
  5. Send Authorization: Bearer <token> and x-api-key on every call to https://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 $...

原文:https://www.a8q9.cn/en/article/how-to-scrape-stockx-with-node-js-product-search-size-level-bids-and-asks-price--76dbcf.html

相关文章