A useful eBay price tracker needs to explain what changed. A lower median might reflect a real shift in comparable listings, or it might mean that today's search included more broken units, empty boxes, and accessories. Repeating a keyword request is the collection step. Defining a comparable observation is the work that makes the resulting history useful.

This guide builds a workflow around SoldFetch search results: define a cohort, collect a bounded set of listings, preserve the evidence, and compare only compatible observations. The same approach works for an internal sourcing dashboard or a scheduled research report.

> **Availability:** Query [capabilities](/v1/capabilities) before starting a sweep. When `dataEnabled` is false, data calls are unavailable. The examples below describe the implemented API and use illustrative records, not verified marketplace sales.

## Decide what your price series represents

Choose the question before choosing the endpoint. An active-listing series answers what sellers are currently asking. A sold-search series describes displayed prices on listings returned by a sold search. An item series follows repeated observations of one listing.

These series should have different names in your database and in the interface. Mixing them into one line chart creates a number whose meaning changes between observations.

| Series | Source | Interpretation |
| --- | --- | --- |
| Active asking prices | `/v1/scrape?sold=false` | Displayed asking prices among the returned listings. |
| Sold-listing observations | `/v1/scrape?sold=true` | Displayed sold-search prices, with unverified dates and accepted-offer amounts. |
| Individual listing history | `/v1/item/{itemId}` | Repeated displayed-price observations for one listing. |

For a first version, use one storefront, one currency, one broad condition, and a narrow model query. Add more cohorts only after the application can explain why a record was included or excluded.

Do not label the result as a transaction feed or a verified market valuation. SoldFetch does not establish the final accepted-offer payment, exhaustive sales volume, or exact product equivalence. Keep that distinction visible wherever a user might act on the summary.

## Give every comparison cohort a stable identity

A cohort is the rule set that decides which observations can be compared. The keyword alone is too weak an identity. A used US-market headset is not interchangeable with a new German-market bundle even when the model name is the same.

Store the full request and a version for your inclusion rules:

```json
{
  "cohortId": "headphones-xm5-us-used-v1",
  "keyword": "sony wh-1000xm5",
  "ebaySite": "ebay.com",
  "itemCondition": "used",
  "sold": true,
  "count": 60,
  "maxPages": 3,
  "matchingPolicyVersion": 1,
  "priceBasis": "displayed_item_price"
}
```

The application-specific fields such as `cohortId` and `matchingPolicyVersion` belong in your system, not in the SoldFetch query string. The API rejects unknown parameters. Send only supported request fields when making the network call.

When you change a rule materially, create a new cohort version or mark a break in the series. Expanding a query to include replacement parts can move the median even if comparable complete-item prices have not changed at all.

## Retrieve the first page with explicit parameters

Use `keyword`, not `query`, and use `itemCondition` for the broad condition filter. Sold search is the default, but spelling out `sold=true` makes a saved request easier to audit.

```sh
curl --get 'https://soldfetch.com/v1/scrape' \
  --data-urlencode 'keyword=sony wh-1000xm5' \
  --data-urlencode 'ebaySite=ebay.com' \
  --data-urlencode 'itemCondition=used' \
  --data-urlencode 'sold=true' \
  --data-urlencode 'count=60' \
  --data-urlencode 'page=1' \
  --header "API-KEY: $SOLDFETCH_API_KEY"
```

Search returns a flat object with `items`, `page`, `totalItems`, `totalResults`, `hasNextPage`, warnings, and the fetch timestamp. `totalItems` is the number of returned rows on this page. A requested count is a maximum, not a promise that exactly that many rows will arrive after filtering and deduplication.

For sold rows, read `soldPrice` and `soldCurrency`. Active rows use `currentPrice` and `currentCurrency`. Keep `shippingPrice`, `shippingCurrency`, `totalPrice`, `condition`, `listingType`, `scrapedAt`, and the quality fields alongside the amount.

Search sale dates are supplier-reported and unverified. A dashboard labeled “observed today” can use the fetch timestamp accurately. A dashboard labeled “all sales today” would require evidence this integration does not provide.

## Turn each row into an auditable observation

Keep the original decimal amount and currency before performing calculations. When shipping is unknown, do not turn it into zero. Use `totalPrice` only when it is available and your series is intentionally comparing item-plus-shipping amounts.

This example normalizes sold and active rows into a common storage shape without erasing the distinction:

```js
export function normalizeObservation(row, cohort, runId) {
  const sold = row.listingType === "sold";
  return {
    runId,
    cohortId: cohort.cohortId,
    policyVersion: cohort.matchingPolicyVersion,
    itemId: row.itemId,
    ebaySite: cohort.ebaySite,
    sourceUrl: row.url,
    listingType: row.listingType,
    title: row.title,
    condition: row.condition,
    amount: sold ? row.soldPrice : row.currentPrice,
    currency: sold ? row.soldCurrency : row.currentCurrency,
    shippingAmount: row.shippingPrice,
    shippingCurrency: row.shippingCurrency,
    landedAmount: row.totalPrice,
    observedAt: row.scrapedAt,
    recordedAt: new Date().toISOString(),
    supplierSaleDate: sold ? row.endedAt : null,
    priceBasis: row.quality.priceBasis,
    exactMatchVerified: row.quality.exactProductMatchVerified,
    saleDateVerified: row.quality.saleDateVerified,
  };
}
```

A range lower bound is different from a single displayed amount. Exclude `priceBasis: "range_lower_bound"` from a single-price comparison unless your product has a specific way to represent price ranges. Never hide this decision in a numeric conversion.

Record why your application excluded a listing: incompatible currency, missing price, suspected accessory, bundle mismatch, or uncertain condition. An analyst can then review the matching policy without guessing why a count changed.

## Store collection runs separately from observations

A run describes your attempt to collect data. An observation describes what a successful response contained. Keep these concepts in separate tables so an outage cannot masquerade as a price collapse.

For each run, record the cohort version, start and finish times, pages attempted, pages completed, request IDs, usage units, stop reason, and outcome. For each observation, retain listing identity, amounts, context, timestamps, and inclusion decisions.

A useful uniqueness rule for a run is `(run_id, ebay_site, item_id)`. It prevents a listing repeated across pages from increasing its statistical weight. Across runs, keep a separate observation identity using the item, storefront, source timestamp, and a hash of the fields you compare. That helps distinguish a cached observation from a genuine update.

Store a failed fetch as a run failure with an error code. Do not insert rows with zero prices, fabricate an empty successful run, or overwrite the last complete snapshot. A user should be able to distinguish “no comparable listings returned” from “we could not refresh this cohort.”

## Bound pagination and stop on repetition

Search pagination is inferred. Pages can overlap, and the underlying listing set can change during a sweep. `hasNextPage` helps decide whether to try another page, but it does not certify that you have collected every relevant listing.

Give the sweep a maximum page count and stop if it returns no new item IDs. The collector below accepts a `fetchPage` function that performs one authenticated request and returns the JSON body:

```js
export async function collectCohort(fetchPage, maxPages = 3) {
  const seen = new Set();
  const items = [];
  let pagesCompleted = 0;
  let stopReason = "page_budget";

  for (let page = 1; page <= maxPages; page += 1) {
    const result = await fetchPage(page);
    if (!Array.isArray(result.items)) {
      throw new TypeError("Search response is missing items");
    }
    pagesCompleted += 1;
    let added = 0;
    for (const item of result.items) {
      if (seen.has(item.itemId)) continue;
      seen.add(item.itemId);
      items.push(item);
      added += 1;
    }
    if (result.items.length === 0) {
      stopReason = "empty_page";
      break;
    }
    if (added === 0) {
      stopReason = "repeated_page";
      break;
    }
    if (!result.hasNextPage) {
      stopReason = "no_next_page";
      break;
    }
  }
  return { items, pagesCompleted, stopReason };
}
```

Persist the stop reason with the run. A three-page budget is a sampled collection policy, not an exhaustive census. If the fetch helper fails partway through, mark the run incomplete and retain its diagnostics rather than publishing its summary as a complete snapshot.

## Compare like-for-like prices and expose sample size

Only calculate a summary after applying your cohort rules. At minimum, keep marketplace, currency, listing type, condition policy, and price basis consistent. Use the same pagination budget when comparing runs, and show the included and excluded counts.

A median is often a useful descriptive statistic for a set with extreme prices, but it cannot repair bad matches. A group of accessories may have a tidy distribution and still be irrelevant to the complete product you intended to track.

For example, suppose yesterday's accepted sample contained 28 used complete units and today's contained only four. A 12% change deserves review, but the reduced sample size also deserves attention. The alert should include both facts, source links, and the observation time.

Separate changes in an individual listing from changes in the cohort. A listing's displayed price may stay constant while the cohort median moves because different listings appear. Those are distinct events and should produce distinct explanations.

## Schedule around freshness and your request budget

Successful page requests consume one unit each, including cache hits and successful empty pages. Failed requests do not consume monthly units. Fetching three pages once a day for 30 days therefore uses up to 90 successful request units before item enrichment or additional sweeps.

Plan around the shared workspace limits: 60 data requests per minute and five concurrent requests. A dashboard user and a scheduled worker share those limits. Keep a single queue or another coordination mechanism so independent workers do not all start their sweeps at the same instant.

Successful operation results are cached for five minutes. A polling interval shorter than that can repeatedly retrieve the same observation while consuming units. Choose cadence from the product need and the response's fetch timestamp rather than assuming a faster loop always creates fresher data.

For longer collections, the bounded asynchronous search API can perform multiple pages. Keep a page cap there too, preserve the job's status, and download results within their retention window. The migration guide describes the job routes and their limits.

## Retry without duplicating work or creating false alerts

Use a unique `Idempotency-Key` for a logical page request and retain it for transport retries. A completed request replay within the 24-hour window costs zero additional units. The same key with different normalized parameters returns a conflict, so include the run and page in your internal key design.

A 409 can also mean the original request is still running or previously failed. Inspect the message. Wait within a bounded deadline for in-progress work; a definitively failed request requires a new key for a new attempt. Do not continuously rotate keys while the original operation may still be running.

Handle quota exhaustion separately from short-term rate limiting. Respect `Retry-After` when supplied, cap attempts for temporary 5xx failures, and stop if capabilities says data access is disabled. Suppress price-change alerts for incomplete runs. Send a collection-health alert instead if the last usable snapshot becomes too old.

## Make the result explainable in the UI

Show the cohort definition, the number of included observations, the collection time, and the reason the sweep stopped. Link summary points to their underlying listings. Keep a visible distinction between asking prices, displayed sold-listing prices, and unknown final transaction amounts.

Before release, test repeated pages, mixed currencies, a null shipping value, a range price, and a failed second page. Confirm that none of these creates a fabricated drop in the chart. A trustworthy tracker should be able to show less data when evidence is incomplete and still explain the last reliable observation.

## References and next steps

- [Search API documentation](/docs#search) — accepted request parameters and response fields.
- [Capabilities](/v1/capabilities) — enabled sites, availability, and collection limits.
- [Usage and request history](/docs#account) — reconcile a sweep with request accounting.
- [eBay item API guide](/blog/ebay-item-api-guide) — enrich selected candidates with listing details.
- [Migration and compatibility guide](/blog/ebay-api-compatibility) — job behavior, auth, and rollout checks.