Skip to main content

Complete Guide: How to Scrape Wallapop Spain Using Wallapop Scraper

Run Wallapop Scraper from Python for prices, towns, distance and shipping across Spain, then search five Spanish marketplaces in one call.

Updated 12 min readMarkdown
Complete Guide: How to Scrape Wallapop Spain Using Wallapop Scraper

This complete guide to Wallapop Scraper shows how to scrape Wallapop, the Spanish second-hand marketplace, from Python through the Apify API, and get back one typed row per listing: title, asking price in EUR, town, region and postcode, distance from a point you choose, whether the seller posts, Wallapop's reserved, refurbished and warranty flags, and the seller's own text.

Two things make a home-made Wallapop crawler awkward. First, Wallapop searches around a coordinate, not a country: one search from Madrid does not show a Bilbao listing that does not ship. Second, one search feed returns at most 40 listings and has no paging. The Actor answers both by sweeping several origins and sort orders and merging the results.

The second half covers Wallapop + Vinted + Milanuncios Scraper, an Apify Actor that runs one keyword on five Spanish marketplaces and returns one table. Prerequisites are minimal: Python 3.11+, apify-client and an Apify API token. The sample row below is a sample from a capture dated 2026-08-27, not output of this code.


TL;DR

In this tutorial, you'll learn to run the Wallapop Scraper Apify Actor from Python, choose its two search passes and filters, and read its rows; then run the same keyword on Wallapop, Vinted, Milanuncios, Cash Converters and eBay Spain in one call. Key steps: install apify-client, export APIFY_API_TOKEN, set searchQueries in run_input, call reventadata/wallapop-scraper, read run.default_dataset_id.

Expected outcome: a JSON list of Wallapop listings with price, town, distance and shipping, ready for a spreadsheet or database. The Actor is at apify.com/reventadata/wallapop-scraper.


Prerequisites and setup

You need:

  1. Python 3.11+ (apify-client 3.3.0 requires it).
  2. An Apify account. Apify is a cloud platform for running scrapers, called Actors; Apify Console is its web interface, where runs, datasets and settings live.
  3. An API token, from the API & Integrations page in Apify Console. Apify's advice is to give a token a description and never use one token for several services.

Install the client:

pip install "apify-client~=3.0"

Export the token so the code can read it:

export APIFY_API_TOKEN="your_apify_token_here"

Everything below runs from a terminal and one Python file. The Apify documentation covers Actors, datasets and schedules in depth.


Understanding Wallapop Scraper

What it does and how it works

Wallapop Scraper calls Wallapop's own JSON search endpoint, the same one the website uses. It runs two passes. Pass 1 (nearby) searches from one point (default Madrid, 40.4168, -3.7038) and keeps every listing within radiusKm (default 100), whether or not the seller posts, asking twice with two sort orders. Pass 2 (shipping) searches from 12 city origins and, by default, keeps only sellers who post. A listing found twice is saved once.

The radius, the price band, the shipping requirement and the condition filter go to Wallapop in the request. Keyword, damage, accessory and battery checks run on the rows.

Limitations

  • No paging: coverage grows with origins, sort orders, conditions and keywords, never with page depth.
  • Wallapop's keyword search is fuzzy: the README notes that a search for iphone 13 mini also returns iPhone 8s, iPhone 11s and multi-model shop adverts. requireKeywordMatch exists for that.
  • Battery health is whatever the seller typed. The README calls it a shortlist, not a fact.
  • Wallapop rows carry no listing id or condition field; the url identifies the listing.

Step-by-step walkthrough

Step 1: Access the Actor

Open the Wallapop Scraper page on Apify Store. Its README lists every input and output field. Everything after this step happens in Python.

searchQueries is the one required input: one search per keyword, any product or category. Every keyword is repeated around every origin. This configuration runs one keyword through both default passes and saves at most 20 rows:

import os
from apify_client import ApifyClient

# 1. Initialize ApifyClient with the token from the environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])

# 2. One keyword, both default passes, at most 20 rows
run_input = {
    "searchQueries": ["iphone 11 pro max"],
    "maxItems": 20,
}

Two more configurations, written as run_input dictionaries:

  • Local pickup only, Barcelona: {"searchQueries": ["bicicleta montaña"], "nearLatitude": 41.3851, "nearLongitude": 2.1734, "radiusKm": 30, "searchAllSpainWithShipping": false}. Pass 2 is off, so every row is within 30 km.
  • National price research: {"searchQueries": ["nintendo switch"], "searchNearby": false, "requireShipping": false, "minPrice": 50}. Pass 2 only, sellers who post and those who do not; sellerAllowsShipping tells you which is which.

Best practice: keep the query short. Every word of it must appear in the title, so iphone 11 pro max 256gb drops listings that only mention the capacity in the description.

Step 3: Advanced configuration

Parameter Default Where it applies
minPrice / maxPrice 1 / none Wallapop, in the request.
radiusKm 100 Wallapop, re-checked on each row's coordinates.
requireShipping true Wallapop, pass 2.
conditions any Wallapop: un_opened, in_box, new, as_good_as_new, good, fair, has_given_it_all. One value per request.
cities all 12 Pass 2 origins.
orderBy most_relevance Pass 2 sort; newest narrows the feed to a recent window.
requireKeywordMatch true Rows: every query word in the title, ignoring case and accents.
excludeDamaged true Rows: drops "pantalla rota", "grietas", "no enciende", "para piezas", iCloud-locked and similar; "sin grietas" stays.
maxItems 500 Cap on rows for the whole run.

A value outside the conditions, cities or orderBy lists is refused before the run starts.

Step 4: Optional phone-mode features

Three switches, all off by default, exist for phone searches: excludeAccessories drops cases, protectors, spare parts and repair adverts by title (a phone listed "+ funda" is kept); minBattery sets a battery-health floor read from the seller's Spanish text, and allowUnknownBattery keeps listings that never state one; benchmarkAgainstShop makes one extra request per keyword to Cash Converters and adds shopPriceFrom, shopPriceMedian, shopCheapestGrade, shopUnitsSeen, priceGapVsShop and percentOfShopPrice. The gap is measured against the shop median, and the lookup never removes a listing. Leave all three off elsewhere: the README notes that pantalla is also a television and funda a duvet cover.

Step 5: Run the Actor

# 3. Start the run and wait for it to finish
run = client.actor("reventadata/wallapop-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
    status = run.status if run else "unknown"
    raise SystemExit(f"Run did not succeed (status: {status}).")

# 4. Fetch the saved listings
items = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(items)} listings.\n")

call() starts the run and waits for it to finish before returning. With one keyword and no conditions, the Actor reads 2 feeds in pass 1 (one origin, two sort orders) and 12 in pass 2; each conditions entry multiplies that. The log prints the planned feed count before each pass.

Step 6: Examine the results

for item in items:
    print(f"{item['price']} {item['currency']}  {item['title']}")
    print(f"  {item.get('city')}, {item.get('region')} ({item['stage']} pass)")
    if item.get("distanceKm") is not None:
        print(f"  {item['distanceKm']} km from the near point")
    print(f"  Seller posts: {item['sellerAllowsShipping']}  Reserved: {item['reserved']}")
    print(f"  {item['url']}")

Step 7: Download and export

Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL. Over the API, add ?format=csv (or json, jsonl, html, xlsx, xml, rss) to the dataset items URL.


Working with the API: five marketplaces in one call

Wallapop + Vinted + Milanuncios Scraper (reventadata/wallapop-vinted-milanuncios-scraper) fetches nothing itself. It calls five published single-site Actors (Wallapop Scraper, plus the Vinted, Milanuncios, Cash Converters and eBay Spain scrapers), reads their datasets and maps each row onto shared fields. Its inputs: searchQueries (required), sites (all five by default), minPrice, maxPrice and maxItemsPerSite (default 100).

# 5. The same keyword on five Spanish marketplaces, one table
spain_input = {
    "searchQueries": ["iphone 11 pro max"],
    "sites": ["wallapop", "vinted", "milanuncios", "cashconverters", "ebay"],
    "maxItemsPerSite": 5,
}
spain_run = client.actor("reventadata/wallapop-vinted-milanuncios-scraper").call(
    run_input=spain_input
)
if spain_run is None or spain_run.status != "SUCCEEDED":
    status = spain_run.status if spain_run else "unknown"
    raise SystemExit(f"Run did not succeed (status: {status}).")

for row in client.dataset(spain_run.default_dataset_id).list_items().items:
    print(f"{row['sourceSite']:<15} {row['price']} {row['currency']}  {row['title']}")

Authentication is the same token. Each marketplace runs as its own Actor run. The parent starts at most four marketplace runs at a time, so five sites run in two waves. If your plan's concurrent-run limit is the bottleneck for your own calls, call() takes wait_for_resources, which retries the start while the account lacks the memory or a concurrent-run slot.

For event-driven pipelines, add a webhook on ACTOR.RUN.SUCCEEDED: its payload's resource is the Get Actor run response, which carries defaultDatasetId.


Advanced usage patterns

Batch processing multiple queries

Put several keywords in searchQueries. Each keyword runs on every origin, and a listing kept under one keyword is not saved again under another. A listing rejected under one keyword stays eligible for the next: Wallapop's fuzzy search often returns it again where it does match. Keywords multiply feeds: two keywords with default settings read 4 feeds in pass 1 and 24 in pass 2.

Scheduled runs

Save the input as a task and add an Apify schedule. Schedules use cron expressions; 0 8 * * * is every day at 8 AM. The README suggests a daily schedule with the dataset sorted by createdAt to see what is new.

Monitoring

Check run.status after every call. A Wallapop run whose every feed failed ends FAILED; a run where nothing matched ends SUCCEEDED with a warning in the log. In the combined Actor, a marketplace whose run failed adds no rows, and the status message names it.

Volume control

maxItems caps a Wallapop run; maxItemsPerSite caps each marketplace in the combined run. Fewer cities and fewer conditions mean fewer feeds. Each conditions entry adds one feed per origin, so three conditions triple the requests.


Understanding your data

A sample from a capture dated 2026-08-27: a Wallapop Scraper row whose keyword was iphone 11 pro max, mapped by the multi-marketplace Actor. A Wallapop row from the current version also carries description and createdAt, which this capture does not show:

{
    "sourceSite": "wallapop",
    "query": "iphone 11 pro max",
    "title": "iphone 11 pro MAX",
    "price": 280,
    "priceBasis": "price",
    "currency": "EUR",
    "url": "https://es.wallapop.com/item/iphone-11-pro-max-1083680444",
    "condition": null,
    "id": null,
    "imageUrl": "https://cdn.wallapop.com/images/10420/hx/71/__/c10420p1083680444/i5321040760.jpg",
    "sellerType": null,
    "category": null,
    "raw": {
        "stage": "nearby",
        "batteryHealth": 88,
        "batteryReplaced": false,
        "screenReplaced": false,
        "price": 280,
        "currency": "EUR",
        "distanceKm": 1.2,
        "sellerAllowsShipping": true,
        "title": "iphone 11 pro MAX",
        "city": "Madrid",
        "region": "Comunidad de Madrid",
        "postalCode": "28014",
        "reserved": false,
        "isRefurbished": false,
        "hasWarranty": false,
        "matchedKeyword": "iphone 11 pro max",
        "url": "https://es.wallapop.com/item/iphone-11-pro-max-1083680444",
        "imageUrl": "https://cdn.wallapop.com/images/10420/hx/71/__/c10420p1083680444/i5321040760.jpg"
    }
}

raw.stage is nearby (pass 1); raw.distanceKm is 1.2 km from the near point. raw.batteryHealth is 88, as the seller typed it; batteryReplaced and screenReplaced are false, meaning not stated. In the combined fields, condition, id, sellerType and category are null because Wallapop publishes none of them. On Vinted, price is the total with the buyer protection fee: a captured Vinted row of 143 EUR plus a 7.85 fee maps to 150.85 with priceBasis: "totalPrice".


Troubleshooting guide

  • FAILED with "Bad input": searchQueries had no non-empty keyword, or nearLatitude, nearLongitude or radiusKm was not a finite number.
  • FAILED with "Every feed failed - the endpoint contract probably changed.": no feed came back. The README says Wallapop's endpoint rejects requests missing the X-DeviceOS header, which the Actor always sends; in testing on 2026-08-20 a direct request with it got HTTP 200 and one without it 403. Read each feed's warning line in the log.
  • SUCCEEDED, zero rows: the log warns Widen the radius, loosen minBattery, raise maxPrice, ....
  • Unexpected rows: turn excludeAccessories on only for phones; keep requireKeywordMatch on to drop fuzzy matches.
  • Reading logs: the last summary line is Done. N listings considered, X nearby + Y shippable pushed.; from Python, client.log(run.id).get().

For help, use the Actor's Store page.


Best practices

  • Set minPrice above zero. The schema's note: Wallapop's cheapest results are phone cases and repair-service adverts.
  • Prefer server-side filters. Radius, price, shipping and condition go to Wallapop, so each feed's 40 slots go to listings that qualify.
  • Leave orderBy on relevance unless you are watching for new adverts on a schedule.
  • Treat seller text as claims. Battery health and replaced-part flags come from what the seller wrote.
  • Pick the right Actor. The combined Actor passes neutral settings to Wallapop, with damage and accessory filters off; call Wallapop Scraper directly for radius or battery filters.
  • Start small. Keep maxItems: 20 until your downstream code handles the fields.

Pricing

For current pricing and details, see the Store page.


Conclusion and next steps

You ran Wallapop Scraper from Python across two search passes, read its rows, and searched five Spanish marketplaces in one call. Next, schedule the search, add a webhook, or narrow it with conditions.

Try Wallapop Scraper yourself and let me know what you build!

About the author: reventadata builds and maintains Apify Actors for second-hand marketplaces.


Resources

About the author

  • Hi, I’m the engineer behind ReventaData. I build automated web scrapers, reverse-engineer undocumented APIs, and design data pipelines on Apify that turn complex, unstructured websites into clean, reliable feeds. Here on Hashnode, I share practical tutorials on modern web scraping with Python, bypassing aggressive anti-bot protections (Cloudflare, Akamai, PerimeterX), and data pipeline automation, reverse-engineering hidden endpoints, and automating data extraction. Explore our turnkey scrapers: https://apify.com/reventadata

No comments yet. Be the first to comment.

More from this blog