Wildberries is one of the largest online marketplaces in Russia. If you build price monitoring, assortment analytics or review mining for e-commerce, sooner or later a client asks for its data — and you discover there is no official English-language API documentation to point at.
The good news is that the Wildberries storefront is a JavaScript app talking to a small set of public JSON endpoints. No API key, no request signature, no session token — anywhere. Products, prices, stock, delivery estimates, full attributes, media and reviews are all reachable with plain HTTP requests.
I mapped those endpoints from a datacenter IP. The endpoint behaviour below was measured on 2026-07-15 and re-verified on 2026-07-18; the reliability numbers at the end run through 2026-07-19. Where something is a guess, I say so. Where a measurement of mine turned out to be wrong, I say that too — the last section of this post exists because my own first conclusion did not survive contact with a fourth run.
Rule zero: every price is in kopecks
Every price field in every Wildberries response is an integer in kopecks. Divide by 100.
{ "basic": 202400, "product": 23800, "logistics": 0, "return": 0, "cashback": 0 }
That is basic = 2,024 ₽ and product = 238 ₽ for the same headphones (live response, July 2026). basic is the price before discount, product is what the customer actually pays. Get this wrong and you ship a dashboard that overstates every price by 100× — and misses the discount on top.
1. Discovery: the search endpoint
https://search.wb.ru/exactmatch/ru/common/v4/search
?query=iphone
&resultset=catalog
&dest=-1257786
&curr=rub
&spp=30
&appType=1
&lang=ru
&page=1
Returns HTTP 200 with three top-level keys: metadata, products, total. metadata.rs is 100 — you get 100 products per page — and total is the size of the result set.
The interesting parameter is dest, which I cover below. resultset=catalog gets you products; page paginates.
Pagination does not end — it loops
This one is a trap, and it is the reason I am writing this section at all. Wildberries does not signal the end of a result set with an empty page. Past the last real page, it starts serving page 1 again — a full 100 products, HTTP 200, no marker of any kind.
Walking page until you get an empty response therefore never terminates. You loop forever, writing duplicates, hammering the one endpoint that is actually rate-limited.
Measured on the query ноутбук (2026-07-18): pages 2 and 3 returned distinct results, page 60 returned page 1's exact contents — same leading id.
The fix is three lines: remember the first id from page 1 and stop when you see it again.
let firstIdOfPageOne;
for (let page = 1; ; page++) {
const { products } = await search(query, page);
if (!products?.length) break;
if (page === 1) firstIdOfPageOne = products[0].id;
else if (products[0].id === firstIdOfPageOne) break; // wrapped — we are done
yield* products;
}
Do not substitute a hardcoded page cap for this. Where the wrap happens depends on the query.
Each entry in products[] carries the fields you would expect — brand, brandId, name, supplier, supplierId, supplierRating, rating / reviewRating / nmReviewRating, feedbacks / nmFeedbacks, pics, colors[], subjectId, totalQuantity — plus a sizes[] array where price and stock actually live.
The two identifiers are the thing to understand:
Field in products[]
|
What it is | What it's for |
|---|---|---|
id |
nmId — the SKU-level id |
product cards, the static CDN card, the public URL |
root |
imtId — the parent card id |
reviews, the static card's imt_id
|
I verified the second one against the static card: for nmId 1097975275 the search result gives root: 2227481309, and the CDN card for that product reports imt_id: 2227481309. Same number. Miss this and you will spend an hour wondering why the reviews endpoint returns nothing.
Extracting the price
const toRub = (k) => (typeof k === 'number' ? k / 100 : undefined);
function mapProduct(p) {
// An item can carry several size entries; take the first one that actually has a price.
const size = p.sizes?.find((s) => s.price?.product != null);
return {
nmId: p.id,
imtId: p.root,
name: p.name,
brand: p.brand,
price: toRub(size?.price.product), // final price
basicPrice: toRub(size?.price.basic), // before discount
rating: p.reviewRating ?? p.rating,
feedbacks: p.feedbacks ?? p.nmFeedbacks,
inStock: (p.totalQuantity ?? 0) > 0,
url: `https://www.wildberries.ru/catalog/${p.id}/detail.aspx`,
};
}
That .find() is not defensive programming for its own sake. Wildberries expresses "unavailable" by omission rather than by a null or an error — you will see the same habit in the batching behaviour below, where sold-out ids simply vanish from a response. Reaching straight into sizes[0].price.product assumes a shape the API never promised you. Treat a missing price as "not purchasable right now", not as an error.
2. Product detail: the card endpoint
https://card.wb.ru/cards/v4/detail?appType=1&curr=rub&dest=-1257786&spp=30&nm=1097975275
The response is products[] with the same product schema as search, plus a promotions array. If you have written a mapper for search results, it works here unchanged.
Two practical notes:
Only v4 exists. I checked the variants that older blog posts and GitHub projects still reference:
GET /cards/v1/detail -> 404
GET /cards/v2/detail -> 404
GET /cards/detail -> 404
GET u-card.wb.ru/... -> 404
GET /cards/v4/detail -> 200
If you are copying a snippet from a 2024 article, this is probably why it stopped working.
It batches. Pass several nmId values separated by ;:
&nm=863160612;1097975275;14671602
One request, many products. This is the single most useful thing in this whole post for anyone scraping at volume. One caveat: invalid or unavailable ids are silently dropped. A batch of 3 returned 2 products; a batch of 21 returned 14. Never assume response.products.length === ids.length — join the results back by id yourself, and treat an absence as data rather than as a bug.
3. Full attributes and media: the basket CDN
Search and card give you the commercial view. The full merchandising card — description, attribute list, certificate, composition, media — lives on a static CDN with a computed hostname:
https://basket-{NN}.wbbasket.ru/vol{vol}/part{part}/{nmId}/info/ru/card.json
vol = floor(nmId / 100000)
part = floor(nmId / 1000)
For nmId 1097975275 that is vol=10979, part=1097975, served by basket-42. For nmId 14671602 it is vol=146, part=14671, served by basket-02.
{NN} is assigned by a stepped lookup on vol that Wildberries extends over time (known reference points: vol=146 → basket-02, vol=8826 → basket-39, vol=10979 → basket-42). Don't hardcode that table. Probe and cache:
const hostCache = new Map(); // vol -> host number, e.g. 10979 -> "42"
async function fetchStaticCard(nmId) {
const vol = Math.floor(nmId / 100000);
const part = Math.floor(nmId / 1000);
const path = `/vol${vol}/part${part}/${nmId}/info/ru/card.json`;
const cached = hostCache.get(vol);
if (cached) {
const res = await fetch(`https://basket-${cached}.wbbasket.ru${path}`);
if (res.ok) return res.json();
hostCache.delete(vol); // shard moved — fall through and re-probe
}
for (let n = 1; n <= 60; n++) {
const nn = String(n).padStart(2, '0');
const res = await fetch(`https://basket-${nn}.wbbasket.ru${path}`);
if (res.ok) {
hostCache.set(vol, nn);
return res.json();
}
}
throw new Error(`no basket host found for nmId ${nmId}`);
}
One probe per vol bucket, then straight hits. The payload keys are imt_id, nm_id, imt_name, subj_name, vendor_code, description, options, compositions, certificate, colors, media, data and a few more; options is the attribute list as {name, value} pairs.
And again: imt_id here equals root from search.
4. Reviews
https://feedbacks1.wb.ru/feedbacks/v1/{imtId}
Note: imtId, not nmId. Take it from root in search, or imt_id in the static card.
The response gives you aggregates — valuation, valuationSum, valuationDistribution, feedbackCount, feedbackCountWithPhoto / WithText / WithVideo — and the reviews themselves in feedbacks[].
async function fetchReviews(imtId) {
const res = await fetch(`https://feedbacks1.wb.ru/feedbacks/v1/${imtId}`);
if (!res.ok) throw new Error(`feedbacks HTTP ${res.status}`);
const data = await res.json();
return (data.feedbacks ?? []).map((f) => ({
stars: f.productValuation, // 1-5
text: f.text,
pros: f.pros,
cons: f.cons,
size: f.size,
color: f.color,
date: f.createdDate,
author: f.wbUserDetails?.name,
sellerReply: f.answer?.text ?? null,
}));
}
pros and cons are separate fields from text and are frequently empty — for sentiment work you want all three. answer.text is the seller's public reply, which is a genuinely underrated signal if you are profiling sellers rather than products.
feedbacks2.wb.ru serves identical content — it's a mirror, not a shard, so don't bother splitting requests across the two for coverage.
The hard limit nobody mentions: one response returns at most 1000 reviews, and there is no pagination on this endpoint. I checked a product reporting feedbacks: 132597 in search: the endpoint returned exactly 1000 entries, the most recent ones, sorted by createdDate descending. The aggregates at the top of the response (feedbackCount, valuationDistribution) still describe all reviews — so you can compute an accurate rating distribution for a product whose individual reviews you can only sample.
If you are building review mining, design for that now: you are working with a recent-1000 window, not a full corpus. Anyone promising you "all reviews for any product" from this endpoint has not tested it on a product with six-figure review counts. If you ship this to someone else, say which of the two numbers you are handing them.
Two more things that will save you a debugging session:
-
A product with no reviews returns HTTP 200 with
feedbacks: null— not a 404, not an empty array. So does a nonexistentimtId. Guard fornullexplicitly, or every review-less product will look like a failed request. -
photosandvideobehave differently. Thephotoskey is present only on reviews that actually have photos (43 of 1000 on one product I sampled) and holds{id, key, isBlurred, isReady}entries;videois present on every review but isnullfor almost all of them (2 of 1000 on the same product). Check for presence and non-null, not for type.
One endpoint to avoid: /feedbacks/v1/summary/full?imtId=. It is referenced in various places and it returned zeroes for products that demonstrably have reviews. It looks like it works, which is worse than failing. Use /feedbacks/v1/{imtId}.
Worth noting what else rides along in that same response for free: valuationDistribution (the 1–5 star breakdown), the three feedbackCountWith* counters, and matchingSizePercentages (runs small / runs large). Those are product-level aggregates that cost you no extra request.
5. The dest parameter, and why it will bite you
dest is a warehouse/region code. It is not cosmetic: it changes what the API tells you about delivery and, on the product I measured, about price.
On 2026-07-15, nmId 1097975275 returned product: 11589400 with dest=-1257786 and product: 11417500 with dest=123585487 — the same item, 1,719 ₽ cheaper, purely from the region code. basic did not change; time2 (the delivery estimate) went from 18 to 27.
That is one product on one day, so I would not turn it into a law about how Wildberries prices everything. It is more than enough to justify the rule:
Pin one
destfor an entire run. If you rotate it, your price time series is comparing different things and you will "discover" price movements that are really just geography.
-1257786 is the Moscow default and a reasonable choice. Some values are not valid: dest=0 returns products: null, and -1 and -446488 come back empty.
Resolving a dest code from coordinates (there is a user-geo-data.wb.ru/get_geo_info endpoint) is something I could not test — the host did not resolve from my environment. Treat it as unverified.
6. Anti-bot: two independent layers
This is where most posts about Wildberries are either vague or wrong. There are two separate mechanisms, and the one people worry about is not the one that will stop you.
Layer 1 — proof-of-work (x-pow), currently not enforced
Requests to search.wb.ru and card.wb.ru come back from a gateway (server: wbaas) with a header like x-pow: status=invalid;challenge=8,8,1,.... Wildberries has published how this works: adaptive difficulty, more hashes demanded from clients that look suspicious, described there in the context of their mobile app.
Here is the part that matters: on the web JSON endpoints it was not enforced. The response arrives with status=invalid and the data is in it anyway. You do not need to compute anything or send a token back.
I would not build on the assumption that this lasts forever. If enforcement is switched on, an API-level scraper needs either a hash solver or a headless-browser fallback. It costs nothing to leave a seam in your code for that today.
Layer 1.5 — your HTTP client is part of the fingerprint
Before you blame the endpoint, check what you are calling it with. On 2026-07-18 I ran the exact same search URL two ways from the same machine, seconds apart:
// Node's built-in fetch, default headers → HTML anti-bot page, not JSON
await fetch(searchUrl);
// got-scraping → HTTP 200, JSON
await gotScraping({ url: searchUrl });
Same URL, same IP, completely different outcome. search.wb.ru fingerprints the client — TLS handshake and header ordering, not just the User-Agent string, which is why setting a browser-ish UA on plain fetch does not rescue it.
This matters more than it sounds: it is a very common way to conclude "the endpoint is dead" when it is fine. Use a client that emulates a real browser's TLS profile (got-scraping, curl-impersonate, or a real browser). Plain curl and plain fetch will mislead you.
The other hosts (card, feedbacks, basket) were far less picky in my tests — they sit behind a different stack. feedbacks1 answers with server: Angie, no x-pow, and CORS *.
Layer 2 — rate limiting (the one that actually stops you)
Only search.wb.ru is throttled, and it is aggressive. From a single datacenter IP:
- A cold first request can return 429 immediately, before you have sent anything else.
- 30 sequential requests over 69 seconds: 13 × 200, 17 × 429.
- 50 parallel requests at roughly 3.8 RPS: 30 × 200, 20 × 429.
The 429 body is HTML, and there is no Retry-After header — the response carries little more than date and server: wbaas. Your backoff has to be heuristic. The upside, at least initially: it behaves as a throttle rather than an outright ban, and a retry a few seconds after a 429 returned 200.
And there is a quieter failure mode than 429. Sometimes search answers HTTP 200 with a different response shape — instead of {metadata, products, total} you get {state, version, params, data: {products: [...]}} carrying unrelated content. No error code, no header to tell you apart.
That one is more dangerous than a 429, because a parser looking for a top-level products key finds nothing, returns zero results, and your pagination loop reads that as "the result set ended". You get a silently truncated dataset that looks like a clean run. Validate the shape of the response, not just the status code, and treat a shape mismatch as a retryable failure — after a backoff, the same URL returned normal data.
The other hosts were not throttled in my tests:
| Host | Test | Result |
|---|---|---|
search.wb.ru |
50 parallel | 30 × 200, 20 × 429 |
card.wb.ru/v4 |
30 parallel | 30 × 200 |
feedbacks1.wb.ru |
30 parallel | 30 × 200 |
basket-42.wbbasket.ru |
40 parallel | 40 × 200 |
The limit is per service, and only one service is expensive. That single fact should shape your whole design.
7. What four reliability runs taught me, including where I was wrong
I want to be careful here, because this is the section where I initially drew a conclusion that later measurements destroyed.
I ran the same workload — 50 runs against live endpoints, one datacenter IP, no proxies — four times, changing the pacing settings once and then leaving them alone.
| # | Settings | Success rate | Failures | p50 latency |
|---|---|---|---|---|
| 1 | 250 ms min delay, no retries | 91.2% | 2 × 429, 4 × substituted response | 473 ms |
| 2 | 500 ms min delay + 2 retries (1.5 s / 3 s) | 98.75% | 1 × substituted response | 471 ms |
| 3 | same settings, later the same day | 90.14% | 7 × substituted, zero 429 | 1700 ms |
| 4 | same settings, next day after ~12 h idle | 77.94% | 13 × 429, 2 × substituted; 15/50 runs empty; 48 retries | 7532 ms |
After run 2, the obvious story was: the failures are pacing artefacts; slow down, retry transient errors, and you get 98.75%. I believed that. It is also the kind of number that ends up in a README.
Run 3 was the same code with the same settings a few hours later: 90.14%, and the p50 had already more than tripled. Run 4, after leaving the IP completely idle overnight, was worse again — 77.94%, with mass 429s back and a p50 sixteen times the "fresh" figure.
So two of my own conclusions were wrong:
- "It's fixed by pacing and retries" — refuted by run 3. Pacing and retries are necessary. They are not sufficient.
-
"The penalty decays if you let the IP rest" — refuted by run 4. Roughly twelve hours of idling produced not recovery but further degradation. Whatever
search.wb.ruis tracking against that IP outlives a night, or the IP has landed on a reputation list.
The honest caveats: this is one IP, four measurements, at different times of day. I cannot fully separate "accumulated penalty" from "time-of-day load". But monotonic degradation through a rest period is hard to explain any other way.
Three practical consequences:
- 98.75% is a best case on a fresh IP, not a steady state. Quoting it as your service level would be a lie with a spreadsheet attached.
- Rotating IPs on the discovery host is a precondition, not an optimization — if you want a reliability figure above 95% that holds. The other three hosts aren't throttled, so this applies to a small fraction of your traffic.
- Stop benchmarking from a burned IP. Once your address is penalised, your success rate measures the depth of your own penalty, not the target's behaviour — and each run digs deeper. The next honest number has to come from a fresh address.
That third point is the one I would most like to have known earlier.
Putting it together
The architecture that falls out of the measurements is: expensive discovery, cheap enrichment.
- Use
search v4only to discover ids — collectid(nmId) androot(imtId), paginate withpage, 100 per page. Rate-limit yourself here, and if you need volume, this is the only place proxies are worth paying for. - Enrich with
card v4in batches of manynmIdper request. Not throttled. - Fetch reviews from
feedbacks1/v1/{imtId}using therootyou already have. Not throttled. - Pull attributes and media from the basket CDN. Not throttled, no gateway in front of it.
Nothing in that pipeline needs a browser. No JS rendering, no proof-of-work solving, no login. It is HTTP and JSON.parse, which means it is cheap to run and boring to maintain — the two properties you actually want in a scraper. What it does not make cheap is the discovery step, and that is where your budget goes.
What I still don't know
In the spirit of not pretending:
- The RPS threshold for 429 on a genuinely fresh, unpenalised IP. My numbers are a lower bound measured from an IP that had already been throttled.
- How long the penalty on
search.wb.ruactually lasts. I know it survives twelve hours. I don't know whether it is days, or permanent for that address. - The complete
vol→basket-NNmapping. It drifts; probing is the correct answer. - How to resolve a
destcode from coordinates, and the full list of valid values. - What the opaque
qvandkcltokens inmetadataare for. Results come back fine without them; my guess is deep pagination, unverified. - What exactly triggers the substituted-200 response, and whether the boundary where pagination wraps is a fixed result-count cap or query-dependent. I only know how to detect both, not what drives them.
- Whether and when Wildberries starts enforcing proof-of-work on web endpoints.
If you have measured any of these — particularly the penalty duration, which I can no longer test cleanly from this address — I would genuinely like to compare notes.
Top comments (0)