Whatnot is where a lot of trading cards, comics, Funko Pops, and sneakers get sold now, live, one auction at a time. If you sell there, or you're studying the market, you want numbers: which sellers move the most items, who is live right now, how many people are watching, and what a card goes for when it's listed.
The first thing most people search is "Whatnot API". There is one, but it probably isn't the one you want. So this post covers both: what the official Whatnot API gives you, and how to build a small Whatnot scraper in Node.js for the public data it doesn't cover.
The scraper is one file, no npm dependencies, Node 18+. It will:
- Discover seller handles from the sitemap Whatnot publishes in
robots.txt - Read a seller's profile: followers, items sold, rating, reviews, live status
- List a seller's live and scheduled shows, and their shop listings
- Run a search and read a category feed
- Read a single listing with price, condition, and seller
- Snapshot live viewers in a category every few minutes into a CSV
Everything below was run on October 28, 2026, and the output is real.
Before you start: Whatnot's Terms of Service prohibit scraping, crawling, and other automated access to the app.
robots.txtallows the public pages used here, but robots rules and terms are different things. Treat this as a learning project or small-scale research, keep request rates very low, don't resell the data, and don't build profiles of individual buyers or sellers. If you're a seller who wants your own data, use the official API first.
Is there a Whatnot API?
Yes. Whatnot has an official Seller API (REST, https://api.whatnot.com/seller-api/rest/v2, docs at developers.whatnot.com). You create a personal access token in the developer dashboard and send it as Authorization: Bearer wn_pat_tk_.... It covers products, inventory, orders, shipments, your shows, and show analytics.
The catch is in the name. The Whatnot API is for your own store. It answers "what did I sell and what do I need to ship", not "how many followers does that seller have" or "who is live in Pokémon cards right now". There is no official public endpoint for other sellers, search, or category feeds.
So, a quick decision table:
| You want | Use |
|---|---|
| Your own orders, inventory, show analytics | Official Seller API |
| Other sellers' public stats, live shows, search results, listing prices | The public website, which is what this Whatnot scraper reads |
How whatnot.com looks from a scraper's point of view
Three findings shaped the code.
1. robots.txt is friendly and points to sitemaps. It allows everything except account pages, the player, and a few others (/account/, /dashboard, /messages, /order/, /player, /video/, ...). It also lists seller-sitemap.txt, users-sitemap-index.xml, and listings-sitemap-index.xml. That's your discovery layer.
2. The data is already in the HTML. whatnot.com is a Next.js app with Apollo GraphQL. When the server renders a page, it embeds the GraphQL results it used in two places:
-
(window[Symbol.for("ApolloSSRDataTransport")] ??= []).push({...})script tags. These hold the query results for feeds: search, category, a seller's shows, a seller's shop. -
self.__next_f.push([1,"..."])script tags. That's the React Server Components payload, and it carries the seller profile and the listing details.
So you don't need a headless browser, and you don't need to call the GraphQL endpoint yourself. The browser's GraphQL calls go to /services/graphql, which is protected by Kasada bot detection. Leave it alone. The HTML is enough.
3. Cloudflare sits in front, and it cares about honesty. This surprised me. From Node's built-in fetch, a request that pretends to be Chrome gets Cloudflare's "Just a moment..." challenge every time. A request with an honest, descriptive User-Agent gets a normal 200:
round user-agent status bytes challenge? 0 Chrome 130 string 403 5768 true 0 whatnot-research-script 200 830788 false 0 (none, Node default) 403 5533 true 0 curl/8.5.0 403 5533 true 1 Chrome 130 string 403 5768 true 1 whatnot-research-script 200 830827 false My guess is that a Chrome User-Agent with a non-Chrome TLS fingerprint looks like a disguised bot, while a script that says it's a script looks like what it is. That might change, but "say who you are" is good practice anyway.
Step 1: A polite fetch helper
One request every 3 to 4 seconds, an honest User-Agent, 404 treated as "gone", and a hard back-off when Cloudflare shows up.
// whatnot.mjs - a small Whatnot scraper in Node.js (18+), no dependencies. // Reads only public, robots-allowed pages: sitemaps, /user/, /tag/, /search, /listing/. import fs from "node:fs"; const BASE = "https://www.whatnot.com"; // Say who you are. A fake Chrome UA from Node gets Cloudflare's challenge (see "Pitfalls"). const UA = process.env.WHATNOT_UA ?? "whatnot-research-script/1.0 (+mailto:you@example.com)"; const MIN_DELAY_MS = 3000; // one request every ~3 s is plenty for this kind of job // ---------- Step 1: a polite fetch helper ---------- let lastRequest = 0; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export class BlockedError extends Error {} export async function get(url, { tries = 3 } = {}) { for (let attempt = 1; attempt <= tries; attempt++) { const wait = lastRequest + MIN_DELAY_MS + Math.random() * 1000 - Date.now(); if (wait > 0) await sleep(wait); lastRequest = Date.now(); const res = await fetch(url, { headers: { "user-agent": UA, "accept-language": "en-US,en;q=0.9", accept: "text/html,*/*" }, redirect: "follow", }); const body = await res.text(); if (res.status === 404) return null; // Cloudflare's interstitial: 403 + "Just a moment..." (or a challenge platform script) if (res.status === 403 && /Just a moment|challenge-platform/.test(body)) { if (attempt === tries) throw new BlockedError(`Cloudflare challenge on ${url}`); await sleep(30_000 * attempt); // back off hard, don't hammer continue; } if (res.status === 429 || res.status >= 500) { await sleep(5_000 * attempt); continue; } if (!res.ok) throw new Error(`${res.status} on ${url}`); return body; } throw new Error(`gave up on ${url}`); } BlockedError matters. If you see a challenge after a long back-off, stop. Don't rotate User-Agents or proxies to get around it.
Step 2: Read the data the page already ships
Two small parsers. The Apollo blocks are JavaScript object literals, not strict JSON: they contain undefined, so we swap it for null before JSON.parse. The RSC payload is a series of JSON strings that we concatenate. Then we pull out typed objects by looking for {"__typename":"..." and slicing a balanced {...}.
// ---------- Step 2: get the data the page already ships ---------- // (a) Apollo SSR transport: the GraphQL results the server rendered with. // They are JS object literals (they contain `undefined`), not strict JSON. const APOLLO = '(window[Symbol.for("ApolloSSRDataTransport")] ??= []).push('; export function apolloResults(html) { const out = []; let i = 0; while ((i = html.indexOf(APOLLO, i)) !== -1) { const start = i + APOLLO.length; const end = html.indexOf(")</script>", start); const chunk = JSON.parse(html.slice(start, end).replace(/:undefined\b/g, ":null")); for (const v of Object.values(chunk.rehydrate ?? {})) if (v?.data) out.push(v.data); for (const ev of chunk.events ?? []) if (ev.type === "next" && ev.value?.data) out.push(ev.value.data); i = end; } return out; } // (b) React Server Components payload: self.__next_f.push([1,"..."]) strings. export function rscText(html) { const re = /self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)/g; let m, text = ""; while ((m = re.exec(html))) text += JSON.parse(m[1]); return text; } // Pull one balanced {...} object out of a text blob, starting at `from`. function sliceObject(text, from) { let depth = 0, inStr = false, esc = false; for (let i = from; i < text.length; i++) { const c = text[i]; if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; } else if (c === '"') inStr = true; else if (c === "{") depth++; else if (c === "}" && --depth === 0) return text.slice(from, i + 1); } return null; } // Every object in the RSC text that starts with {"__typename":"<type>" export function rscObjects(text, typename) { const needle = `{"__typename":"${typename}"`; const out = []; let i = 0; while ((i = text.indexOf(needle, i)) !== -1) { const raw = sliceObject(text, i); if (raw) { try { out.push(JSON.parse(raw)); } catch { /* skip partial chunks */ } } i += needle.length; } return out; } The brace slicer is a bit crude, but the payload is machine-generated JSON, so a string-aware depth counter is enough. If a slice fails to parse, we skip it.
Step 3: Find sellers from the sitemap
seller-sitemap.txt is a plain list of profile URLs. On October 28, 2026 it had 7,740 handles.
// ---------- Step 3: discover sellers from the sitemap robots.txt points to ---------- export async function sellerHandles() { const txt = await get(`${BASE}/seller-sitemap.txt`); return txt .split(/\r?\n/) .map((l) => l.trim().match(/\/user\/([^/?#]+)$/)?.[1]) .filter(Boolean); } $ node whatnot.mjs sellers 5 { "count": 7740, "sample": ["001spades", "1099comics", "10k_sportscards", "112comics", "123readycomics"] } The sitemap is stale in places. In two random samples, 4 of 14 handles returned 404 (renamed or closed accounts), including 10k_sportscards from the sample above. That's normal. Treat 404 as data, not as an error.
Step 4: Seller profile and shows
A seller page renders whichever tab the seller picked as default. Some open on Shows, others on Shop, and the Apollo data changes with it (GetUserLiveStreams vs ProfileShop). Requesting /user/{handle}/shows forces the shows tab, so the data is always the same shape. The profile object (followers, sold count, rating) is in the RSC payload on every tab.
// ---------- Step 4: seller profile + shows from /user/{handle}/shows ---------- const money = (m) => (m ? { amount: m.amount / 100, currency: m.currency } : null); // amounts are in cents function cleanShow(s) { return { id: s.id, title: s.title, status: s.status, // PLAYING = live now, CREATED = scheduled start_time: s.startTime ? new Date(s.startTime).toISOString() : null, active_viewers: s.activeViewers ?? null, bookmarks: s.totalWatchlistUsers ?? null, seller: s.user?.username ?? null, categories: (s.livestreamCategories ?? []).map((c) => c.label).join("|"), tags: (s.tags ?? []).map((t) => t.label).join("|"), url: `${BASE}/live/${s.id}`, }; } export async function seller(handle) { // Plain /user/{handle} renders whichever tab the seller picked (shows or shop). // /shows forces the shows tab, so the Apollo data is always GetUserLiveStreams. const html = await get(`${BASE}/user/${encodeURIComponent(handle)}/shows`); if (!html) return null; // 404: renamed, banned, or deleted const user = rscObjects(rscText(html), "PublicUserNode").find( (u) => u.username?.toLowerCase() === handle.toLowerCase() && "followerCount" in u ); if (!user) throw new Error(`no profile object on /user/${handle} (layout changed?)`); const streams = apolloResults(html).find((d) => d.getUser?.livestreams)?.getUser.livestreams; return { handle: user.username, id: Buffer.from(user.id, "base64").toString().split(":")[1] ?? user.id, // "PublicUserNode:190554" display_name: user.displayName?.trim() || null, bio: user.bio || null, followers: user.followerCount, following: user.followingCount, sold: user.soldCount, // null when the seller hides it / has none rating: user.sellerRating?.overall ?? null, reviews: user.sellerRating?.numReviews ?? 0, avg_ship_days: user.averageShipDays, is_live: user.isLive, verified: user.isVerifiedSeller, premier: !!user.premierShopStatus?.isPremierShop, shows_total: streams?.totalCount ?? null, shows: (streams?.edges ?? []).map((e) => cleanShow(e.node)), // live + scheduled, first 15 }; } Two things in there are easy to miss:
- IDs are base64 Relay IDs.
UHVibGljVXNlck5vZGU6MTkwNTU0decodes toPublicUserNode:190554. Keep the numeric part. It doesn't change when a seller renames their account, and the handle does. - Money is in cents.
{"amount": 5500, "currency": "USD"}is $55.00. There's also alocalizedPriceconverted to your currency based on your IP (I got VND from Vietnam). Always storeprice, neverlocalizedPrice.
A seller who was live while I ran this:
$ node whatnot.mjs seller hititfromthepack { "handle": "hititfromthepack", "id": "25944732", "display_name": "HitItFromThePack", "followers": 16358, "following": 128, "sold": 4407, "rating": 4.9, "reviews": 753, "avg_ship_days": 1, "is_live": true, "verified": false, "premier": true, "shows_total": 7, "shows": [ { "id": "359ac220-1a62-435d-9e2a-50c00ab85c4c", "title": "🇺🇸 MY BIGGEST $1 START STREAM EVER! $100K+ MID ERA", "status": "PLAYING", "start_time": "2026-10-28T02:02:51.692Z", "active_viewers": 158, "bookmarks": 95, "categories": "Pokémon Cards", "tags": "$1 Starts|Sudden Death|Vintage", ... }, { "id": "a1854a1a-e238-421c-b057-99162b365ae5", "status": "CREATED", "start_time": "2026-10-29T19:38:00.000Z", "active_viewers": 0, "bookmarks": 58, ... } ] } PLAYING means live now, CREATED means scheduled. The seven shows were one live and six upcoming, so the profile shows live and scheduled streams, not the archive. bookmarks (totalWatchlistUsers) on a scheduled show is a nice early signal of how big that show will be.
And a random sample from the sitemap, via node whatnot.mjs sellers-csv 6:
handle,followers,sold,rating,reviews,avg_ship_days,is_live,premier,shows_total sundazebest,7839,13691,4.9,2878,,false,false,0 comicselitelive,25574,91897,5,17060,0,true,false,51 king_espeo,197,117,5,43,,false,false,0 bfunko_collectibles,1262,414,4.8,149,,false,false,0 (The other two of the six were 404s.) sold is lifetime items sold, which is the most useful single number for sizing a seller. avg_ship_days is often null.
The seller's shop
Many sellers also keep buy-it-now and auction listings in a shop. /user/{handle}/shop ships the first 20 of them:
export async function shop(handle) { const html = await get(`${BASE}/user/${encodeURIComponent(handle)}/shop`); if (!html) return null; const feed = apolloResults(html).find((d) => d.getUser?.searchShop)?.getUser.searchShop; return flattenFeed(feed).listings; // first 20 listings } $ node whatnot.mjs shop bangarangcards [ { "title": "Typhoid Mary", "price": 5, "currency": "USD", "type": "BUY_IT_NOW", "status": "ACTIVE", ... }, { "title": "Thena", "price": 15, "currency": "USD", "type": "BUY_IT_NOW", "status": "ACTIVE", ... }, ... ] Step 5: Search and category feeds
Search (/search?query=...) and category ...
