I run a visa agency in Bali. The unrelated half of my week goes into scrapers on Apify, and one of them reads kleinanzeigen.de — Germany's biggest classifieds site, the one that used to be eBay Kleinanzeigen. Keyword, city, category or a pasted search URL in; one row per ad out, with the price as a number and the full ad page attached.

Because the Actor health-checks itself every night with its own prefilled example, I do not have a snapshot of it — I have a panel. 38 byte-identical runs between 22 October and 13 November: searchQueries: ["iphone 15"], location: "Berlin", maxItems: 30, details on. Every run returned exactly 30 listings. 1,140 rows in total.

Between them they contain 659 distinct ads, and 358 of those appeared in exactly one run.

That number is the whole article. A search that looks stable — same keyword, same city, same 30 rows — is a different market every morning, and once you know how different, you schedule the thing differently.

Twenty-four hours apart, one ad survives

I diffed every consecutive pair of runs by ad id. The health check sometimes fires twice in a few minutes, which gave me a control group for free:

gap between runs pairs ads kept out of 30
under 1 hour 10 26–28
3–10 hours 7 5–20
12–48 hours 20 0–2

Eight of the twenty day-apart pairs kept nothing at all. Not one ad of thirty.

This is not the site hiding things from a scraper: two runs eight minutes apart agree on 28 of 30 rows. The inventory is what moves.

Because page one is six hours wide

Kleinanzeigen sorts a search newest-first and serves 25 ads per page. Here is the tail of one run, 13 November, positions in delivery order:

 1 2026-11-13T20:27 410 € private Apple iPhone 15 – 256GB – Rosé – Top Zustand 2 2026-11-13T20:18 550 € private Apple iPhone 15 Pro Max 256 GB Titan Blau ... 24 2026-11-13T14:57 330 € VB private Apple iPhone 15 Pro 128Gb weiß, neues Display 25 2026-11-13T14:51 530 € private iphone 15 Pro Max 256 GB 

Twenty-five ads, 20:27 down to 14:51 — five hours and 36 minutes. Across all 38 runs the median width of page one is 5.97 hours (minimum 3.7, maximum 11.6). The posting timestamps are monotonic in delivery order: over 950 page-one rows I found 9 inversions, all of them minutes wide.

Divide it out and "iphone 15 in Berlin" produces 4.2 new ads per hour, about 100 a day. Median asking price €435.

So a daily schedule at maxItems: 30 is not sampling the market, it is sampling the six hours before the run. Four fifths of the day's ads were posted and buried before the scraper looked. The fix is not a better scraper, it is matching the window to the clock:

  • want a full day of one search → budget ~120 rows, not 30;
  • want a week → ~700;
  • want a narrow window but every ad in it → run every 4 hours with onlyNewRowsStore set, which skips ads already delivered and does not charge for them. My two monitoring test runs six minutes apart: the first wrote 20 listings, the second wrote zero rows and charged nothing.

The rows after 25 are advertising

Now the part I did not expect, which only shows up because the panel is 38 runs deep. I split all 1,140 rows at the page boundary:

positions 1–25 positions 26–30
rows 950 190
flagged isTopAd 17 (1.8%) 125 (66%)
postedAt has no time of day 17 114
commercial sellers 215 135
private sellers 735 55

The Actor only resolves a time of day for ads posted today or yesterday; everything older arrives as a bare date. So two thirds of the rows past position 25 are paid Top-Anzeigen, and most of them are not from this week at all. Median age of a flagged top ad: 8.9 days. Oldest: 29.4 days. Median age of an organic row: 3.7 hours.

They are also the same ads every single time. Of the 659 distinct ads in the panel, 17 appeared in four or more runs — and 16 of those 17 were top ads:

14 of 38 runs TOP iPhone Handy Display Reparatur | Express 30 Min. 17 16 15 … 14 TOP iPhone 15 16 17 Pro Max Plus OLED Display Glas Reparatur Berlin 13 TOP Handy, iPhone 17 16 15 14 13 12 Pro Max Mini Plus +, iPad Ankauf 13 TOP iphone Backcover Rückglas Akkudeckel 16 15 14 13 12 Pro Max Akku 13 TOP ANKAUF SUCHE IPHONE 17 16 15 14 13 Pro MAX SAMSUNG S24 25 ULTRA 

Nobody there is selling an iPhone 15. They are repair shops and buy-up dealers renting the bottom of the page. Average price to answer "what does a used iPhone 15 go for in Berlin" and these rows put €19 screen repairs and a €1,500 dealer wanted-ad into the sample — and they persist for weeks, so they survive any dedup by id. Filter isTopAd before you aggregate anything.

One honest caveat on that flag: it is read reliably from the ad page, which means it only arrives when includeDetails is on. In my 60-row and 1,250-row runs with details off, isTopAd never appeared once — yet position 28 of the 60-row run is the same iPhone Ankauf dealer, recognisable only by a postedAt with no time in it. If you scrape list pages cheaply, that null date is your tell.

postedAt is a bump date, not a birthday

Of the 301 ads I saw in more than one run, 13 changed their posting date between runs. One walked through seven of them:

ad 802383984 19 Oct → 23 Oct → 26 Oct → 30 Oct → 4 Nov → 8 Nov → 11 Nov 

Same id, same title, same price, re-dated roughly every four days. Sellers pay to push an ad back to the top, and the date resets with it. So postedAt answers "when was this last bumped", not "when did this appear" — and a chunk of what looks like fresh daily supply is recycled stock. Deduplicate on id. Never on date, and never on title.

The ceiling, and what the fields actually contain

The site serves at most 50 pages per search — 1,250 ads — and page 51 repeats page 50. A sofa run with maxItems: 1250 delivered exactly 1,250 rows with zero duplicate ids, so the cap is real and clean. For the Berlin iPhone search, 1,250 rows is about twelve days of supply; for sofa nationwide it is a few hours. Past the cap there is no scraping trick, only splitting the search by category, radius or price band.

Across all 6,698 listing rows I pulled in the period:

priceType fixed 3,218 | negotiable 3,023 | free 323 | absent 134 price (number) missing on 371 rows oldPrice present on 326 rows sellerType private 2,818 | commercial 1,310 

Nearly half of all German classifieds prices are VB — Verhandlungsbasis, negotiable — so priceType is not decoration, it is the difference between an asking price and an opening bid. And 371 rows carry no number at all: ads priced "VB" with no figure, or zu verschenken giveaways. A pipeline that assumes price exists breaks on 5.5% of rows.

Starting a run over the API, with the window sized for a full day:

curl -s -X POST "https://api.apify.com/v2/acts/lergassy~kleinanzeigen-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"searchQueries":["iphone 15"],"location":"Berlin","radius":10, "maxItems":120,"includeDetails":true,"onlyNewRowsStore":"iphone-berlin"}' 

Bad input comes back as a free row rather than a crash: an unknown location ("Atlantis"), a nonsense category, a keyword with no hits or a deleted ad all produce a type: "error" row that says so and is not charged. Filter type != "listing" and you have your table.

When this is the wrong tool: if you need seller phone numbers at scale, most are behind a login and never appear. If you need sold prices, Kleinanzeigen does not publish them — an ad simply vanishes, which is exactly the churn measured above. And if you want national price statistics for a common keyword, the 1,250-ad cap means you are sampling the last few hours of a big market; split the search before you trust the average.