A powerful API interface for Ebay-Kleinanzeigen.de that enables you to fetch listings and specific data.
🚀 Looking for a ready-to-use solution?
Try it at kleinanzeigen-agent.de »
✓ Automated Search Agents
✓ Search & Detail API
Report Bug
·
Request Feature
Visit kleinanzeigen-agent.de for our hosted solution with additional features and zero configuration required.
- Python 3.12 or higher
- pip (Python package manager)
- Playwright
- Clone the repository
git clone https://github.com/DanielWTE/ebay-kleinanzeigen-api.git
cd ebay-kleinanzeigen-api- Install dependencies
pip install -r requirements.txt
playwright install chromium- Start the API
uvicorn main:app --reloadThe API will be available at http://localhost:8000
Set the HEADLESS environment variable to false to watch the browser while scraping — useful during development:
HEADLESS=false uvicorn main:app --reload- uv package manager
uv syncuv run uvicorn main:app --reloadHEADLESS=false uv run uvicorn main:app --reload- Build the Docker image
docker build -t ebay-kleinanzeigen-api .- Run the Docker container
docker run -p 8000:8000 ebay-kleinanzeigen-apiThe API will be available at http://localhost:8000
Endpoint: GET /inserate
Description: Retrieves a list of listings based on search criteria.
query(string, optional): The search term (e.g., "fahrrad" to search for bicycles).location(string, optional): The location or postal code to narrow the search (e.g.,10178for Berlin).radius(integer, optional): The search radius in kilometers from the specified location (e.g.,5for a 5 km radius).min_price(integer, optional): The minimum price in Euros for the listings (e.g.,200for at least 200 Euros).max_price(integer, optional): The maximum price in Euros for the listings (e.g.,500for at most 500 Euros).page_count(integer, optional): The number of pages to search or return (e.g.,5for the first 5 pages, default is 1, max: 20 pages).min_publish_date(datetime, optional): Stop fetching once a page contains listings published before this datetime. Listings older than the threshold are removed from the final results. Format:YYYY-MM-DDTHH:MM:SS(e.g.,2026-05-03T08:00:00). Useful for intraday runs that should only collect new listings.
GET /inserate?query=fahrrad&location=10178&radius=5&min_price=200&page_count=5With min_publish_date — stop when listings older than this morning are reached:
curl "http://localhost:8000/inserate?query=fahrrad&page_count=10&min_publish_date=2026-05-04T08:00:00"Endpoint: GET /inserat/{id}
Description: Retrieves detailed information about a specific listing.
-
id(string): The listing identifier. Two formats are accepted:Format Example Source Full URL segment (recommended) 3399373623-220-16792Last path segment of the listing urlfield from search resultsPlain adid 3399373623The adidfield from search resultsKleinanzeigen listing URLs follow the pattern:
/s-anzeige/{slug}/{adid}-{category_id}-{location_id}The suffix
-{category_id}-{location_id}is used by Kleinanzeigen for server-side routing (CDN/load balancer can route directly without a database lookup). Using the full segment avoids a potential server-side redirect — Kleinanzeigen may or may not redirect depending on the listing.
With full URL segment (recommended, ~4s):
curl -s http://localhost:8000/inserat/3399373623-220-16792 | python -m json.toolWith plain adid only (also works, redirect may or may not occur):
curl -s http://localhost:8000/inserat/3399373623 | python -m json.toolTip: When iterating over search results, use the url field directly — extract the last path segment to get the full id:
# Extract the id from a listing url
echo "https://www.kleinanzeigen.de/s-anzeige/fendt-bianco/3399373623-220-16792" | grep -oP '[^/]+$'
# → 3399373623-220-16792{
"success": true,
"time_taken": 4.108,
"data": {
"id": "3399373623",
"url_requested": "https://www.kleinanzeigen.de/s-anzeige/3399373623-220-16792",
"url_redirected": "https://www.kleinanzeigen.de/s-anzeige/fendt-bianco-aktiv-550-kg/3399373623-220-16792",
"categories": ["Wohnwagen & Reisemobile", "Wohnwagen"],
"title": "Fendt Bianco Aktiv 550 KG",
"status": "active",
"price": {
"amount": "12500",
"currency": "€",
"negotiable": true
},
"delivery": "pickup",
"location": {
"zip": "88299",
"city": "Leutkirch im Allgäu"
},
"views": "142",
"description": "Verkaufe unseren gepflegten Fendt Bianco...",
"images": [
"https://img.kleinanzeigen.de/api/v1/prod-ads/images/..."
],
"details": {
"Baujahr": "2015",
"Zustand": "Gebraucht"
},
"features": {},
"seller": {
"name": "Max Mustermann",
"type": "private"
},
"extra_info": {}
},
"performance_metrics": {
"success_rate": 100,
"time_taken": 4.108
}
}Response fields:
data.id— Kleinanzeigen listing ID (numeric string).data.url_requested— The URL sent to Kleinanzeigen. When called with a plain adid, this is the short form without slug.data.url_redirected— The final URL after any server-side redirect. When called with a plain adid, this contains the full canonical URL including slug and routing suffix{adid}-{category_id}-{location_id}. Identical tourl_requestedwhen the full segment was passed.data.status— Listing status:active,sold,reserved, ordeleted.data.price.negotiable—trueif the price is marked as negotiable (VB).data.delivery—pickup,shipping, ornull.data.details— Key/value pairs of listing attributes (e.g. Baujahr, Zustand).data.features— Additional configuration options if present.
Endpoint: GET /inserate-detailed
Description: Retrieves listings and their detailed information in a single request.
Same as /inserate endpoint, plus:
max_concurrent_details(integer, optional): Maximum concurrent detail fetches (default: 5, max: 10).
GET /inserate-detailed?query=laptop&page_count=2&max_concurrent_details=5Endpoint: POST /inserate-by-url
Description: Scrape listings using a full Kleinanzeigen search URL. All filters already encoded in the URL (category, brand, year, fuel type, transmission, car type, etc.) are preserved as-is. Page numbers are injected automatically for multi-page fetching. Use this when /inserate does not yet support a filter you need.
Pages are fetched sequentially to avoid IP-level rate limiting by Kleinanzeigen.
{
"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen",
"max_pages": 3
}url(string, required): Any Kleinanzeigen search or category URL.max_pages(integer, optional): Number of pages to fetch (default: 1). Each page returns up to 25 listings.min_publish_date(datetime, optional): Stop fetching once a page contains listings published before this datetime. Listings older than the threshold are removed from the final results. Format:YYYY-MM-DDTHH:MM:SS. Supports intraday precision — e.g."2026-05-04T08:00:00"keeps only listings from that morning onward.
Single page — VW with Klima (25 results):
curl -X POST http://localhost:8000/inserate-by-url \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen",
"max_pages": 1
}'3 pages — VW with Klima (up to 75 results):
curl -X POST http://localhost:8000/inserate-by-url \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen",
"max_pages": 3
}'With full filters — VW Kombi/SUV, Automatik, CNG/LPG, ab 2008, 3 pages:
curl -X POST http://localhost:8000/inserate-by-url \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.ez_i:2008%2C+autos.fuel_s:(cng%2Clpg)+autos.marke_s:volkswagen+autos.shift_s:automatik+autos.typ_s:(kombi%2Csuv)",
"max_pages": 3
}'Wohnwagen — Fendt, Klima, max 15.000 €, ab 2008:
curl -X POST http://localhost:8000/inserate-by-url \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.kleinanzeigen.de/s-wohnwagen-mobile/wohnwagen/preis::15000/klima/k0c220+wohnwagen_mobile.art_s:wohnwagen+wohnwagen_mobile.ez_i:2008%2C+wohnwagen_mobile.marke_s:fendt",
"max_pages": 2
}'With min_publish_date — stop once listings older than this morning are found (intraday run):
curl -s -X POST http://localhost:8000/inserate-by-url \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen",
"max_pages": 20,
"min_publish_date": "2026-05-04T08:00:00"
}' | python -m json.toolExpected: pages_requested < 20, all published_at values ≥ "2026-05-04T08:00:00".
Verify early-stop: future cutoff → 0 results, only 1 page fetched:
curl -s -X POST http://localhost:8000/inserate-by-url \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen",
"max_pages": 5,
"min_publish_date": "2099-01-01T00:00:00"
}' | python -m json.toolExpected: "unique_results": 0, "pages_requested": 1.
Verify no false filtering: past cutoff → full 25 results:
curl -s -X POST http://localhost:8000/inserate-by-url \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen",
"max_pages": 1,
"min_publish_date": "2000-01-01T00:00:00"
}' | python -m json.toolExpected: "unique_results": 25.
Verify schema enforcement: plain date string → HTTP 422:
curl -s -X POST http://localhost:8000/inserate-by-url \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen",
"max_pages": 1,
"min_publish_date": "2026-05-04"
}' | python -m json.toolExpected: HTTP 422 — the time component is required.
Pretty-print the response with Python:
curl -s -X POST http://localhost:8000/inserate-by-url \
-H "Content-Type: application/json" \
-d '{"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen", "max_pages": 1}' \
| python -m json.tool{
"success": true,
"results": [
{
"adid": "3398033022",
"url": "https://www.kleinanzeigen.de/s-anzeige/...",
"title": "VW Golf Variant 2.0 TDI Automatik Klima",
"price": "12500",
"description": "Gepflegter Zustand, Erstbesitz...",
"published_at": "2026-05-03T22:06:00"
}
],
"unique_results": 75,
"total_results": 120140,
"time_taken": 12.358,
"performance_metrics": {
"pages_requested": 3,
"pages_successful": 3,
"success_rate": 100.0,
"average_page_time": 4.119
},
"browser_metrics": {
"contexts_created": 3,
"contexts_reused": 0,
"contexts_in_pool": 3,
"contexts_in_use": 0
}
}Response fields:
results: Array of listing objects. Each listing contains:adid— Kleinanzeigen internal listing IDurl— Direct link to the listingtitle— Listing titleprice— Price as string (numeric digits only, no currency symbol; empty string if not set)description— Short teaser text from the listing cardpublished_at— ISO 8601 publish datetime (e.g."2026-05-03T22:06:00"). Older listings show only the date at midnight ("2026-04-26T00:00:00").nullif not shown on the card.
unique_results: Total number of listings returned across all fetched pages.total_results: Total hits reported by Kleinanzeigen for this search (e.g.120140). Extracted from the page breadcrumb on the first page load — no extra request needed.time_taken: Total elapsed time in seconds.performance_metrics.pages_requested/pages_successful/success_rate: Page-level fetch statistics.
Endpoint: POST /convert-url
Description: Parses a Kleinanzeigen URL and returns two groups of parameters: inserate_params (what the /inserate endpoint currently understands) and unmapped (filters not yet supported by /inserate, such as category, brand, year, or transmission). Useful for inspecting which parts of a URL can be expressed via the structured API.
{
"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen"
}Basic — category + keyword only:
curl -X POST http://localhost:8000/convert-url \
-H "Content-Type: application/json" \
-d '{"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.marke_s:volkswagen"}' \
| python -m json.toolWith year filter:
curl -X POST http://localhost:8000/convert-url \
-H "Content-Type: application/json" \
-d '{"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.ez_i:2008%2C+autos.marke_s:volkswagen"}' \
| python -m json.toolWith full filters — fuel, transmission, body type:
curl -X POST http://localhost:8000/convert-url \
-H "Content-Type: application/json" \
-d '{"url": "https://www.kleinanzeigen.de/s-autos/volkswagen/klima/k0c216+autos.ez_i:2008%2C+autos.fuel_s:(cng%2Clpg)+autos.marke_s:volkswagen+autos.shift_s:automatik+autos.typ_s:(kombi%2Csuv)"}' \
| python -m json.tool{
"inserate_params": {
"query": "klima",
"page_count": 1
},
"unmapped": {
"category_slug": "s-autos",
"subcategory": "volkswagen",
"category_id": 216,
"year_from": 2008,
"year_to": null,
"brands": ["volkswagen"]
}
}{
"success": true,
"time_taken": 1.23,
"unique_results": 25,
"data": [
{
"adid": "123456",
"url": "https://www.kleinanzeigen.de/s-anzeige/...",
"title": "Example Item",
"price": "100",
"description": "Short teaser text..."
}
],
"performance_metrics": {
"pages_requested": 1,
"pages_successful": 1,
"success_rate": 100.0,
"average_page_time": 1.23
}
}The /inserate-detailed response additionally nests a details object inside each listing with full description, seller info, and location.
{
"success": true,
"results": [
{
"adid": "123456",
"url": "https://www.kleinanzeigen.de/s-anzeige/...",
"title": "Example Item",
"price": "100",
"description": "Short teaser text...",
"published_at": "2026-05-03T22:06:00"
}
],
"unique_results": 75,
"total_results": 120140,
"time_taken": 12.358,
"performance_metrics": {
"pages_requested": 3,
"pages_successful": 3,
"success_rate": 100.0,
"average_page_time": 4.119
},
"browser_metrics": { ... }
}total_results is the total hit count shown by Kleinanzeigen (e.g. 120140). It is extracted from the breadcrumb of the first page — no extra request is made. The field is omitted if the breadcrumb is not found.
{
"inserate_params": {
"query": "klima",
"page_count": 1
},
"unmapped": {
"category_slug": "s-autos",
"subcategory": "volkswagen",
"category_id": 216,
"year_from": 2008,
"year_to": null,
"brands": ["volkswagen"]
}
}Performance tests conducted on Arch Linux with Intel i7-1260P processor:
| Endpoint | Operation | Avg Time | Min Time | Max Time | Success Rate | Results |
|---|---|---|---|---|---|---|
| Root | API status check | 0.004s | 0.001s | 0.009s | 100% | - |
| Listings | 1 page search | 1.209s | 1.081s | 1.596s | 100% | 25 |
| Listings | 5 pages search | 2.436s | 2.328s | 2.532s | 100% | 125 |
| Listings | 10 pages search | 4.989s | 4.790s | 5.229s | 100% | 250 |
| Details | Single listing details | 1.072s | 0.928s | 1.276s | 100% | 1 |
| Combined | 1 page + details | 9.058s | 8.804s | 9.319s | 100% | 25 |
| Combined | 2 pages + details | 17.512s | 17.063s | 18.038s | 100% | 50 |
- Single page search: ~1.2s average response time
- 10-page search: ~5.0s average response time
- Individual listing details: ~1.1s average response time
- Combined search + details: ~9.1s average for 1 page with full details
All tests performed with uvloop optimization and browser context pooling enabled.
- uvloop Integration: High-performance asyncio event loop
- Context Pooling: Efficient browser resource reuse
- Memory Optimization: Automatic garbage collection and efficient processing
- Intelligent Concurrency: Optimal concurrent processing with resource control
- Clean API Responses: Minimal overhead with essential data only
- Resource Management: Automatic cleanup and efficient resource usage
- Scalable Architecture: Handles concurrent requests efficiently
- Docker Optimized: Fast container builds with uv package manager
API documentation is available at http://localhost:8000/docs when running locally.
Distributed under the MIT License. See LICENSE for more information.
Maintainer: Darko Palić
The images and views top-level fields have been replaced. The data is still present
but lives in a different location.
| Field | Before | After |
|---|---|---|
| Image URLs | data.images → ["url"] (one image) |
data.media.images.urls → ["url1", "url2", …] (all images) |
| Image count | not provided | data.media.images.count |
| Views | data.views |
data.extra_info.views (unchanged location — was already there) |
How to migrate:
# Before
image = response["data"]["images"] # single URL string or list of one
views = response["data"]["views"]
# After
images = response["data"]["media"]["images"]["urls"] # list of all gallery images
count = response["data"]["media"]["images"]["count"]
views = response["data"]["extra_info"]["views"] # unchanged// Before
const image = data.images;
const views = data.views;
// After
const images = data.media.images.urls; // array of all gallery images
const count = data.media.images.count;
const views = data.extra_info.views; // unchangedPreviously, fetching a deleted or expired ad returned HTTP 200 with success: true
and empty fields (title "[ERROR] Title not found", status "active").
Now, two conditions are detected and return HTTP 404 with success: false:
- The URL redirects away from the ad page (ad deleted — Kleinanzeigen redirects to homepage)
- The page contains
#srchrslt-adexpired(ad expired page)
How to migrate:
# Before — callers had to inspect fields to detect a missing ad
resp = requests.get(f"/inserat/{id}", params={"batch_id": "x"})
data = resp.json()["data"]
if data["title"] == "[ERROR] Title not found":
# ad was gone
# After — standard HTTP semantics
resp = requests.get(f"/inserat/{id}", params={"batch_id": "x"})
if resp.status_code == 404:
# ad is deleted or expired
print(resp.json()["detail"]["status"]) # "deleted"
else:
data = resp.json()["data"]// Before
const res = await fetch(`/inserat/${id}?batch_id=x`);
const { data } = await res.json();
if (data.title === "[ERROR] Title not found") { /* gone */ }
// After
const res = await fetch(`/inserat/${id}?batch_id=x`);
if (res.status === 404) {
const { detail } = await res.json();
// detail.status === "deleted"
} else {
const { data } = await res.json();
}data.scraped_at— ISO 8601 UTC timestamp of when the ad was scrapeddata.url_requested— URL sent to Kleinanzeigen (built from the id)data.url_redirected— final URL after any server-side redirectdata.seller.user_id— numeric Kleinanzeigen user ID extracted from the profile link href
- All gallery images returned — previously only the first image was fetched
(
#viewad-image). Now all images from.galleryimage-element imgare returned, includingdata-srclazy-loaded images. Deduplication applied. POST /inserate-by-url— smarter pagination — breadcrumb is now fully parsed on page 1 to determine the exact page count. Fetching stops automatically when the real page count is reached, avoiding wasted requests for non-existent pages.POST /inserate-by-url— URL pagination fix — page injection now correctly handles complex URLs with filters before the category segment (e.g.anzeige:angebote,preis::N).- Healthcheck endpoint —
GET /healthadded for load balancer / uptime monitoring.
- 16 live integration tests in
tests/test_inserat_live.pycovering active ads (both full-segment and plain adid formats), all response fields, and deleted-ad detection (HTTP 404,status: "deleted") for 2 known-expired ad IDs.
Maintainer: Darko Palić
GET /inserat/{id}response now includes two URL fields indata:url_requested— the URL that was sent to Kleinanzeigen (built from the id passed to the endpoint).url_redirected— the final URL as reported by the browser after any server-side redirect. Identical tourl_requestedwhen no redirect occurred.
- Useful when calling with a plain adid (e.g.
/inserat/3399373623) to discover the canonical URL including slug and routing suffix.
- 13 live integration tests in
tests/test_inserat_live.pycovering both id formats (full segment and plain adid),url_requested,url_redirected, content fields, and status validation.
Maintainer: Darko Palić
min_publish_dateupgraded fromdatetodatetimeon bothGET /inserateandPOST /inserate-by-url. The parameter now accepts a full ISO 8601 datetime string (YYYY-MM-DDTHH:MM:SS) instead of a plain date. This enables intraday filtering — e.g."2026-05-04T08:00:00"keeps only listings published from that exact time onward, not just from that calendar day.- The internal comparison in
_page_has_old_listingsand_filter_by_min_publish_datenow operates on fulldatetimeobjects instead of stripping to.date(), preserving hour/minute precision. - German date strings (
"Heute, 08:46","Gestern, 14:29") already produced full ISO 8601 datetimes — no parsing changes required.
- 3 new live integration tests in
tests/test_inserate_by_url_live.pycoveringmin_publish_date:- Future cutoff (
2099-01-01T00:00:00) returns 0 results and stops after page 1. - Past cutoff (
2000-01-01T00:00:00) does not filter anything — result count unchanged. - All returned
published_atvalues satisfy the cutoff when a real intraday threshold is used.
- Future cutoff (
Maintainer: Darko Palić
POST /inserate-by-url— scrape listings using any full Kleinanzeigen search URL. All filters encoded in the URL (category, brand, year, fuel type, transmission, body type, etc.) are preserved as-is. Page numbers are injected automatically for multi-page fetching. Response includestotal_results(total hit count from the page breadcrumb, no extra request) andpublished_aton every listing.POST /convert-url— parse a Kleinanzeigen URL intoinserate_params(what/inserateunderstands today) andunmapped(filters not yet supported by the structured endpoint).
published_at— ISO 8601 publish datetime added to every listing result (e.g."2026-05-04T22:06:00"). Extracted from the listing card during the existing page load — no extra request. Older listings return a date-only value ("2026-04-26T00:00:00");nullif not shown on the card.
- Cookie clearing on context release — browser contexts now have session cookies cleared before returning to the pool, preventing Kleinanzeigen tracking state from carrying over between unrelated requests and causing silent empty results on pages 2+.
- Inter-page delay — a 2-second pause between sequential page fetches within a single multi-page scrape reduces bot-detection false positives.
- 11 unit tests for
/convert-url(tests/test_convert_url.py, no server required) - 14 live integration tests for
/inserate-by-url(tests/test_inserate_by_url_live.py), covering JSON structure,published_at,total_results, and exact result counts for 1–4 pages (25 / 50 / 75 / 100 results)