DEV Community

Viktor
Viktor

Posted on

Vinted Scraper in Go: Search, Prices, and the Walls You'll Hit (2026)

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:

  1. Searches Vinted for a keyword on a chosen country domain
  2. Pulls out item id, title, brand, size, price, and seller
  3. Pages through results politely
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Fetch the HTML once to get cookies into your client jar (use net/http/cookiejar).
  2. Replay the catalog API URL with the same jar and headers.
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Where DIY falls apart

The script above is fine for a few pages a day from a home connection. Past that you'll hit:

  1. 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.
  2. Country domains. Inventory, currency, and anti-bot behavior differ per host. A scraper tuned on vinted.fr will mis-parse prices on vinted.co.uk.
  3. Internal API drift. Catalog JSON field names and required cookies change. Budget time for fixtures and alerts when a page returns zero items.
  4. 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.
  5. 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)
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)