Vinted is one of the biggest second-hand fashion marketplaces in Europe, and its public catalog is useful for price tracking, brand research, and spotting what actually sells in a niche. There is no official public product API for third-party tools, so most people end up scraping the HTML or reverse-engineering the same JSON the website uses.
This guide builds a small Go scraper that:
- Searches Vinted for a keyword on a chosen country domain
- Pulls out item id, title, brand, size, price, and seller
- Pages through results politely
- Stores snapshots in SQLite so you can track prices over time
I'll also be honest about where the DIY approach breaks.
Legal note: Only scrape publicly visible data, keep your request rate low, and read Vinted's terms for the country you target. If you need this data for a commercial product at scale, assume you'll need proxies and ongoing maintenance — or an API that already handles them.
How Vinted search is structured
A search URL looks like this:
https://www.vinted.fr/catalog?search_text=nike+zoom&page=1 Country matters. vinted.fr, vinted.de, vinted.co.uk, vinted.pl, and vinted.com are separate marketplaces with different inventories and currencies. Don't assume a listing on .fr exists on .com.
The page is heavily client-rendered. The HTML shell is mostly empty; the useful data arrives as JSON from Vinted's internal catalog API. You can either:
- Parse that internal JSON (fragile: paths and auth headers change)
- Or render the page in a browser and scrape the DOM (slow, still fingerprintable)
For a small DIY tool, listening to the catalog JSON from a headless browser is more reliable than hand-rolling their signed requests. For a pure-Go path without a browser, people often replay the catalog endpoint with careful headers — until it breaks. We'll start with the pure-Go approach against the public catalog URL and fall back honestly when it fails.
Setup
mkdir vinted-scraper && cd vinted-scraper go mod init vinted-scraper # no third-party deps for the basic version Step 1: Fetch a search page without looking like a bot on day one
At minimum, send a real browser User-Agent, an Accept-Language that matches the domain, and reuse one http.Client so cookies persist:
package main import ( "fmt" "io" "math/rand" "net/http" "net/url" "strings" "time" ) var client = &http.Client{Timeout: 25 * time.Second} func headers(domain string) http.Header { h := make(http.Header) h.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36") h.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") h.Set("Accept-Language", langFor(domain)) h.Set("Connection", "keep-alive") return h } func langFor(domain string) string { switch domain { case "fr": return "fr-FR,fr;q=0.9,en;q=0.8" case "de": return "de-DE,de;q=0.9,en;q=0.8" case "co.uk": return "en-GB,en;q=0.9" case "pl": return "pl-PL,pl;q=0.9,en;q=0.8" default: return "en-US,en;q=0.9" } } func fetch(rawURL string, domain string) ([]byte, error) { var last error for attempt := 0; attempt < 3; attempt++ { req, err := http.NewRequest(http.MethodGet, rawURL, nil) if err != nil { return nil, err } req.Header = headers(domain) resp, err := client.Do(req) if err != nil { last = err } else { body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { last = readErr } else if resp.StatusCode == 403 || resp.StatusCode == 429 || resp.StatusCode == 503 { last = fmt.Errorf("blocked: HTTP %d", resp.StatusCode) } else if resp.StatusCode >= 400 { last = fmt.Errorf("HTTP %d", resp.StatusCode) } else if looksLikeChallenge(body) { last = fmt.Errorf("challenge / captcha page") } else { return body, nil } } wait := time.Duration(5*(1<<attempt))*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond time.Sleep(wait) } return nil, last } func looksLikeChallenge(body []byte) bool { s := strings.ToLower(string(body)) return strings.Contains(s, "captcha") || strings.Contains(s, "cf-challenge") || strings.Contains(s, "access denied") } A 200 with a Cloudflare challenge page is still a failure. Check the body, not only the status code.
Step 2: Prefer the JSON the site already loads
When you open a catalog page in DevTools, you'll see requests to something like /api/v2/catalog/items. That payload is cleaner than the DOM. The hard part is that those calls often need cookies and anti-bot tokens minted by the first HTML load.
A practical DIY pattern in Go:
- Fetch the HTML once to get cookies into your client jar (use
net/http/cookiejar). - Replay the catalog API URL with the same jar and headers.
- Parse the JSON into your own structs.
type CatalogResponse struct { Items []CatalogItem `json:"items"` Pagination struct { CurrentPage int `json:"current_page"` TotalPages int `json:"total_pages"` } `json:"pagination"` } type CatalogItem struct { ID int64 `json:"id"` Title string `json:"title"` URL string `json:"url"` Price string `json:"price"` // sometimes a string; sometimes nested — normalize below Brand struct { Title string `json:"title"` } `json:"brand"` SizeTitle string `json:"size_title"` User struct { ID int64 `json:"id"` Login string `json:"login"` } `json:"user"` Photo struct { URL string `json:"url"` } `json:"photo"` } type Item struct { ID int64 Title string Brand string Size string Price float64 URL string Seller string } Field names and nesting change. Keep a saved fixture from a successful response and a unit test that fails loudly when Vinted renames size_title again.
Price parsing is locale-dependent ("12,50 €" vs "£12.50"). Don't ParseFloat the raw string — strip currency symbols and normalize commas first.
Step 3: Paginate politely across country domains
func search(domain, query string, maxPages int) ([]Item, error) { var out []Item seen := map[int64]bool{} for page := 1; page <= maxPages; page++ { u := url.URL{ Scheme: "https", Host: "www.vinted." + domain, Path: "/catalog", } q := u.Query() q.Set("search_text", query) q.Set("page", fmt.Sprintf("%d", page)) u.RawQuery = q.Encode() body, err := fetch(u.String(), domain) if err != nil { return out, fmt.Errorf("page %d: %w", page, err) } items, err := parseCatalog(body, domain) // your JSON or HTML parser if err != nil { return out, err } if len(items) == 0 { break } for _, it := range items { if !seen[it.ID] { seen[it.ID] = true out = append(out, it) } } fmt.Printf("page %d: %d items\n", page, len(items)) time.Sleep(time.Duration(3+rand.Intn(5)) * time.Second) } return out, nil } Random delays of a few seconds matter more than clever headers. Burst traffic from one datacenter IP is the fastest way onto a challenge wall.
Step 4: Store snapshots for price history
A single scrape is rarely useful. SQLite is enough:
import ( "database/sql" "time" _ "modernc.org/sqlite" // or github.com/mattn/go-sqlite3 ) func save(db *sql.DB, keyword string, items []Item) error { _, err := db.Exec(`CREATE TABLE IF NOT EXISTS snapshots ( id INTEGER, keyword TEXT, title TEXT, brand TEXT, size TEXT, price REAL, url TEXT, seller TEXT, scraped_at TEXT )`) if err != nil { return err } now := time.Now().UTC().Format(time.RFC3339) tx, err := db.Begin() if err != nil { return err } stmt, err := tx.Prepare(`INSERT INTO snapshots VALUES (?,?,?,?,?,?,?,?,?)`) if err != nil { return err } defer stmt.Close() for _, it := range items { if _, err := stmt.Exec(it.ID, keyword, it.Title, it.Brand, it.Size, it.Price, it.URL, it.Seller, now); err != nil { tx.Rollback() return err } } return tx.Commit() } Run it on a cron and you can ask:
SELECT id, date(scraped_at) AS day, MIN(price) AS price FROM snapshots WHERE keyword = 'nike zoom' AND brand = 'Nike' GROUP BY id, day ORDER BY id, day; Where DIY falls apart
The script above is fine for a few pages a day from a home connection. Past that you'll hit:
- Cloudflare / bot challenges at volume. From AWS, GCP, or a cheap VPS you often get blocked on the first request. Residential proxies cost real money and still need care.
- Country domains. Inventory, currency, and anti-bot behavior differ per host. A scraper tuned on
vinted.frwill mis-parse prices onvinted.co.uk. - Internal API drift. Catalog JSON field names and required cookies change. Budget time for fixtures and alerts when a page returns zero items.
- Sold / reserved items. Listings disappear or flip to reserved quickly in popular niches. If you only store the latest scrape, you lose the price history that made the project worth doing.
- Headless browsers aren't free wins. Playwright helps with client-rendered pages but is slower, heavier, and still fingerprinted.
Option: use an API for the hard part
If you'd rather not maintain proxies and selectors, a scraping API can return the same catalog as JSON. For example, ScrapeAtlas* has a Vinted search endpoint that takes the query, domain, and page:
func searchAPI(apiKey, query, domain string, page int) ([]Item, error) { u, _ := url.Parse("https://api.scrapeatlas.com/v1/vinted/search") q := u.Query() q.Set("query", query) q.Set("domain", domain) // fr, de, co.uk, com, ... q.Set("page", fmt.Sprintf("%d", page)) q.Set("per_page", "24") u.RawQuery = q.Encode() req, _ := http.NewRequest(http.MethodGet, u.String(), nil) req.Header.Set("x-api-key", apiKey) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // Decode their JSON into your Item slice; check status for // "failed" / "challenged" / "partial" the same way you would // for any upstream that can be rate-limited. return decodeScrapeAtlas(resp.Body) } There are also endpoints for a single item by id/URL, a member profile, and a member's wardrobe. domain selects which marketplace the data was read from. That output can feed the same save() function, so you can swap fetchers without changing storage.
Wrapping up
- For small, occasional scrapes: Go
net/http+ careful headers + slow delays can work from a residential IP. - For daily tracking of a few keywords: add a cookie jar, response fixtures, zero-result alerts, and per-domain locale parsing.
- For anything at scale or from cloud servers: plan for residential proxies and ongoing breakage, or an API that absorbs that cost.
Whichever route you take, store snapshots instead of overwriting rows. On Vinted the listing that was €40 yesterday may be gone tomorrow — the history is the product.
Questions or a selector that broke? Leave a comment and I'll update the guide.
*I build it.
