Complete Guide: How to Scrape Kleinanzeigen Ads Using Python and Apify
Run the Kleinanzeigen Scraper Actor from Python and get iPhone 13 ads from private sellers with shipping as clean, typed JSON rows.

This complete guide to Kleinanzeigen Scraper shows how to collect ads from kleinanzeigen.de, the German classifieds portal formerly known as eBay Kleinanzeigen, from Python through the Apify API. Instead of copying listings by hand, you send one JSON input and receive one structured row per ad: title, price in whole euros, a flag for negotiable prices, postcode and town, posting time in the Europe/Berlin zone, shipping and buy-now flags, seller badges, photo count and the ad URL.
A single search such as "iphone 13" mixes private sellers with shops, offers with wanted ads, and ads with and without shipping. The Actor applies those distinctions as filters on the rows, so the dataset already matches your question. Here the question is concrete: iPhone 13 ads from private sellers that offer shipping.
You will install the Apify client, run the Actor from a short script, and read the rows. You need Python, a terminal and an Apify account token. Estimated time to complete: About 30 minutes. The run cited throughout (task input searchQueries: ["iphone 13"], sellers: "private", shippingOnly: true, maxItems: 60, maxPages: 4) returned 60 rows in 9.3 seconds.
TL;DR
In this tutorial, you'll learn to run the Kleinanzeigen Scraper Actor from Python, read its dataset, and filter kleinanzeigen.de ads by seller kind and shipping. The key steps: install apify-client, put your token in the script, define run_input with a search term and the sellers and shippingOnly filters, call the Actor, and iterate over the default dataset.
Expected outcome: 60 typed rows for the cited run, each with a title, a price, a currency, a location and an ad URL, ready for a spreadsheet, a database or a price tracker.
The Actor is available at apify.com/reventadata/kleinanzeigen-scraper.
Prerequisites and setup
To follow this tutorial you need three things:
- Python 3 on your machine.
- The Apify API client for Python, installed with pip.
- An Apify account and its personal API token.
Apify Console is the web interface where Actors, runs, datasets and tokens are managed, and your token is on the API & Integrations page there. The platform documentation lives at docs.apify.com.
Install the client:
pip install "apify-client~=3.0"
Keep the token out of your source control. The script below uses the placeholder <YOUR_APIFY_TOKEN>; replace it with your own token or read it from an environment variable in your own setup.
Understanding Kleinanzeigen Scraper
Kleinanzeigen Scraper (reventadata/kleinanzeigen-scraper) searches kleinanzeigen.de by keyword, category or place and returns each matching ad as one row. Technically it requests the site's public search result pages, 25 ads per page, and parses each ad card into a record. The site serves at most 50 pages per search, so one search term can yield up to 1,250 ads.
Key capabilities:
- Filters applied to rows: price range, shipping, seller kind and offer type are applied after the page is fetched, so they work in every category.
- Wanted ads separated: cards marked "Gesuch" (someone looking to buy) are dropped by default.
- Promoted cards flagged: cards shown without a posting date are returned with
isPromotedset to true. - Optional detail pages: with
fetchDetailson, the Actor opens each kept ad for its full text, attributes and seller block.
Limitations to know: "private" means a card without the PRO shop badge, so a commercial seller without that badge is listed as private; and the keyword filter looks only at the title and the card's short text, not at the full ad.
Step-by-step walkthrough
Step 1: Accessing the Actor
Open the Kleinanzeigen Scraper page on the Apify Store. It has a "Try for free" button that opens Apify Console, where you can fill the same input in a form. This guide runs the Actor from Python instead, so the same script can later sit inside a scheduled job.
Step 2: Configuring input parameters
The input is a JSON object. The fields used in this guide:
| Field | Type | Meaning |
|---|---|---|
searchQueries |
array | One search per term across kleinanzeigen.de. |
sellers |
string | any (default), private or proShops. |
shippingOnly |
boolean | Keep only ads marked "Versand möglich" (shipping possible). |
maxItems |
integer | Hard cap on rows pushed across all search terms. |
maxPages |
integer | Pages per search term, 1 to 50, at 25 ads a page. |
Three complete configurations:
- Private iPhone 13 ads with shipping (this guide):
{"searchQueries": ["iphone 13"], "sellers": "private", "shippingOnly": true, "maxItems": 60, "maxPages": 4} - Everything for a term, any seller:
{"searchQueries": ["fahrrad"], "maxItems": 100} - Price band:
{"searchQueries": ["iphone 13"], "minPrice": 150, "maxPrice": 300, "maxItems": 50}
Step 3: Advanced configuration
Beyond the basics, the input schema offers:
categoryId: the number after thecin a category URL on the site, for example 173 for smartphones and phones. It narrows every search term to that category.locationId: the number after thelin a search URL, for example 3331 for Berlin. Without it the search covers all of Germany.offerType:offers(default),wantedorall.excludePromoted: drops the undated promoted cards.requireKeywordMatch: on by default; it drops ads where a word of your term appears in neither the title nor the card text.
Price bounds are applied after fetching, because the site's own price filter path is disallowed in its robots.txt. An ad with only "VB" and no numeric price is dropped whenever a bound is set.
Step 4: Optional enrichment with ad pages
Set fetchDetails to true and the Actor opens every kept ad. The row then carries the full description, the structured attributes as written in German (brand, model, storage and so on), seller name and kind (private or commercial), member-since date, category path and the approximate coordinates the ad page publishes. It adds one extra page request per row. The cited run left it off, which is why the sample row below shows detailFetched as false and the detail fields as null.
Step 5: Running the Actor
This is the script for the cited input. It calls the Actor, waits for it to finish and prints one line per ad:
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run_input = {
"searchQueries": ["iphone 13"],
"sellers": "private",
"shippingOnly": True,
"maxItems": 60,
"maxPages": 4,
}
run = client.actor("reventadata/kleinanzeigen-scraper").call(run_input=run_input)
for item in client.dataset(run.default_dataset_id).iterate_items():
print(item["title"], item["price"], item["currency"], item["location"], item["url"])
.call() starts the run and blocks until it ends, then returns the run record, whose default_dataset_id attribute points at the rows. The cited run ended with status SUCCEEDED after 9.3 seconds and pushed 60 rows.
Step 6: Examining the results
The last loop streams the dataset with iterate_items(). In the cited run, the first row was this ad, copied from the dataset:
{
"id": "3549899772",
"title": "IPhone 13 Pro Max",
"url": "https://www.kleinanzeigen.de/s-anzeige/iphone-13-pro-max/3549899772-173-702",
"price": 290,
"currency": "EUR",
"priceNegotiable": true,
"isFree": false,
"previousPrice": null,
"postcode": "24143",
"location": "Gaarden",
"region": null,
"postedAt": "2026-10-23T20:59:00+02:00",
"postedDate": "2026-10-23",
"isPromoted": false,
"isWanted": false,
"shipping": true,
"buyNow": false,
"sellerWarranty": false,
"isProShop": false,
"shopName": null,
"shopUrl": null,
"imageUrl": "https://img.kleinanzeigen.de/api/v1/prod-ads/images/4b/4b5daab1-628b-45a6-a28b-258f983bb9b4?rule=$_2.AUTO",
"imageCount": 4,
"snippet": "iPhone 13 Pro Max – 128 GB – Guter Zustand\n\nIch verkaufe mein iPhone 13 Pro Max mit 128 GB Speicher...",
"description": null,
"condition": null,
"attributes": null,
"sellerName": null,
"sellerId": null,
"sellerKind": null,
"sellerShopUrl": null,
"sellerSince": null,
"sellerAdCount": null,
"categoryId": null,
"categoryPath": null,
"latitude": null,
"longitude": null,
"detailFetched": false,
"matchedKeyword": "iphone 13",
"scrapedAt": "2026-10-23T19:00:35.214489+00:00"
}
Reading it: price is whole euros and priceNegotiable is true, so the ad shows 290 euros with VB. postedAt carries a Berlin offset while scrapedAt is UTC. isProShop is false and shipping is true, as the filters required. snippet is the card text, cut by the site at about 100 characters.
Step 7: Downloading and exporting data
The dataset can be downloaded as JSON, CSV, Excel or JSONL, and the API serves the same rows to your own code. JSON keeps nested fields such as attributes; CSV suits a quick spreadsheet.
Working with the API
Everything above is the Apify API through the Python client. The .call() method blocks until the run finishes. When you do not want to hold a connection open, client.actor("reventadata/kleinanzeigen-scraper").start(run_input=run_input) returns the run object immediately, with its id and status, and you read the dataset later.
Authentication is the API token: pass it to the client constructor and never commit it. To read results, use client.dataset(run.default_dataset_id) and either iterate_items() to stream or list_items() for a page of rows.
For event-driven setups, add an Apify webhook to the run when you start it through the API and subscribe to ACTOR.RUN.SUCCEEDED. The webhook's resource is the object you would get from the relevant API at that moment; for a run event that is the Actor run, which carries defaultDatasetId (docs.apify.com/platform/integrations/webhooks/actions). Your endpoint can then fetch the rows without polling, once it has confirmed the run succeeded.
Error handling: check that the run's status is SUCCEEDED before you read rows, and wrap the call in your own retry logic if a job must not be missed. If the Actor pushes zero rows, it raises an error and the run fails rather than looking like an empty success, so a failed run in a pipeline means "look at the log", not "no ads exist".
Advanced usage patterns
1. Several search terms in one run
Pass more than one term, for example ["iphone 13", "iphone 14"]. The Actor walks each term in turn inside a single run and writes everything to one default dataset. Each row records the term that found it in matchedKeyword. An ad id is remembered once it is kept, so the same ad is not pushed twice in a run.
2. Narrow by category and place
Use categoryId and locationId to cut a broad search down. A search term plus a category keeps accessories and repair offers out of a phone search; a place keeps the results regional. Both numbers come from the site's own URLs, as described in Step 3.
3. Scheduled monitoring
To watch a market over time, run the same input on a timetable. An Apify schedule runs an Actor or task on a cron expression (docs.apify.com/platform/schedules). Compare two runs by id: the same id with a different price is a change you detected, while previousPrice is a drop the seller made on the card.
4. Separating shops from private sellers
Run once with sellers: "private" and once with sellers: "proShops" to keep the two groups apart, or use any and split on the isProShop field. For a sharper private-or-commercial label, turn on fetchDetails and read sellerKind, the site's own wording.
Understanding your data
The main fields of each row:
id,title,url: ad number, title and link.price,currency,priceNegotiable,isFree,previousPrice: whole euros (nullwhen the card shows only "VB"), the VB flag, the "Zu verschenken" free flag, and the struck-through earlier price.postcode,location: postcode and town or district.postedAt,postedDate: posting time in Europe/Berlin when the card shows a clock time, and the date; promoted cards have no date.shipping,buyNow,isProShop,isPromoted,isWanted: badge flags.condition,attributes,sellerName,sellerKind,categoryPath: detail only.
What the cited run showed, computed from its 60 rows: 59 ads had a numeric price, ranging from 4 to 400 euros, with a median of 230 and a mean of 208.36. Prices split into 33 negotiable (VB), 27 fixed and 0 free, which sums to 60; one VB ad showed no figure, so its price is null. Two ads carried a previousPrice: 199 euros (was 279) on an iPhone 13 mini and 335 euros (was 350) on an iPhone 13 Pro. 22 ads offered Direkt kaufen and 38 did not, 1 was a promoted placement, and the rows named 57 distinct towns.
Troubleshooting guide
- The run fails with "No rows pushed": every listing was filtered out, or the page layout changed. Loosen
sellers,shippingOnlyor the price bounds, and check that the term returns ads on the site. - The input is refused before the run starts:
offerTypeandsellersare enums in the input schema, so a value outside the list is rejected by the platform. With neither a search term nor acategoryId, the Actor itself stops at start-up and asks for one. - Accessories or repair offers appear: the site matches the full ad text. Keep
requireKeywordMatchon, and addcategoryIdfor a tighter result. - Fewer rows than
maxItems: the term may simply have fewer matching ads after your filters, or the site's 50-page ceiling was reached. - A page is missing: a page that keeps failing is skipped with a warning in the log, and the run goes on with the next page.
- Reading the log: open the run in Apify Console and read the log; the last lines list rejected rows by reason and the number of rows pushed. For help, use the Store page.
Best practices
- Start small. Test with a low
maxItemsandmaxPages, then raise them. - Always set
maxItems. It is a hard cap on rows pushed across all search terms, andmaxPageslimits how deep each term goes. - Treat "private" as "no PRO badge". If the difference matters, enable
fetchDetailsand readsellerKind. - Keep
requireKeywordMatchon for product searches, and use category and place IDs to reduce noise. - Re-run for freshness. Schedule the input and compare datasets by
idto see which ads are new, gone or repriced. - Mind the site.
categoryIdandlocationIdnarrow the search itself; the price, seller, shipping and keyword filters run on the fetched rows. Set these input fields instead of looking for URL tricks.
Pricing model
For current pricing and details, see the Store page.
Conclusion and next steps
You now have a script that turns a kleinanzeigen.de search into a typed dataset: a search term, two filters, a cap on rows, and one loop to read them. From here you can add more terms, narrow by category and place, switch on ad pages for seller and attribute data, or put the input on a schedule.
Review the Apify documentation for schedules, webhooks and dataset exports, and the Actor page for the input form.
Try Kleinanzeigen Scraper yourself and let me know what you build!
I build and maintain marketplace scrapers under the reventadata name on the Apify Store.
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
Comments
Join the discussionNo comments yet. Be the first to comment.




