Scrape car listings from Wallapop, Spain's largest classifieds marketplace. Extract price, mileage, year, location, seller info, and photos for every listing. Filter by brand, model, price range, and region. Ideal for market research, price comparison, and automotive analytics in Spain.
New boolean input stripSellerPII (default: false). When enabled, personal data of private
(non-professional) sellers is removed from the output before it reaches the dataset:
user_id → null
latitude / longitude → truncated to 2 decimals (~1 km precision)
description → phone numbers and emails replaced with [REDACTED]
Professional seller data is never affected. The toggle is off by default — no change in
behaviour for existing users.
0.6 — 2026-08-23 · timeFilter filters by modification, not publication
timeFilter was documented as returning "listings created or modified today". Only the second half
is true, and the difference matters.
Measured against the live API, share of results that had gone more than 24 h without a
modification:
Unfiltered
today
By modified_at
317 / 320
8 / 318
By created_at
320 / 320
159 / 318
The filter works — on modified_at. So:
a listing published 786 days ago and edited this morning is returned by today;
a listing published yesterday and untouched since is not.
These feeds bump and re-index constantly, so recency of modification says very little about
recency of publication. Anyone reaching for timeFilter to capture new supply will silently miss
most of it. The schema now says so, and the actor logs the same warning at runtime when the filter
is used.
Also measured: today, lastWeek and lastMonth returned identical result sets (238 of 238),
because the feed is dominated by recently bumped listings. The granularity between values is not
reliable and the schema says that too.
Where it is genuinely useful: sweeping for recent activity — price changes and bumps — without
scanning the whole catalogue.
0.6 — 2026-08-23 · user_id, and the truth about seller_type
seller_type has been null on 100% of output since this actor shipped. The fallback that was
supposed to fill it —
The seller's type lives on a different endpoint: GET /api/v3/users/{user_id} returns
seller_type, type and micro_name, and /stats returns rating_average and ratings. That is
one extra request per seller, so it belongs behind a flag with a per-user cache, not in the row
builder. seller_type is now left null rather than guessed, and the dead fallback is gone — with a
test asserting it cannot come back.
user_id is in the search response and costs nothing. It is now mapped. On a 60-listing sample
of Madrid cars it revealed 4 unique sellers, one of them holding 54 of the 60 listings — a
dealer identified with no extra request. Seller recurrence and supply concentration are answerable
today; professional-vs-private can follow by enriching only the unique ids.
0.6 — 2026-08-23 · Geographic stop, and the region as the unit of trust
geoStopPostalPrefix, geoStopThreshold (0.5), geoStopPages (3). When several
consecutive pages fall outside the postal prefix, the sweep stops. The streak resets if a page
comes back inside, so one border page cannot end a run, and a listing with no postal code is
never counted as outside — an absent field would otherwise look like drift.
Why this is not a workaround for a broken filter
Wallapop honours distance_in_km as a hard cut. Measured: 65 km requested returned a maximum of
65.7 km across 997 unique listings, with no drift beyond it. The mismatch is geometric — a 65 km
disc around Bilbao contains all of Álava, half of Gipuzkoa and part of Cantabria. With
orderBy=closest the sweep works outward, so leaving the region means there is nothing left inside
it.
Measured on Bizkaia, 997 listings within 65 km of Bilbao:
Band (km)
Total
CP 48
Outside
0–5
411
411
0%
10–15
31
27
13%
25–30
25
13
48%
35–40
13
1
92%
40–50
293
0
100%
The mix crosses 50% at ~35 km, and only 1 of 542 Bizkaia listings sits beyond that point. The
far corner is geometrically real — Ermua is 36 km out — but holds almost no supply.
geo_stop is a complete sweep of its region
It joins feed_exhausted as a termination that sets sweep_complete, and SEEN_IDS.scope now
carries what makes that safe:
postalPrefix — which slice the id list speaks for. Without it a consumer would compare a
Bizkaia sweep against a national catalogue and deactivate the rest of Spain.
inScopeScanned — listings scanned inside the region, not emitted. Emitted excludes
unchanged listings, which are exactly the ones still alive.
maxDistanceKm — how far the sweep actually reached, so the consumer can judge whether the
far corner was covered rather than assume it.
A defect the negative control caught
Pointing the search at Vitoria while asking for prefix 48 stopped cleanly after 120 listings with
inScopeScanned: 0 — and reported reconcilable: true. A consumer would have deactivated every
Bizkaia listing it held. When a prefix is set the region is the denominator, so reconcilability
now requires in_region_scanned > 0, not merely scanned > 0.
Found by deliberately running the guard against a region it could not satisfy, not by reading the
code.
0.6 — 2026-08-22 · SEEN_IDS carries its own trustworthiness
SEEN_IDS was a bare list of ids. A consumer reading held − SEEN_IDS = sold would have deleted
live vehicles whenever a sweep was truncated or blocked — and a rate-limited run, which scans
nothing, would have read as "every car in this area sold overnight".
The record now reports whether it can be trusted:
reconcilable — true only when the sweep exhausted its slice AND scanned more than zero.
sweep_ended — feed_exhausted / last_page / max_pages / max_items / http_error /
no_payload / unhandled_error. Every termination path is now labelled at the point it happens.
scope — the filters that define the slice this list speaks for. Absence is only meaningful
inside it; comparing a provincial sweep against a national catalogue deletes the country.
An unusable sweep logs a warning instead of passing silently.
SUMMARY carries sweep_complete and sweep_ended too, so the canary can track truncation.
Verified on the platform: a finished sweep reports reconcilable: true, sweep_ended: "last_page";
a capped sweep reports false with the reason; and the promotion smoke run hit a real coches.net
rate limit and correctly reported reconcilable: false, sweep_ended: "http_error".
0.6 — 2026-08-21 · Ledger-based delta engine
Added
Ledger-based incremental mode.enableCursor now tracks a per-stream ledger of seen
listing ids plus a content fingerprint over
price / km / title / reserved / has_warranty / is_refurbished / seller_type
. A listing is emitted when its id is unknown
or its fingerprint moved; unchanged listings are skipped and not charged.
_delta_status ("new" / "updated"), _changed_fields, _previous_price
and _price_delta on every record in incremental mode. Price movements are now
first-class output rather than something the consumer has to diff.
cursorId input — namespaces the ledger. Runs sharing a stream id share one ledger;
different ids are independent. Derived from the search filters when left empty.
emitSeenIds input — writes every listing id observed during the sweep (including
those skipped as unchanged) to SEEN_IDS in the run key-value store, so consumers can
detect delisted/sold vehicles. No extra cost.
maxPages input — explicit cap on pages swept. 0 keeps the previous automatic
behaviour in normal mode and allows ~30 pages in incremental mode, where most listings are
skipped and more pages must be walked to reach the new ones.
location: "espana" added to the input schema enum. The code already defined it as the
nationwide default; the schema rejected it with invalid-input.
Delta view in the dataset schema, surfacing status and price movement.
Offline test suite (tests/test_delta_engine.py, 26 assertions) including a replay of
real production datasets.
Changed
Removed the early-stop. The run no longer aborts when it meets a listing at or below a
watermark. It sweeps to maxItems / maxPages and lets the ledger decide what to emit.
SUMMARY is now diagnostic. Reports scanned, emitted_new, emitted_updated,
unchanged_skipped, ledger_size, ledger_pruned and engine. The old skipped_cursor
counter was structurally incapable of exceeding 1 and is gone.
Empty delta runs are no longer an error. A run that scans listings and emits none is a
correct outcome and is logged as such. A run that scans nothing still raises.
Fixed
Incremental mode dropped new listings permanently. v0.5 stored the newest modified_at
seen and stopped the run at the first listing at or below it. Two properties of Wallapop's
API break that scheme:
order_by=newest is not ordered by created_at or modified_at — measured at 57% of
adjacent pairs out of order over 160 listings.
modified_at is a re-index timestamp bumped in bulk, not a seller-edit timestamp.
Together they meant the watermark advanced past listings the run had never seen. Because the
feed is a rotating window, a listing published at 06:12 could surface at 06:20 — by which
point the watermark had moved to 06:14 and the listing was below it forever.
Measured end-to-end on 2026-08-21 over a 17-minute window, with a simultaneous control run:
15 genuinely new listings, 9 delivered, 6 lost permanently — 60% recall. Replaying the
same production datasets through the v0.6 ledger recovers 15/15 — 100% recall.
The cursor was global per account. State lived under a single key
(FIREHOSE_CURSOR, optionally suffixed by sellerType), so unrelated searches shared one
watermark and cancelled each other out. Ledgers are now namespaced per stream.
Stale listings aborted whole runs. A late-surfacing listing near the top of the feed set
cursor_hit and killed the run — observed returning 3, 5, 8 and 9 items on runs configured
for 100. No early-stop, no aborts.
Migration
Automatic and requires no action. v0.6 uses a new store (wallapop-delta-state) and does not
carry over the v0.5 watermark, which was not sound enough to build on. The first v0.6 run with
enableCursor: true captures a full baseline and seeds the ledger; subsequent runs return
deltas. Legacy v0.5 state is detected and reported in the log, and is left untouched.
Consumers should expect one larger-than-usual run at the transition. Anyone who was running
v0.5 incrementally should treat that run as a catalogue rebuild, since v0.5 gaps are not
recoverable from Wallapop after the fact.
Compatibility
Backward compatible. All existing input fields keep their names and semantics; new fields are
optional and additive. Runs with enableCursor: false behave exactly as before apart from the
richer SUMMARY.