How to Scrape Vinted Listings
Scrape Vinted listings across vinted.fr, .de and .co.uk: the internal JSON API, cookie bootstrapping, per-market catalogue IDs, and GDPR-safe resale analytics.

To scrape Vinted listings you have to solve three problems most marketplace scrapers never face at once: there is no single vinted.com to point at, the catalogue is served by an internal JSON API that refuses anonymous callers, and every listing is posted by a private individual rather than a business. That last one changes what you are allowed to store, not just how you fetch it. This guide covers the per-country domain structure, the /api/v2 endpoints the web app actually calls, how to bootstrap a session the API accepts, why catalogue IDs and currencies cannot be shared across markets, and how to build a resale dataset that answers sell-through and price-by-brand questions without ever recording who the seller was.
The privacy boundary you have to design around
Say this part first, because it constrains the schema, and the schema constrains everything downstream.
Vinted is consumer-to-consumer. Every listing you see was posted by a private individual, usually in an EU member state, on a platform operated by Vinted UAB in Lithuania. Under the GDPR, information relating to an identified or identifiable natural person is personal data, and Article 4(1) carves out no exception for data the person chose to publish. Recital 26 pushes the same way: if a username, an avatar, a city, and a listing history can be combined to single out one human, that combination is personal data whether or not you scraped it from a public page. A Vinted profile is exactly that combination.
So the line runs through the record, not through the request:
- Collect item and price attributes. Title, brand, size, condition, price, currency, category, photo count, favourite count, first-seen and last-seen timestamps. These describe the object being sold.
- Do not collect seller identities. No user id, no username, no display name, no avatar URL, no profile link, no location string, no feedback score, no "member since" date. Drop them at parse time, not at report time.
- Do not build per-seller profiles. Ranking sellers by volume, tracking one person's inventory over months, or inferring someone's income from their listings is exactly the profiling that turns market research into a data protection problem.
- Aggregate is the defensible use. "Nike trainers in size 42, very good condition, listed in France in Q2 at a median of 34 EUR" says nothing about any individual, and it is the kind of statistical output a legitimate-interests assessment under Article 6(1)(f) can realistically survive.
Hashing the seller id does not fix this. A salted hash is pseudonymisation, which the GDPR still treats as personal data, and it still lets you build per-seller time series. If you need to stop one person's bulk relist from skewing a median, dedupe on a content fingerprint instead: hash (brand, size, condition, price, first_photo_url) and collapse near-identical records inside a short window. You get the deduplication without ever holding an identifier.
The rest of this guide assumes that boundary. The example schema has no seller column, and none of the queries need one.
Vinted is many marketplaces, not one site
There is no global Vinted catalogue. Each country runs on its own domain with its own inventory, its own currency, its own shipping options, and its own localized category titles. An item listed on vinted.pl does not appear on vinted.fr. Scrape one domain and call the result "the Vinted market" and you have measured one country.
Here is the shape of it as of August 2026. Treat the list as a starting point and confirm the live set from the country switcher in the site footer, because Vinted keeps adding markets:
| Domain | Market | Currency | Notes |
|---|---|---|---|
| vinted.lt | Lithuania | EUR | The original market, launched 2008 |
| vinted.fr | France | EUR | One of the largest by listing volume |
| vinted.de | Germany | EUR | High volume, strong in womenswear |
| vinted.co.uk | United Kingdom | GBP | Separate currency, separate size conventions |
| vinted.es | Spain | EUR | |
| vinted.it | Italy | EUR | |
| vinted.nl / vinted.be | Netherlands / Belgium | EUR | Belgium serves both FR and NL locales |
| vinted.pl | Poland | PLN | Non-euro, large volume |
| vinted.cz / vinted.sk | Czechia / Slovakia | CZK / EUR | Adjacent markets, different currencies |
| vinted.se / vinted.dk / vinted.fi | Sweden / Denmark / Finland | SEK / DKK / EUR | |
| vinted.com | United States | USD | Newest and thinnest catalogue |
Three consequences for the crawler design:
- Your unit of work is (domain, category), not (category). Every job needs the domain baked into its key, and every row you store needs a
marketcolumn. - Geo matters at the network layer. Requesting vinted.fr from a US exit IP is not the same session as requesting it from a French one. Vinted localizes shipping options and sometimes price presentation by region, and mismatched geo is a bot signal in its own right. Pin the exit country to the domain.
- Never mix currencies in one price column. More on that below, because it is the most common way a resale dataset quietly becomes wrong.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
The internal JSON API behind the catalogue
Vinted's web app is a JavaScript front end that talks to a versioned JSON API on the same host. You do not need to parse listing cards out of rendered HTML. Open DevTools on any catalogue page, filter the Network tab to XHR, and you will see requests to /api/v2/... returning exactly the objects the cards are built from.
The endpoints that matter for listing collection, all relative to the market domain:
| Endpoint | Returns | Use |
|---|---|---|
| `/api/v2/catalog/items` | Paged array of catalogue items | The main listing feed |
| `/api/v2/catalog/initializers` | Category tree, brands, sizes, filter options | Resolving catalogue IDs per market |
| `/api/v2/items/{id}` | Full detail for one item | Current state, description, view count |
| `/api/v2/users/{id}/items` | One seller's inventory | Skip it, see the privacy section |
The catalogue endpoint takes query parameters that mirror the on-site filters:
GET /api/v2/catalog/items
?page=1
&per_page=96
&catalog_ids=1904
&order=newest_first
&brand_ids=53
&status_ids=6,1
&price_from=10
&price_to=60
¤cy=EUR
order=newest_first is the one you want for a monitoring crawl, because it makes the feed behave like a change log: keep a high-water mark on item id or creation timestamp and stop paging once you cross it. Relevance ordering is unstable between requests and will have you re-fetching the same items forever.
Two limits to plan around. Paging is capped in practice, so a query matching hundreds of thousands of items will not let you walk to the end of it. The fix is to slice the query space rather than page deeper: split by catalogue id, then by brand id, then by price band, until each slice returns a walkable number of pages. That general pattern, plus how to find these endpoints on any site, is covered in how to scrape hidden JSON API endpoints.
Bootstrapping a session Vinted will accept
Call /api/v2/catalog/items cold with curl and you get a 401. The API is not open. It expects the cookies a real browser picks up on its first visit to the domain, and Vinted sits behind DataDome, which scores the request before the API ever sees it.
The bootstrap sequence a browser performs, and the one you have to reproduce:
GET https://www.vinted.fr/with a full browser header set.- The response carries
Set-Cookiefor the session cookie (named per market, for example_vinted_fr_session), an anonymous id cookie, and adatadomecookie. - Subsequent
/api/v2calls go out with those cookies, plusAccept: application/jsonand a same-originReferer.
Miss the cookies and you get 401. Send cookies that were issued to a different exit IP than the one you are now calling from and you get challenged, because DataDome binds its cookie to signals that include the network path. Two rules follow:
- One session equals one exit IP. Bootstrap and harvest through the same egress. Rotating mid-session is what turns a working scraper into an intermittent one.
- Re-bootstrap on 401 or 403, do not retry the same cookies. A stale session cookie will fail forever. A fresh homepage visit fixes it in one request.
The cleanest approach is to let a real headless browser perform steps 1 and 2, then reuse the cookie jar for the cheap JSON calls. For the deeper background on why cookie scope and same-site rules break naive scrapers, see handling cookies and sessions in web scraping. For the anti-bot layer specifically, bypassing DataDome explains what its cookie is actually measuring.
Set up the SparkProxy Scraping API
Rather than running Chromium, a proxy pool, and a DataDome workaround yourself, route both steps through the SparkProxy Scraping API. Base URL and auth, from the scraping API docs:
import requests
API = "https://scrape.sparkproxy.io/api/v1"
KEY = "YOUR_API_KEY" # sk-xxxxxxxxxxxxxxxx from app.sparkproxy.io
r = requests.get(
API,
headers={"X-API-Key": KEY},
params={
"url": "https://www.vinted.fr/",
"render_js": "true",
"country_code": "FR", # pin the exit to the market
"premium_proxy": "true",
"wait_for_and_click": "#onetrust-accept-btn-handler", # consent banner
"tag": "vinted/fr/bootstrap",
},
timeout=120,
)
print(r.status_code, len(r.text))
That single call renders the homepage from a French residential IP, dismisses the consent banner, and gives Vinted's front end the chance to set its cookies. render_js=true costs 5 credits on the rotating pool and 25 on the premium pool, and country_code adds 5. The JSON calls that follow need no browser, so they run at render_js=false for 1 credit (10 on premium), which is where the cost of a large crawl actually lives.
For stubborn markets add stealth=true, which layers a homepage pre-warm, a forced Google referrer, and longer idle delays on top of the default fingerprint randomisation. It requires render_js=true and adds 5 credits.
Fetch a page of catalogue items
With a session in hand, the harvest loop is plain JSON. Fold the captured cookies into forward_headers for the cheap fetches, or pass them through the cookies parameter when you need a browser:
import json, requests
from urllib.parse import urlencode
API = "https://scrape.sparkproxy.io/api/v1"
KEY = "YOUR_API_KEY"
class SessionExpired(Exception):
pass
def catalog_page(host, cookie_header, page, catalog_id, per_page=96):
target = f"https://{host}/api/v2/catalog/items?" + urlencode({
"page": page,
"per_page": per_page,
"catalog_ids": catalog_id,
"order": "newest_first",
})
fwd = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "fr-FR,fr;q=0.9",
"Referer": f"https://{host}/catalog?catalog[]={catalog_id}",
"Cookie": cookie_header,
"X-Requested-With": "XMLHttpRequest",
}
r = requests.get(
API,
headers={"X-API-Key": KEY},
params={
"url": target,
"render_js": "false", # 1 credit, 3x faster
"country_code": "FR",
"premium_proxy": "true",
"forward_headers": json.dumps(fwd),
"transparent_status_code": "true", # surface Vinted's own 401/429
"tag": "vinted/fr/catalog",
},
timeout=90,
)
if r.status_code in (401, 403):
raise SessionExpired(host)
return r.json()
transparent_status_code=true is the detail that saves hours of debugging. Without it, plain HTTP mode returns 200 with a challenge body in it and your parser silently writes zero rows. With it, Vinted's own 401 or 429 reaches your code, so you can re-bootstrap instead of recording a fake empty page.
A trimmed response looks like this:
{
"items": [
{
"id": 4821993017,
"title": "Veste en jean oversize",
"price": { "amount": "24.00", "currency_code": "EUR" },
"brand_title": "Levi's",
"size_title": "M",
"status": "Tres bon etat",
"url": "https://www.vinted.fr/items/4821993017-veste-en-jean-oversize",
"favourite_count": 7,
"photo": { "url": "https://images1.vinted.net/..." },
"user": { "id": 12345678, "login": "..." }
}
],
"pagination": { "current_page": 1, "total_pages": 152, "per_page": 96 }
}
Field names shift between API revisions, so assert on the ones you depend on rather than trusting them. And note the user object sitting right there in the payload. That is the field the next sections throw away.
Resolve catalogue IDs per market
Do not hardcode a catalogue id from vinted.fr and reuse it on vinted.pl. The category trees are maintained per market: the depth differs, some markets carry categories others do not, and localized titles obviously differ. A numeric id that means "Women > Shoes > Trainers" on one domain is not guaranteed to mean the same on another, and a silent mismatch produces a dataset where you think you compared trainers across four countries and actually compared trainers to handbags.
Resolve them at runtime instead. The initializers endpoint returns the tree for whichever domain you call it on:
def catalog_tree(host, cookie_header):
data = fetch_json(host, "/api/v2/catalog/initializers", cookie_header)
return data["dtos"]["catalogs"] # nested: each node has id, title, code, catalogs[]
def walk(nodes, predicate, path=()):
"""Depth-first search for category nodes matching a predicate."""
for node in nodes:
here = path + (node["title"],)
if predicate(node, here):
yield node["id"], " > ".join(here)
yield from walk(node.get("catalogs") or [], predicate, here)
# Prefer the stable 'code' field over localized titles where one is present
trainers = list(walk(
catalog_tree("www.vinted.fr", COOKIES),
lambda n, p: (n.get("code") or "").endswith("trainers"),
))
Build the mapping once per market, store it in a catalog_map(market, canonical_key, vinted_catalog_id, local_title) table, and key every crawl job off canonical_key. Match on the node code where one exists, since codes are more stable across markets than localized titles, and fall back to a hand-checked title mapping. Re-run the resolution monthly and diff it. Category trees change, and a job that suddenly returns zero items is far more often a moved id than a block.
This is the piece most Vinted tutorials skip entirely, and it is the difference between one multi-country dataset and four incompatible ones.
The seller-free listing schema
Here is the parse step, and it is where the privacy boundary becomes code. Note that user is never read:
import hashlib
from datetime import datetime, timezone
CONDITION_MAP = { # per-market label -> canonical enum
"Neuf avec etiquette": "new_with_tags",
"Neu mit Etikett": "new_with_tags",
"New with tags": "new_with_tags",
"Tres bon etat": "very_good",
"Sehr gut": "very_good",
"Very good": "very_good",
}
def to_record(item, market):
price = item.get("price") or {}
amount = float(price.get("amount", 0))
photo = (item.get("photo") or {}).get("url") or ""
fingerprint = hashlib.sha256("|".join([
market,
(item.get("brand_title") or "").lower(),
(item.get("size_title") or "").lower(),
f"{amount:.2f}",
photo.split("?")[0],
]).encode()).hexdigest()[:20]
now = datetime.now(timezone.utc).isoformat()
return {
"listing_id": item["id"], # an item, not a person
"market": market, # 'fr', 'de', 'pl' ...
"title": item.get("title"),
"brand": item.get("brand_title"),
"size_label": item.get("size_title"),
"condition_raw": item.get("status"),
"condition": CONDITION_MAP.get(item.get("status"), "unknown"),
"price_amount": amount,
"price_currency": price.get("currency_code"),
"favourite_count": item.get("favourite_count"),
"item_fingerprint": fingerprint,
"first_seen": now,
"last_seen": now,
# deliberately absent: user id, login, avatar, city, feedback, profile url
}
The fields, and why each one is in or out:
| Field | Keep | Reason |
|---|---|---|
| `id`, `title`, `brand_title`, `size_title` | Yes | Attributes of the object for sale |
| `price.amount` plus `price.currency_code` | Yes | The measurement, always stored as a pair |
| `status` (condition) | Yes | The second-biggest driver of resale price after brand |
| `favourite_count` | Yes | Demand proxy, only ever aggregated |
| `photo.url` | As a reference | Do not rehost or redistribute seller photographs |
| Free-text description | Case by case | Sellers write personal details into it, redact first |
| `user.*` (id, login, avatar, city, feedback) | No | Personal data about a private individual |
The description field deserves the caution. People put their first name, their social handle, and occasionally their address into listing descriptions. If you need the text for defect or measurement extraction, run a redaction pass before storage and keep only the derived flags, never the raw string.
Marketplaces where the seller is usually a business give you far more latitude on the seller dimension, which is why the approach in scraping eBay listings and scraping Etsy product data differs from this one. A pure C2C classifieds site like Craigslist sits on the same side of the line as Vinted. Design for the stricter case.
Currency, size systems, and other per-market traps
Store price_amount and price_currency as a pair, in the same row, always. If you need a comparable series, add two more columns, price_eur and fx_rate_date, converted at the rate for the day the listing was seen rather than at query time. A dataset converted lazily at read time silently rewrites its own history every time you rerun the report.
Two more per-market traps, each worth a guard clause:
- Size labels are not comparable strings. A UK shoe size 8 on vinted.co.uk and an EU 42 on vinted.fr are the same foot, and
size_titlewill not tell you that. Keep the raw label and add asize_normalizedcolumn, populated from a per-market conversion table for the categories you actually analyse. - Condition labels are localized, and the set is small. Map them to a canonical enum on ingest, as in the code above. Doing it at query time means rewriting the same
CASE WHENin every report, and getting it slightly different each time.
Measuring sell-through rate
Vinted publishes no sold feed, and the catalogue endpoint returns live listings only. Sell-through therefore has to be measured as cohort attrition: capture a set of listing ids on day zero, re-check each id on a schedule, and record when it leaves the catalogue.
def check_state(host, cookie_header, listing_id):
"""Returns 'live', 'gone', or 'unknown' for one listing."""
r = fetch_raw(host, f"/api/v2/items/{listing_id}", cookie_header)
if r.status_code in (404, 410):
return "gone"
if r.status_code != 200:
return "unknown" # 429 or challenge: retry later, record nothing
item = (r.json() or {}).get("item") or {}
if item.get("is_closed") or item.get("is_hidden"):
return "gone"
return "live"
Now the honest caveat, and it is what separates a usable metric from a misleading one: disappearance conflates sold with delisted. A listing vanishes when it sells, when the seller pulls it, when it is edited and republished under a new id, and when moderation removes it. Treat cohort attrition as an upper bound on sell-through, and say so in the output.
You can tighten it two ways. Cross-check a sample against the item detail state, which distinguishes a closed listing from a hard 404. And compare attrition curves instead of absolute levels: if size 38 dresses attrit at 61 percent in 30 days while size 46 attrits at 34 percent, the ratio is informative even when neither number is a clean sale rate.
Cohort design that keeps the re-check bill affordable:
- Sample rather than tracking everything. A few thousand listings per (market, category, brand) cohort gives stable weekly numbers.
- Re-check on a decaying schedule: day 1, 3, 7, 14, 30. Most attrition happens in the first week.
- Freeze the cohort. Adding new listings mid-window turns a survival curve into noise.
- Run re-checks at
render_js=false. A 5,000-item cohort on that five-point schedule is 25,000 calls, which is exactly why you sample.
Price by brand and condition, and seasonality
Once the table exists, the three questions resale operators actually ask fall out of it, and none of them need a seller column.
Price by brand and condition
Condition is the second axis, and skipping it is why naive brand medians look random:
SELECT brand,
condition,
COUNT(*) AS listings,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_eur) AS median_eur,
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY price_eur) AS p90_eur
FROM vinted_listings
WHERE market = 'fr'
AND category_key = 'women.shoes.trainers'
AND first_seen >= NOW() - INTERVAL '90 days'
GROUP BY brand, condition
HAVING COUNT(*) >= 50
ORDER BY listings DESC;
The HAVING COUNT(*) >= 50 is not decoration. Thin brand-and-condition cells produce medians that swing wildly week to week, and a volume floor is also the simplest guard against a single seller's bulk inventory dominating a cell.
Condition premium
Divide the new_with_tags median by the very_good median for each brand. Across apparel the premium is real but nowhere near uniform, and the brands where it is widest are the ones worth sourcing deadstock for. This number only exists if you canonicalised condition on ingest, which is the whole argument for doing it there.
Seasonality
Join listing volume and median price by week, keeping market in the grouping:
SELECT DATE_TRUNC('week', first_seen) AS wk,
market,
category_key,
COUNT(*) AS new_listings,
AVG(price_eur) AS avg_price
FROM vinted_listings
WHERE market IN ('fr', 'de', 'pl')
GROUP BY 1, 2, 3
ORDER BY 1;
Second-hand apparel shows two seasonality effects that new-goods retail does not. Supply spikes on the wardrobe-clearing cycle, heaviest at the turn of each season, which pushes prices down at exactly the moment the category becomes relevant. And cross-market timing differs: a Nordic market and a Mediterranean one hit their coat peak weeks apart. That is visible only because you kept market on every row, so model the curves per market instead of averaging Europe into one line.
Scaling without getting blocked
Concurrency discipline, not clever headers, is what keeps a Vinted crawl alive:
import time, random
from concurrent.futures import ThreadPoolExecutor
def harvest(host, catalog_id, cookie_header, max_pages=25):
rows, page = [], 1
market = host.rsplit(".", 1)[-1]
while page <= max_pages:
try:
data = catalog_page(host, cookie_header, page, catalog_id)
except SessionExpired:
cookie_header = bootstrap(host) # fresh homepage visit
continue
items = data.get("items") or []
if not items:
break
rows.extend(to_record(i, market) for i in items)
if page >= (data.get("pagination") or {}).get("total_pages", 1):
break
page += 1
time.sleep(random.uniform(1.5, 4.0)) # jitter, not a fixed delay
return rows
with ThreadPoolExecutor(max_workers=4) as pool: # per market, not global
results = list(pool.map(lambda c: harvest(HOST, c, COOKIES), CATALOG_IDS))
The rules behind that code:
- Cap concurrency per market, not per crawler. Four in-flight requests against one domain is a sane ceiling. Twenty spread across five domains is fine. Twenty against vinted.fr is not.
- Jitter every delay. A fixed sleep is a fingerprint by itself.
- Back off on 429, re-bootstrap on 401. Different failures, different handlers. Keep SparkProxy's own 429 (rate or concurrency limit, with
retry_after_secondsin the body) separate from Vinted's. - Crawl off-peak for the target's timezone. A French crawl at 04:00 CET competes with far less real traffic than one at 20:00.
- Watch cost, not only success rate. The rendered bootstrap is 25 credits on premium, the JSON call is 10. Bootstrap once per session and amortise it across hundreds of catalogue pages instead of rendering every request.
If your success rate drifts down gradually rather than falling off a cliff, the cause is usually IP reputation rather than your headers. Log the ratio of 401 to 429 to 530 per market and per exit country, because those three numbers point at three different fixes.
Frequently asked questions
FAQ
Accessing public listing pages is rarely the sticking point; what you store is. Vinted's Terms of Use restrict automated collection, and because listings come from private individuals in the EU, the GDPR applies to any seller data you retain. Collect item attributes only, drop seller identifiers at parse time, and get legal counsel before shipping a commercial product built on it.
No. Vinted publishes no public developer API for catalogue data. The /api/v2 endpoints its web app calls are internal and undocumented, so anything built on them needs schema assertions and monitoring rather than a hardcoded parser that assumes field names never move.
Because the API expects the cookies a browser receives on its first visit to the market domain, including the session cookie and a DataDome cookie. Fetch the homepage first with a real browser, reuse that cookie jar for the JSON calls, and keep both on the same exit IP.
Do not assume so. Each market maintains its own category tree with its own ids and localized titles, so resolve ids per domain from /api/v2/catalog/initializers, store a mapping keyed by your own canonical category, and re-check it monthly.
Track a frozen cohort of listing ids and re-check each one on a decaying schedule, recording when it leaves the catalogue. Disappearance mixes sold with delisted, so publish it as an upper bound and compare attrition curves between segments rather than quoting a single absolute number.
The session bootstrap needs a rendered page, so 5 credits on the rotating pool or 25 on premium, plus 5 for country_code. Every catalogue and item call after that runs at render_js=false for 1 credit, or 10 on premium, so the bootstrap cost amortises across the whole session.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

How to Scrape Zomato and Swiggy Data (Menus and Prices)
Scrape Zomato and Swiggy data that means something: pin the delivery coordinates, key every row by restaurant, pin and timestamp, and split the fee stack.

How to Scrape Yandex Search Results in 2026
Scrape Yandex search results reliably: lr region codes, Cyrillic queries, SmartCaptcha detection, the official XML API, and working SparkProxy code.

How to Scrape TikTok Public Data With Proxies
Scrape TikTok public data with proxies: read the hydration JSON blob, use the Research API and oEmbed, detect fake 200s, and cut credits per good page.
