By the end of this tutorial, you will have a short Python script that turns a Wallapop search into structured rows: title, asking price in EUR, town and postcode, distance from a point you choose, whether the seller posts, and the seller's own text.

Wallapop is a Spanish second-hand marketplace. Its search works around a coordinate, not a country, so one search from Madrid does not show a Bilbao listing that does not ship. We will cover that with Wallapop Scraper (reventadata/wallapop-scraper) on Apify, then run the same keyword on five Spanish marketplaces at once with Wallapop + Vinted + Milanuncios Scraper (reventadata/wallapop-vinted-milanuncios-scraper).

The JSON row shown below is a sample from a capture dated 2026-08-27, not output of this code.

⏱️ Time estimate: one sitting: install one package, set one token, run four short Python blocks.

Prerequisites

To follow this tutorial, you will need:

  1. Python 3.11+ (apify-client 3.3.0 requires it).
  2. The Apify API client for Python.
  3. An Apify account and its API token, from the API & Integrations page in Apify Console.

Install the client:

pip install "apify-client~=3.0" 

The code reads the token from an environment variable:

export APIFY_API_TOKEN="your_apify_token_here" 

What you'll achieve

You will turn one keyword into one row per Wallapop listing. Each row carries title, price and currency, city, region and postalCode, distanceKm from your near point, sellerAllowsShipping, reserved, the seller's description, createdAt, the listing url and imageUrl, and stage, which tells you which of the two search passes found it.

Then you will run the same keyword on Wallapop, Vinted, Milanuncios, Cash Converters and eBay Spain and get one table back, each row labelled with its marketplace.

Example use case: you want a used iPhone 11 Pro Max and you live near Madrid. Pass 1 keeps anything within 100 km whether or not the seller posts; pass 2 searches from 12 Spanish cities and keeps only sellers who post. Rows describing a cracked screen or a parts-only unit are dropped by default.

Step 1: Access Wallapop Scraper

Go to the Wallapop Scraper page on Apify Store. In this tutorial we drive the Actor from Python, so everything after this step happens in code.

Step 2: Configure the search

The one required input is searchQueries: one search per keyword, any product or category (bicicleta montaña, iphone 11 pro max). Every keyword is repeated around every search origin.

The Actor runs two passes:

  • Pass 1, nearby (searchNearby, on by default): searches from nearLatitude / nearLongitude (default Madrid, 40.4168, -3.7038) and keeps listings within radiusKm (default 100), whether or not the seller posts. It asks twice, sorted by closest and by relevance.
  • Pass 2, rest of Spain (searchAllSpainWithShipping, on by default): searches from 12 city origins (madrid, barcelona, valencia, sevilla, zaragoza, malaga, bilbao, murcia, palma, alicante, las-palmas, vigo). With requireShipping on (the default) it keeps only sellers who post.

Wallapop returns at most 40 listings per search feed and has no paging, which is why the Actor widens coverage with origins and sort orders instead. A listing found by both passes is saved once.

Here is the setup. One keyword, both passes with their defaults, 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, } 

💡 Tip: keep the query short. With requireKeywordMatch on (the default), every word of the query must appear in the title, so iphone 11 pro max 256gb drops listings that only mention the capacity in the description.

No proxy setting is needed. The README says a proxy is not required: Wallapop's endpoint rejects any request missing the X-DeviceOS header, which the Actor always sends. In testing on 2026-08-20, a direct request with that header got HTTP 200 and the same request without it got 403.

Step 3: Add optional filters

Some filters go to Wallapop in the request; the rest run on the rows. Dropped rows never reach your dataset:

Parameter Default What it does
minPrice / maxPrice 1 / none Price band in EUR, applied by Wallapop.
radiusKm 100 Pass 1 radius, applied by Wallapop and re-checked on each row's coordinates.
requireShipping true Pass 2 keeps only sellers who post, applied by Wallapop.
conditions any Wallapop's own condition filter: un_opened, in_box, new, as_good_as_new, good, fair, has_given_it_all. One extra request per condition per origin.
orderBy most_relevance Pass 2 sort. newest narrows the feed to a recent window.
requireKeywordMatch true Every query word must be in the title, ignoring case and accents.
excludeDamaged true Drops "pantalla rota", "grietas", "no enciende", "para piezas", iCloud-locked and similar. "Sin grietas" is a denial and stays.
excludeAccessories false Phone mode: drops cases, protectors, spare parts and repair adverts by title.
minBattery / allowUnknownBattery 0 / false Phone mode: battery-health floor read from the seller's text.
benchmarkAgainstShop false Phone mode: adds a Cash Converters reference price. Never removes a row.
maxItems 500 Cap on rows for the whole run.

⚠️ Leave the three phone-mode switches off for anything that is not a phone. The README explains why: outside a phone search, pantalla is a television, funda is a duvet cover and carcasa is a PC case. A value outside the conditions or cities lists is refused before the run starts.

Step 4: Run the Actor

Start the run with client.actor("reventadata/wallapop-scraper").call(run_input=run_input). The call starts the Actor on Apify and waits for it to finish before returning the run:

# 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") 

Behind the scenes:

  • The Actor calls Wallapop's own JSON search endpoint, the one its website uses, once per keyword, origin, sort order and condition. With one keyword and no conditions that is 2 feeds in pass 1 and 12 in pass 2; the log prints the planned count before each pass.
  • It checks each listing's distance and shipping flag, applies your text filters, and saves the row.

Step 5: View and download results

Loop over the rows to print the parts you need:

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']}") 

Now the multi-marketplace version. The same keyword goes to all five marketplaces, at most 5 rows each:

# 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']}") 

That Actor fetches nothing itself. It calls the five single-site Actors (Wallapop Scraper among them), reads their datasets back, and maps each row onto shared fields. Here is one row, a sample from a capture dated 2026-08-27: a Wallapop Scraper row whose keyword was iphone 11 pro max, passed through the multi-marketplace Actor's own mapping. 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" } } 

Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.

Understanding your results

In a Wallapop Scraper row (the raw object above):

  • stage: nearby (pass 1) or shipping (pass 2). The sample came from pass 1, 1.2 km from the near point.
  • distanceKm: computed from the listing's coordinates to your near point, to 0.1 km.
  • sellerAllowsShipping: whether this seller posts. The README notes that Wallapop's item_is_shippable describes the category, not the seller.
  • batteryHealth: the percentage the seller typed (88 in the sample), or null. It is whatever the seller wrote, so ask for proof before paying.
  • batteryReplaced, screenReplaced: the seller says the part was swapped. Reported, never filtered on; false means not stated.
  • reserved, isRefurbished, hasWarranty: Wallapop's own flags.
  • createdAt: listing creation time in milliseconds.

In a multi-marketplace row, title, price, currency and url are filled for every site; condition, id, sellerType and category are null where the site does not publish them, and Wallapop publishes none of the four. On Vinted, price is the total including the buyer protection fee: a captured Vinted row of 143 EUR plus a 7.85 fee maps to 150.85, with priceBasis: "totalPrice".

Pro tips

  1. Set minPrice above zero. It defaults to 1. The schema's own note: Wallapop's cheapest results are phone cases and repair-service adverts.
  2. Origins are not filters. cities picks where pass 2 searches from; results still come from all over Spain. Fewer origins means fewer feeds, and each feed returns at most 40 listings.
  3. Use conditions instead of text filters when the item must look new. Wallapop applies it server-side, so the 40 slots per feed go to listings that qualify. Each condition adds one feed per origin.
  4. For price research, turn requireShipping off. Pass 2 then keeps distant sellers who do not post, and sellerAllowsShipping still tells you which is which.
  5. Pick the right Actor. The multi-marketplace Actor passes neutral settings to Wallapop (damage and accessory filters off, unknown battery kept, shipping not required). For radius, battery floor or condition filters, call Wallapop Scraper directly.

Troubleshooting

  • Run FAILED at once with "Bad input": searchQueries had no non-empty keyword, or a coordinate or radiusKm was not a finite number.
  • Run FAILED with "Every feed failed": no Wallapop request succeeded. The README says the endpoint needs the X-DeviceOS header, which the Actor always sends, so read the log's feed lines.
  • Run SUCCEEDED with zero rows: the log warns Widen the radius, loosen minBattery, raise maxPrice, .... Shorten the query or turn on allowUnknownBattery.
  • A marketplace missing from the combined table: the run's status message names it, as No rows from: <site> (<reason>). Each marketplace runs as its own Actor run.

Read a log from Python with client.log(run.id).get(). For help, use the Actor's Store page.

Pricing and next steps

For current pricing and details, see the Store page.

To repeat a search, save the input as a task and add an Apify schedule, which runs an Actor or task on a cron expression (0 8 * * * is every day at 8 AM). To react when a run finishes, add a webhook on ACTOR.RUN.SUCCEEDED; its payload carries the run object, including defaultDatasetId.

What are you planning to track on Wallapop? Drop a comment! 💬