DEV Community

Devil Scrapes
Devil Scrapes

Posted on

Vinted returns an empty page to scrapers — here's the JSON behind it

Quick answer

Vinted's catalogue is served by a JSON API behind a session-cookie handshake — the listings aren't in the initial HTML, so DOM scraping returns an empty shell. The Vinted Listings Scraper performs the handshake, walks the catalogue endpoint, and emits exactly 13 validated fields per listing at $0.0025 per item ($2.50 per 1,000) plus a $0.01 run start.

Why the obvious approach returns nothing 🕳️

Open a Vinted catalogue page, view source, and search for a product title. You won't find it.

Vinted is a client-rendered app. The HTML you get from a plain HTTP request is a shell — layout, scripts, and an empty mount point. The listings arrive afterwards, from a JSON API that the page calls once it has bootstrapped. If you point BeautifulSoup at the initial response you get zero results and no error, which sends most people straight to a headless browser.

A headless browser works. It's also slow, expensive, and unnecessary here — because the JSON endpoint the app calls is reachable directly. The catch is that it isn't reachable naively: it expects session cookies established through a prior handshake, and without them it returns an authorization failure rather than data.

So the real work is: establish the session the way the app does, then read the same JSON the app reads. That's dramatically faster and cheaper than driving a browser, and it gives you cleaner data — you're reading the API's own typed values instead of re-parsing rendered text back into numbers.

What we handle 🛡️

The session handshake. Runs establish and maintain the cookie state the catalogue endpoint requires, and re-establish it when it expires mid-run rather than failing the remainder of the pagination.

Browser-grade TLS fingerprinting. Requests go out through curl-cffi, impersonating a real browser's TLS and HTTP/2 fingerprint. Vinted is not the most aggressive target we work with, but header-only spoofing is a known-weak approach and we don't rely on it anywhere.

Proxy routing. Requests distribute across Apify's proxy infrastructure rather than hammering from one address.

Typed extraction at the source. price is a Decimal parsed from items[].price.amount — not a float, and not a string with a currency symbol glued on. If you're doing any arithmetic on resale prices, the difference between Decimal and float is the difference between correct totals and cent-level drift across 10,000 rows.

Two fields worth reading the fine print on 📋

We document the awkward ones rather than burying them, because both have burned people using other tools.

status_condition is a condition label, not a sold flag. It carries values like "Good" or "Very good" — the seller's description of wear. It does not tell you whether an item sold. Vinted's public catalogue surface doesn't expose sold status, and any tool implying otherwise is inferring it. If you need sold-price data, that's a different collection problem on a different surface.

created_at is always null in v1. There is no confirmed public source for listing-creation time on the catalogue endpoint. We ship the field as nullable and documented rather than filling it with the scrape timestamp, which would look like data and be a lie. Use scraped_at when you need to know when we saw it.

Full output schema 📦

Thirteen fields, extra="forbid" — the schema is enforced:

Field Type Notes
item_id int Vinted's own numeric item ID
title string Listing title
brand string | null When set by the seller
size string | null When set by the seller
price Decimal From items[].price.amount
currency string ISO 4217 code
status_condition string | null Condition label — not a sold flag
url string Canonical item URL
photo_url string | null Primary listing photo
seller_login string | null Seller username
favourite_count int Users who favourited the listing
created_at string | null Always null in v1 — no confirmed public source
scraped_at string ISO-8601 UTC, when we recorded it

brand, size, and status_condition are seller-supplied and genuinely optional on Vinted — they're nullable because sellers skip them, not because our parser is unsure.

What people build with this

Resale price intelligence. Track asking prices by brand and size over time. favourite_count is the closest available proxy for demand on a listing that hasn't sold — a high favourite count on an aging listing usually means the price is wrong, not that interest is absent.

Brand monitoring for fashion labels. Secondhand volume and asking-price distribution are a real signal about a brand's market position, and one that first-party retail data can't show you.

Sourcing for resellers. Filter by brand and condition, sort by price, and find the underpriced listings before they move.

Academic and market research on circular commerce. Vinted is one of the largest secondhand marketplaces in Europe, and the catalogue is a usable window into consumption patterns.

Frequently asked questions

What does 20,000 listings cost?
$50.01 — 20,000 × $0.0025, plus the $0.01 run start.

Can I get sold prices?
Not from this Actor. The public catalogue surface exposes active listings; status_condition describes wear, not sale status. We'd rather say that plainly than let you build a pricing model on a misread field.

Does it need a Vinted account?
No. The session handshake is the anonymous one the public web app performs — no login, no credentials.

Why Decimal for price instead of float?
Because float arithmetic on currency accumulates error, and at 10,000+ rows that error becomes visible in aggregate figures. Decimal is the correct type for money and it costs nothing to use it.

Which Vinted domains are supported?
Vinted operates per-country domains with a shared API shape. currency ships on every row so multi-market datasets stay unambiguous when merged.

Try it

Live on the Apify Store: Vinted Listings Scraper.

Pay-per-event — you pay for rows written, not for runtime.


Built by Devil Scrapes — we build scrapers for the targets that fight back.

Top comments (0)