Blog / Web data by industry

Classifieds Data for Price Benchmarking: A Practical Guide

Classifieds data gives you asking prices, not transaction prices, so a benchmark built from it needs three corrections: drop duplicates, drop stale listings, and treat the median as likely above value, by an amount you haven't measured. Each listing's price, a timestamp or location, and a stable ID are the fields that matter. Three endpoints cover eBay, Craigslist and Kleinanzeigen at 1 credit per call.

Do asking prices overstate value?

Asking prices probably overstate value because sellers set them before any buyer has responded, and many listings are open to offers. Platforms build this in: eBay's Best Offer lets a buyer propose a price, and the seller can "accept, decline, or make a counteroffer" (eBay, 2026). We have no sold-price data for these sources, so the size of the gap is unmeasured.

Housing is a useful contrast, not support. In the four weeks ending June 8, 2025, the average US home sold at 99.1% of its list price, and 28.5% sold above it (Redfin, 2025). There, sale prices sat close to list prices and often above them, possibly because agents price against recent sales and buyers compete; Redfin's figures don't test that explanation. Asking prices don't always overstate value, so don't assume the same for second-hand goods.

Our working hypothesis, not a measured result, is that the gap is wider for second-hand goods, where a seller picks a number with little evidence. A second hypothesis is survivorship: listings that sit unsold may stay in the data because their price is too high, while a fairly priced listing may sell and vanish quickly and so be seen for less time. If both hold, a snapshot skews upward. Test them yourself by keeping a record of listings that disappear and comparing.

Which fields does a price benchmark need?

A price benchmark needs a price you can compare, an ID you can deduplicate on, a place, and a sign the listing is alive. The search endpoints for all three sources return these in the list response, so you rarely need a second call per listing. The table maps each source's recorded field names.

Need eBay /v1/ebay/search Craigslist /v1/craigslist/search Kleinanzeigen /v1/kleinanzeigen/search
Stable ID product_id posting_id (and id) id
Numeric price price.extracted price price_numeric
Currency price.currency currency in price text (399 €)
Location location (country only) location.name, location.site location
Freshness none in the list bumped_at none in the list
Other flags sponsored, subtitle images description

Four details matter in practice. eBay's location was only United States in the recorded response, so it can't place a listing in a region. To compare a region, set the preferred_location or location_postal parameter on the search instead. Currency differs by source: eBay and Craigslist returned USD and Kleinanzeigen returned EUR, so compute medians per currency and market and never pool them. eBay marks paid placements with sponsored, which you should keep so you can exclude them if promoted listings skew your sample. eBay's subtitle carried the condition (Brand New) in the recorded response. Kleinanzeigen's description is truncated, so condition and VAT hints may need the details endpoint.

How do you pull comparable prices from each source?

You pull comparable prices by fixing the product, the region and a price band in the query, then reading the numeric price field. Each source filters differently, so the parameters below are the ones to use. All three are GET requests with the X-API-Key header (authentication).

eBay

The eBay search endpoint takes q, plus condition[], buy_it_now, auction, price_min and price_max. Filtering to buy_it_now removes auction prices that are still mid-bid.

curl "https://api.perceptlayer.com/v1/ebay/search?q=coffee+maker&page=1&per_page=60" \
  -H "X-API-Key: $API_KEY"

An excerpt of the recorded response shows the shape:

"results": [
  {
    "product_id": "366702966104",
    "title": "Mr. Coffee 4-in-1 Single-Serve Latte, Iced, and Hot Coffee",
    "price": { "raw": "$60.00", "extracted": 60, "currency": "USD" },
    "sponsored": true,
    "subtitle": "Brand New",
    …
  },
  {
    "product_id": "167322718667",
    "price": { "raw": "$428.98", "extracted": 428.98, "currency": "USD" },
    "sponsored": true,
    "subtitle": "Brand New",
    …
  }
],
"total_results": 110000

Both results come from one query and are both Brand New, yet one is $60.00 and the other a commercial machine at $428.98. A query as broad as "coffee maker" mixes products, which is why you need narrower queries or a trimmed statistic.

Craigslist

The Craigslist search endpoint takes site, subarea, category, query, min_price, max_price, sort and page. Find valid values with the sites and categories endpoints.

curl "https://api.perceptlayer.com/v1/craigslist/search?site=portland&category=bia&query=bike&min_price=50&max_price=300" \
  -H "X-API-Key: $API_KEY"
"total": 821,
"results": [
  {
    "posting_id": 7988730852,
    "title": "21\" Specialized Sirrus A1 commuter",
    "price": 250,
    "currency": "USD",
    "location": { "name": "multnomah county", "site": "portland", … },
    "bumped_at": "2026-10-23T20:30:33Z"
  },
  …
]

bumped_at is a freshness hint, not proof a listing is live. A seller can bump or repost an item to refresh it, so a recent timestamp can sit on a stale offer, and an old one may still be available. Pair it with your own first-seen date for each posting_id. The Craigslist posting guide covers the fields in detail.

Kleinanzeigen

The Kleinanzeigen search endpoint requires query and takes page, location, category, price_min and price_max.

curl "https://api.perceptlayer.com/v1/kleinanzeigen/search?query=iphone&page=1&location=Berlin&category=elektronik&price_min=50&price_max=500" \
  -H "X-API-Key: $API_KEY"
"data": [
  {
    "id": "3547273212",
    "title": "Apple iPhone 13 Pro Max 256GB Silber/Weiß Zustand Sehr gut OVP",
    "price": "399 €",
    "price_numeric": 399,
    "location": "13187 Pankow",
    …
  },
  {
    "id": "3528146741",
    "title": "iPhone 13 mini 128GB |91–100 % Akku | inkl. 19% MwSt. Top Zustand",
    "price_numeric": 379,
    …
  }
]

The second title says inkl. 19% MwSt. (VAT included), which may hint at a business seller, though a private seller could write the same words. If dealer and private prices are different populations in your category, the title alone won't separate them. Compare like with like, and read the Kleinanzeigen guide for more on search parameters.

How do you handle duplicates and stale listings?

Handle duplicates by keying on the source ID, then on a normalised title and price; handle stale listings by dropping anything not refreshed recently. The same item reaches your data twice through pagination overlap, repeated sweeps and reposts under new IDs. Each repeat pulls the median toward that item's price.

Run the rules in this order:

  1. Exact ID. Keep one row per posting_id, product_id or id within a source.
  2. Same seller content. Within one site, treat identical lowercase title plus identical price as one listing. This catches reposts with a new ID.
  3. Freshness. On Craigslist, discard postings whose bumped_at is older than your cutoff, but remember a seller bump can refresh it, so also record your own first-seen date and treat long-running postings with suspicion. eBay and Kleinanzeigen list responses carry no timestamp, so track first-seen dates in your own store and drop listings you haven't seen refreshed.
  4. Price floor and ceiling. Use min_price and max_price to cut placeholder prices such as 1 or 99999 before they reach you.

Matching across sources is a harder problem; deduplicating listings across sources covers it. Pagination also needs care, since a sort order that changes between calls can skip or repeat items: see safe page loops.

What does a worked benchmark look like?

A worked benchmark takes one search, removes duplicates and stale rows, and reports the median and a trimmed range. This script reads the Craigslist search above, keeps postings bumped within seven days, and prints the median asking price. Set API_KEY in your environment.

import os
import statistics
from datetime import datetime, timedelta, timezone

import requests

URL = "https://api.perceptlayer.com/v1/craigslist/search"
params = {
    "site": "portland",
    "category": "bia",
    "query": "bike",
    "min_price": 50,
    "max_price": 300,
}
r = requests.get(URL, params=params, headers={"X-API-Key": os.environ["API_KEY"]}, timeout=30)
r.raise_for_status()

cutoff = datetime.now(timezone.utc) - timedelta(days=7)
seen, prices = set(), []
for row in r.json()["data"]["results"]:
    # a repost gets a new posting_id, so also key on the title and price
    key = (row["title"].strip().lower(), row.get("price"))
    if row["posting_id"] in seen or key in seen:
        continue
    seen.update({row["posting_id"], key})
    bumped = datetime.fromisoformat(row["bumped_at"].replace("Z", "+00:00"))
    if bumped >= cutoff and row.get("price"):
        prices.append(row["price"])

prices.sort()
trim = len(prices) // 10
core = prices[trim:len(prices) - trim] or prices
print(f"n={len(prices)} median={statistics.median(prices)} "
      f"trimmed range={core[0]}-{core[-1]}")

The recorded response reported total: 821 matches with has_more: true, so one call is a page, not the whole market. Loop over page to collect more, and check has_more to know when to stop. The min_price and max_price filters (50 to 300) mean the median describes that price band, not the whole market; widen them and rely on the trimmed range for a market view. Treat the printed median as the asking-price benchmark, then record it weekly so you track movement rather than level.

For cost, take 10 Craigslist sites, 5 pages each, once a week: 50 calls × 1 credit = 50 credits a sweep, or 200 credits over four weeks. Add Kleinanzeigen and eBay for the same product at 5 pages each and the total is 50 + 5 + 5 = 60 calls × 1 credit = 60 credits a week. The free tier gives 1,000 credits a month after you verify an email, and pricing shows what larger packs cost. See credits for how billing works.

Where does this approach fall short?

This approach falls short whenever you need what things actually sold for. The documented parameters on all three search endpoints have no sold-items filter, and the recorded responses carry no sale price or sold status. Your benchmark is a market of offers.

If you need transaction prices, use a source that publishes them. For used cars, the used car listing data guide covers the same asking-price problem for vehicles. You can still estimate the discount indirectly. Log first-seen and last-seen dates for each ID, treat a listing that disappears after a price drop as a probable sale at the last price, and compare. That is an inference, not a measurement, so label it that way in your reports. Calls that return errors don't debit credits, and errors lists the codes to handle in a scheduled job.

Get a free key at /signup and run the Craigslist search above in the playground on the Craigslist API page before you write any code.

APIs in this post

Frequently asked questions

Is classifieds data good enough for price benchmarking?

It's good for tracking the direction and relative level of asking prices in a segment, and weak for stating what an item is worth. Asking prices probably sit above sale prices for second-hand goods, but we haven't measured it, and housing shows it isn't universal: the Redfin figures above put the average sale at 99.1% of list, with 28.5% of homes selling above it. Use classifieds data for trends and comparisons, and use sold data to validate levels.

Why use the median instead of the average?

Classifieds contain broken or placeholder prices and mixed product tiers, such as the $60.00 and $428.98 coffee makers in one eBay result page. A mean follows those outliers, while a median and a trimmed range stay near the typical listing. Report the sample size next to every median.

How much does a benchmark sweep cost?

Each search call costs 1 credit, and failed calls cost nothing. Ten Craigslist sites at 5 pages each is 50 calls × 1 credit = 50 credits per sweep. The free tier includes 1,000 credits a month, and packs start at 10,000 credits. Pricing lists what credits cost.

How often should I refresh the data?

Refresh at the pace the market moves, and match it to the decision. We haven't measured how often these listings change, so a weekly sweep is a starting point for a benchmark and a daily one for a repricing job; adjust once your own first-seen and last-seen logs show how fast listings turn over. Keep your calls inside the rate limit, which is 1 request a second on the free tier; see rate limits.

Can I run this from an AI agent?

Yes. The MCP server at https://api.perceptlayer.com/mcp exposes search_endpoints, get_endpoint and call_endpoint, so an agent can run the same searches and spend 1 credit per successful call. See AI agents (MCP) for setup, or build a price tracking agent in Python.

  1. Job Posting Data for Labour-Market Research

    Which Indeed posting fields support trend analysis, where postings mislead versus hires, and a weekly collection plan that costs 3,000 credits a month.

  2. Kleinanzeigen API: Search German Classifieds as JSON

    Search Kleinanzeigen ads by query, city, category and price range with one GET call. Parameters, a real response and the cost: 1 credit per call.

  3. Incremental API Data Pulls: Fetch Only New Records

    Pull only new listings: sort newest first, keep a high-water mark or a seen-ID set per query, and stop paging early. Python tested on Craigslist and eBay data.

Start with 1,000 free credits

No password to set. We email you a sign-in link, and your key works on every API.

No credit card. 1,000 free credits a month.