eBay Price & Inventory Monitor avatar

eBay Price & Inventory Monitor

Pricing

from $100.00 / 1,000 results

Go to Apify Store
eBay Price & Inventory Monitor

eBay Price & Inventory Monitor

Price and inventory monitor for eBay resellers, dropshippers, and flippers: instant alerts the moment a competitor's price drops or rises, an item sells out, or a new listing appears.

Pricing

from $100.00 / 1,000 results

Rating

0.0

(0)

Developer

Alejo Sosa

Alejo Sosa

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

4 days ago

Last modified

Share

An eBay price and inventory scraper built for one job: stop refreshing eBay by hand. Point this Actor at the listings you care about — a competitor's store, a product you resell, a search you'd otherwise keep re-running — and it tells you the moment something changes: a price drop, an item selling out, a new listing appearing, or one disappearing. No coding required: add URLs, put it on a schedule, and let it push alerts to you instead of checking manually.

Intended for personal research and competitive price monitoring — you're responsible for using it in a way that complies with eBay's own terms of service.

⚠️ Before your first run: enable Residential proxy. eBay blocks Apify's default proxy outright — without Residential proxy enabled on your Apify account, every request fails immediately with a 403 error. Check Proxy & Anti-scraping → Residential proxy under your account settings first (billed separately, per GB, by Apify — see Reliability & cost for what that typically costs). This is the single most common reason a first run fails, and takes 30 seconds to check.

Jump to: Who this is for · Markets · Reliability · Quick start · Pricing · Example output · Agents & MCP · Input · Output · Troubleshooting

Who this is for

  • Resellers tracking a supplier listing's price so a margin-eroding increase doesn't slip by.
  • Dropshippers watching multiple source listings for price or stock changes before a customer order fails.
  • Arbitrage buyers / flippers catching a price drop or restock the moment it happens, before other buyers do.
  • Dealers and retailers keeping tabs on what a competitor charges for the same or similar items.
  • Collectors watching a specific listing for a price drop or restock on something scarce.
  • AI agents and automation pipelines consuming structured, MCP-ready output instead of a human checking a page.

Markets supported

Works on any eBay country site — not just ebay.com — with no extra setup, configuration, or charge: point a URL at ebay.co.uk, ebay.de, ebay.com.au, ebay.ca, ebay.fr, ebay.it, ebay.es, or any other eBay country domain and it's tracked the same way. The proxy pool is deliberately not pinned to a country, so adding more market coverage never means configuring anything new.

Built for reliability

Monitoring only earns your trust if it doesn't quietly break. A few things this Actor does specifically so a bad run never costs you real data:

  • A failed check never erases your history. If a listing can't be scraped this run, its last known price and stock carry forward instead of being wiped or falsely reported as removed — eBay's occasional bot-block never looks like a fake "item deleted" event.
  • Blocked requests get a fresh identity, not a retry against the same wall. A 403 is detected automatically and retried on a different proxy session, out of a pool of up to 50 per run — see Reliability & cost.
  • Currency swaps can't fake a price change. Every price is normalized before two runs are compared, and if that lookup ever fails, the Actor declines to guess rather than risk a false alert — see Currency normalization.
  • You're told when something's actually wrong, separately from routine alerts. A sustained scrape-failure streak or a listing stuck on stale data for 3+ runs fires its own alert, so a real problem doesn't get lost in day-to-day price/stock notifications — see Reliability alerts and Possibly-delisted items.
  • Actively maintained. Check this Actor's Information tab on its Apify Store page for the full changelog — fixes and improvements ship regularly, not just at launch.

Quick start

  1. Add one or more eBay URLs to URLs to monitor — individual item pages, search result pages, or seller pages. Any eBay country site works (ebay.co.uk, ebay.de, ebay.com.au, etc.), not just ebay.com — no extra setup or charge either way. Search/ seller pages track up to 5 organic items found on that page — sponsored/promoted listings are skipped, so you're never tracking or paying for an ad slot.
  2. Optionally set a price drop alert threshold and an alert webhook URL to get notified automatically the moment something worth reacting to happens.
  3. Run the Actor once to establish a baseline.
  4. Open the Schedules tab in the Apify Console and set it to run automatically (e.g. daily at 9am). Every run after the first compares against the one before it.

Requires residential proxy access on your Apify plan (billed per GB by Apify, separately from this Actor's own price) — eBay blocks Apify's default proxy outright. See Reliability & cost for the full picture.

Pricing

This Actor uses Apify's Pay-per-event pricing — you're charged per tracked item written to the results, not per compute unit, so your cost stays predictable regardless of how eBay's bot detection behaves on a given run. It's also priced for signal, not just effort: an item that's new, removed, or has a price/stock change costs the full Result price; an item that's simply confirmed unchanged from last run costs the much smaller Checked, unchanged price instead — tracking 20 stable listings doesn't cost the same as tracking 20 volatile ones.

EventPrice
Result — an item that's new, removed, or has a price/stock change$0.12 (Free/Bronze), $0.11 (Silver), $0.10 (Gold/Platinum/Diamond)
Checked, unchanged — an item confirmed unchanged from the previous run$0.01
Actor start$0.00005 per GB of memory allocated (effectively free)
AI enrichment — only if you enable it, once per item actually enrichedSee AI enrichment
Price insight — only if you enable it, once per signal actually fired$0.015

Some examples to help you estimate:

  • Tracking 5 items in a one-off run, all new, costs about $0.60.
  • Tracking 20 items checked once a day for a month (≈600 checks): if none of them ever change, that's about $6 at the Free/Bronze rate; if every check finds a change, that's the old worst case of about $72. Most real usage lands well below that ceiling, since most checks on most days don't find a change.

Tip: track individual item URLs, not a search page, if you want the lower end of that range. A search or seller page's result set can reshuffle between runs (ranking/sponsored rotation) independent of whether any tracked product's price actually moved, so items discovered that way tend to look "new" more often than items tracked by a direct ebay.com/itm/<id> URL, which stays the same item run after run. If you already know which listings you want to watch, add their item URLs directly — you'll see more Checked, unchanged events (cheap) and fewer Result events (the full price) than a search page tracking the same underlying items would produce.

What you'll see

Say you're tracking a competitor's listing and its price quietly drops from $334.99 to $289.99. The next scheduled run picks it up and adds a record like this to your changes dataset:

{
"title": "Nintendo Switch OLED Console - White",
"priceChanged": true,
"priceChangePercent": -13.2,
"previous": { "price": 334.99, "quantityAvailable": 1 },
"current": { "price": 289.99, "quantityAvailable": 4 },
"listingUrl": "https://www.ebay.com/itm/123456789012"
}

If a priceDropThresholdPercent is set and this crosses it, the same information is also POSTed to your alertWebhookUrl as JSON — ready to plug into Zapier, Make.com, or your own endpoint, and from there route to Slack, Discord, email, or a spreadsheet. See Output for the full field reference.

For resellers/dropshippers tracking a supplier or competitor listing, a price going up matters just as much as a price going down — it's the one that silently eats your margin. Set priceIncreaseThresholdPercent (off by default) to also alert when a tracked price rises by at least that percentage.

For ongoing competitor monitoring: add the specific competitor listing URLs you want to watch (see the pricing tip above for why direct item URLs beat a search page here), set priceDropThresholdPercent to whatever counts as "worth reacting to" in your niche, point alertWebhookUrl at a Zapier/Make webhook feeding into Slack or Discord, and schedule the Actor to run daily. From then on you'll get a changes dataset each day showing exactly which competitors dropped price or sold out, plus a push notification the moment a big drop happens — instead of manually rechecking listings. A search page still works well for the other common case — discovering new listings in a category as they appear — where you want the result set to refresh each run rather than track a fixed list.

Automating with agents, MCP, and downstream tools

Every record this Actor produces — the changes dataset, the snapshot dataset, and the webhook payload — is flat, typed JSON with no free-text parsing required, so it's MCP-ready output: just as usable as a trigger for an LLM agent or automation pipeline (called directly via Apify's MCP server, or through the Apify API) as it is for a human dashboard. Two patterns that fit well:

  • Webhook-triggered agent. Point alertWebhookUrl at an endpoint that hands the payload to an agent (e.g. a Zapier/Make step that calls your agent's API, or your own webhook receiver). Because the payload already carries previous/current prices, priceChangePercent, and stockHitZero, the agent doesn't need to re-derive what changed — it can go straight to deciding what to do about it (renegotiate a listing, restock, alert a buyer).
  • Dataset-polling agent. An agent that runs on its own schedule can instead call the Apify API for the changes dataset directly and treat a nonzero result as its trigger, without needing a webhook receiver at all.

Nothing above requires special configuration — it's the same output every run already produces.

How it works

  1. You give it a list of eBay URLs: individual item pages, search result pages, or seller pages.
  2. Search/seller pages are crawled first to discover individual item URLs (up to 5 per page, to keep run time and proxy cost bounded), which are then visited one by one.
  3. Each item page is parsed with CheerioCrawler (plain HTTP + HTML parsing, no browser) using JSON-LD structured data where available, falling back to DOM selectors. This keeps compute cost low. If a page doesn't yield usable data over plain HTTP (e.g. it requires JS rendering or returns a bot-check page), the Actor automatically retries that URL with PlaywrightCrawler.
  4. The current results are compared against the previous run's snapshot, which is stored in a named, persistent key-value store (ebay-monitor-state, key PREVIOUS_SNAPSHOT) — not the run's default key-value store, which on Apify is fresh and empty for every run and would not actually persist anything across scheduled runs. This store is shared across every run of the Actor, including ones with a different urls input (e.g. a one-off test run tracking different items than your regular schedule). Both the diff and the persisted update are scoped to only the items the current run actually requested, so a run with a narrower or different URL list can't misreport your other tracked items as removed, or erase their history — it merges its own results in rather than replacing the whole store.
  5. Two datasets are written: a full snapshot of every tracked item, and a changes dataset containing only items that are new, removed, or have a price/stock change.
  6. If alertWebhookUrl is set, a JSON payload of "alert-worthy" changes (price drops beyond your threshold, or items that hit zero stock) is POSTed to that URL. A summary is always logged to the run log, and also written to the run's Output tab, regardless of webhook configuration.

Input

FieldTypeDefaultDescription
urlsstring[]— (required)eBay item URLs, search result pages, or seller pages to track. Any eBay country site works, not just ebay.com.
checkIntervalHoursnumber24Documentation only, recorded for your reference. Configure actual run frequency via Apify's Schedule feature.
priceDropThresholdPercentnumber10Minimum price drop (%) vs. the previous run that triggers a webhook alert.
priceIncreaseThresholdPercentnumber0 (off)Minimum price increase (%) vs. the previous run that triggers a webhook alert. Off by default — useful for resellers/dropshippers watching a supplier or competitor listing, where a rising price erodes margin the same way a drop erodes a buyer's deal.
alertWebhookUrlstring— (optional)If set, alert-worthy changes are POSTed here after each run. Paste a Discord or Slack incoming webhook URL directly — no relay needed if notificationFormat matches.
notificationFormatstring"raw"Webhook payload shape: raw (full JSON, for Zapier/Make/your own endpoint), discord, or slack.
alertOnStockZerobooleantrueInclude items whose stock dropped to 0 in the webhook alert.
alertDigestIntervalHoursnumber0 (off)Hold price drop/increase alerts and send them together at most once per this many hours, instead of one webhook per run. See Alert digests before enabling.
targetCurrencystring"USD"3-letter ISO code every price is converted to for accurate cross-run comparison. See Currency normalization.
aiEnrichmentbooleanfalseExtract brand/model/category from each title via an LLM. See AI enrichment before enabling.
enableInsightSignalsbooleanfalseFlag a 3-run price-drop streak, a new all-time-low price, or a price well below the item's own rolling average. Also reorders the dataset so the most actionable items come first — see Price insight signals before enabling.
trackListingVariationsbooleanfalseTrack each size/color/etc. combination on a variation listing as its own item. See Tracking listing variations before enabling — it's a real cost multiplier, not just a data-shape change.
stateNamespacestring— (optional)Leave empty unless you run more than one schedule of this Actor tracking different items — see Running more than one schedule before you need this.

Example input:

{
"urls": [
"https://www.ebay.com/sch/i.html?_nkw=nintendo+switch+oled",
"https://www.ebay.com/itm/123456789012"
],
"priceDropThresholdPercent": 15,
"alertWebhookUrl": "https://hooks.example.com/ebay-alerts",
"alertOnStockZero": true
}

Output

The same current-state records are written to three places every run: the run's default dataset (what the Console's Dataset/API/export tabs show for a run), and two named datasets, snapshot and changes, for consumers that specifically want the full-state history (snapshot) or only the diffs (changes) kept separate from run to run.

Run summary (Output tab)

Every run writes a small summary to its default key-value store under the OUTPUT key, which the Apify Console shows directly on the run's Output tab — no need to dig through the log or open a dataset to see whether the run actually worked:

{
"trackedItemCount": 2,
"directlyTrackedItemCount": 2,
"freshlyScrapedCount": 2,
"carriedForwardCount": 0,
"unrecoverableCount": 0,
"reliabilityDegraded": false,
"proxyHealthDegraded": false,
"siblingRunsDetected": 0,
"startupJitterMs": 0,
"changesDetected": 1,
"emptyListingPageCount": 0,
"possiblyDelistedCount": 0,
"possiblyDelisted": [],
"priceInsightCount": 0,
"summary": "Monitor run summary: 1 price drop(s) >= 10%, 0 item(s) out of stock, 0 new listing(s), 0 listing(s) removed, 1 total change(s)."
}
  • freshlyScrapedCount — items successfully scraped this run.
  • carriedForwardCount — items that failed to scrape this run but were filled in from the last known snapshot (see Reliability & cost); not a sign anything needs fixing.
  • unrecoverableCount — items that failed this run and have no previous snapshot to fall back on (only possible on an item's very first run). Worth checking manually if this is nonzero.
  • reliabilityDegraded — whether the scrape failure rate has been sustained across recent runs; see Reliability alerts.
  • proxyHealthDegraded — whether this run (or the one right before it) saw a near-total block rate across your directly-tracked items — a different, faster-reacting signal than reliabilityDegraded above. See Reliability & cost for what this Actor automatically does about it.
  • siblingRunsDetected — other runs of this same Actor that were active under your account right when this run started, e.g. another schedule firing at the same time. Purely informational — it doesn't change this run's behavior. 0 if none were detected, or if this run doesn't have permission to list other runs (a restricted, non-owner token). See Running more than one schedule.
  • startupJitterMs — how long this run actually waited (in milliseconds, up to 2 minutes) before its first request to ebay.com, in direct response to siblingRunsDetected above. 0 whenever no sibling run was detected — a run with nothing else happening never waits.
  • emptyListingPageCount — search/seller pages (not individual items) in your input that returned zero item links even after the browser fallback, likely because eBay changed that page's markup. Worth checking manually if this is nonzero.
  • possiblyDelistedCount/possiblyDelisted — durable, always-on version of the Possibly-delisted items webhook alert: items running on carried-forward data for 3+ consecutive runs, visible here every run regardless of whether alertWebhookUrl is configured or that alert's webhook POST succeeded.
  • priceInsightCount — how many price insight signals fired this run. Always 0 when enableInsightSignals is disabled. Per-item detail lives on each item's row in the dataset, not repeated here.

snapshot dataset

One record per tracked item, every run, representing its full current state:

{
"itemId": "123456789012",
"title": "Nintendo Switch OLED Console - White",
"price": 289.99,
"currency": "USD",
"normalizedPrice": 289.99,
"shippingCost": 0,
"condition": "New",
"quantityAvailable": 4,
"sellerUsername": "example_seller",
"sellerFeedbackScore": 15234,
"watchersCount": 12,
"bidCount": null,
"currentBid": null,
"timeRemaining": null,
"reserveNotMet": false,
"change24hPercent": -5.2,
"change7dPercent": -12.1,
"change30dPercent": null,
"aiBrand": null,
"aiModel": null,
"aiCategory": null,
"aiEnrichmentAttempted": false,
"consecutiveDropStreak": null,
"isNewAllTimeLow": false,
"isUnderpricedVsHistory": false,
"dataStale": false,
"dataStaleReason": null,
"variationKey": null,
"parentItemId": null,
"listingUrl": "https://www.ebay.com/itm/123456789012",
"scrapedAt": "2026-08-01T12:00:00.000Z"
}

aiBrand/aiModel/aiCategory stay null unless aiEnrichment is enabled — see AI enrichment. aiEnrichmentAttempted is true once enrichment has actually run for an item's current title, even if it found nothing (a bundle/lot listing with no identifiable brand, say) — that distinction is what keeps such an item from being re-enriched every run forever just because its brand/model/category are still null. consecutiveDropStreak/isNewAllTimeLow/isUnderpricedVsHistory stay null/false unless enableInsightSignals is enabled — see Price insight signals.

dataStale is true only when this run couldn't scrape the item fresh (eBay bot-blocked the request, etc.) and this row is its last successfully-scraped data carried forward instead — scrapedAt reflects that earlier successful run, not this one. Check this before trusting a row as "what eBay shows right now." An item stuck on dataStale: true for 3+ consecutive runs also shows up in possiblyDelisted on the run's OUTPUT record — see Possibly-delisted items.

dataStaleReason explains why dataStale is true (null whenever it's false): "blocked" means every crawler this run exhausted its retries outright. "skipped-recently-confirmed" means the browser-fallback retry pass was skipped entirely — the plain-HTTP pass hit a full block, but this item had already been successfully scraped within the last hour, so the recent price was served instead of paying for a second full retry ladder a fresh block is unlikely to survive either. Both mean the same thing for trusting the row (it's not fresh this run), but only the second is a deliberate cost-saving decision rather than eBay actually resisting every attempt.

variationKey/parentItemId are non-null only on a row produced by trackListingVariations — see Tracking listing variations. Both stay null for every ordinary row, including every row in the example above.

bidCount/currentBid/timeRemaining are only ever non-null for auction-format listings — a Buy It Now listing like the example above always has them null. reserveNotMet is false for both "no reserve" and "reserve met"; only a genuine "Reserve not met" badge on the page sets it true, since the two can't be distinguished from page content otherwise.

change24hPercent/change7dPercent/change30dPercent track the price trend over a rolling window (up to 30 tracked data points per item, not 30 days), separate from the single-previous-run priceChangePercent in the changes dataset below. Each is null until the item has been tracked long enough for that window to have a baseline — a brand-new item has all three null for its first month, as in the change30dPercent above. If you schedule this Actor more often than roughly once a day, note that the 30-point history window covers less than 30 days, so change7dPercent/change30dPercent can go permanently null for an item once its older points age out, not just during that first-month ramp-up.

price/currency are the raw values eBay actually displayed for this scrape. normalizedPrice is price converted to targetCurrency — see Currency normalization — and is what change detection actually compares across runs. normalizedPrice is null when conversion wasn't possible (unrecognized currency, or the exchange-rate lookup failed that run).

firstSeenAt is the timestamp of the first run this Actor ever observed this item, and stays fixed across every later run — unlike scrapedAt, which updates every run. It's what listingLifespanDays (below, in the changes dataset) is computed from once the item is later detected removed.

changes dataset

Only items with detected differences from the previous run, with before/after values:

{
"itemId": "123456789012",
"listingUrl": "https://www.ebay.com/itm/123456789012",
"title": "Nintendo Switch OLED Console - White",
"changeTypes": ["priceChanged", "stockChanged"],
"newListing": false,
"listingRemoved": false,
"listingLifespanDays": null,
"priceChanged": true,
"priceChangePercent": -13.2,
"stockChanged": true,
"stockHitZero": false,
"previous": { "price": 334.99, "quantityAvailable": 1 },
"current": { "price": 289.99, "quantityAvailable": 4 },
"detectedAt": "2026-08-01T12:00:00.000Z"
}

listingLifespanDays is non-null only when listingRemoved is true: the number of days between this Actor's first-ever observation of the item (firstSeenAt in the main dataset, carried forward unchanged across every run since) and the run that detected it gone. This is not exact "time to sell" — a listing can disappear for reasons other than a sale (delisted, ended without a buyer, temporarily hidden), and the clock only starts from when this Actor first saw it, not from when it was actually first listed on eBay. It's the closest signal derivable from data this Actor already collects, with no new scraping.

Webhook alert payload

Sent as a POST with Content-Type: application/json when alertWebhookUrl is set and at least one alert-worthy change is detected. The shape depends on notificationFormat:

raw (default) — the full structured payload, for Zapier ("Catch Hook" trigger), Make.com ("Custom Webhook" trigger), or your own endpoint:

{
"triggeredAt": "2026-08-01T12:00:00.000Z",
"priceDropThresholdPercent": 10,
"priceIncreaseThresholdPercent": 15,
"alertOnStockZero": true,
"changes": [ /* array of change objects, same shape as the changes dataset above */ ],
"reliabilityEvent": "degraded" /* only present on a reliability transition, see below */,
"possiblyDelisted": [ /* only present when nonempty, see below */ ],
"emptyListingPages": [ /* only present when nonempty, see below */ ],
"priceInsights": [ /* only present when nonempty, see below */ ],
"alertDigestIntervalHours": 24 /* only present when alertDigestIntervalHours is set, see Alert digests */
}

discord{ "content": "Monitor run summary: ...\nPrice drop: ... (https://...)" }, a plain-text summary in the envelope Discord's incoming webhooks actually expect. Paste a Discord channel's webhook URL straight into alertWebhookUrl — no relay needed.

slack — the same summary as { "text": "..." }, for a Slack incoming webhook URL.

Reliability alerts

Separate from price/stock alerts, the webhook payload can include a "reliabilityEvent" field ("degraded" or "recovered") when at least half of your directly-tracked items have failed to scrape (with no previous data to fall back on) across 3 consecutive runs — a sign of something sustained, like eBay changing its markup, rather than one bad run. It fires once on the transition into and out of that state, not on every run while it persists, so it won't spam your webhook. This is separate from the per-run unrecoverableCount/carriedForwardCount fields on the OUTPUT tab, which reflect only the current run.

Possibly-delisted items

The per-run carry-forward safety net (see How it works) is deliberately generous — it keeps serving an item's last known data indefinitely rather than ever failing, so a permanently-delisted item would otherwise just go silently stale forever with no alert. The webhook payload can include a "possiblyDelisted" array (

{ itemId, title, listingUrl, consecutiveCarriedForwardRuns }
) once an item has been running on carried-forward data for 3 consecutive runs. Like the reliability alert, this fires once when the streak first crosses the threshold, not on every run after — if the item later scrapes successfully again, its streak resets, and a future 3-run streak would trigger a fresh alert.

Empty listing pages

If a search or seller page URL in your input returns zero item links, even after retrying with a full browser, that's a sign eBay changed that page's markup (or the query/store genuinely has no matching listings), not a normal empty result. The webhook payload can include an "emptyListingPages" array ({ listingUrl }) whenever this happens on a run, in addition to the emptyListingPageCount field always written to the OUTPUT tab (see Run summary).

Price insight signals

When enableInsightSignals is on, the webhook payload can include a "priceInsights" array ({ itemId, title, listingUrl, signal }, where signal is { type: "consecutiveDrops", consecutiveDrops: 3 }, { type: "allTimeLow", price: 42.5 }, or { type: "underpricedVsHistory", price: 42.5, rollingAverage: 55, percentBelowAverage: 23.6 }) whenever a signal fires on a run. See Price insight signals for what these mean and how they're computed.

AI enrichment (optional)

Off by default. When aiEnrichment is enabled, each item's title is sent to an LLM to extract structured aiBrand/aiModel/aiCategory fields — useful for filtering, grouping, or searching a large tracked list where titles are inconsistently worded. Uses Apify's own OpenRouter proxy, so this Actor never needs (or asks you for) a separate OpenAI/Anthropic API key — the LLM call is billed the same way compute and proxy usage already are, through your Apify account.

Only items actually scraped fresh this run are enriched, and only if their title is new or changed since last run — a daily-scheduled item with an unchanged title is never re-enriched (and never re-charged) on subsequent runs. Enrichment is billed as a separate ai-enrichment event, charged once per item actually enriched.

Before enabling this, add a custom ai-enrichment event with your own price to this Actor's monetization setup in the Apify Console (Actor pricing step) — it isn't billable until you do. If you're the Actor's owner testing this yourself, note that Apify's OpenRouter proxy recommends a paid Apify plan; free-tier accounts may be blocked by anti-fraud protections.

Price insight signals (optional)

Off by default. A single run-over-run diff can be noisy — a search page's result ranking can reshuffle between checks independent of whether any price actually moved, so one drop in isolation isn't always worth reacting to. When enableInsightSignals is enabled, three patterns get flagged specifically because they're harder to fake with noise:

  • 3 consecutive price drops (consecutiveDropStreak: 3 on the item's dataset row, or a consecutiveDrops signal in the webhook payload) — real downward momentum across several checks, not a single-run blip.
  • A new all-time-low price (isNewAllTimeLow: true, or an allTimeLow signal) — this item's price has never been lower since you started tracking it. Never fires on an item's first-ever observation, since every item's first price is trivially "the lowest so far".
  • Underpriced vs. its own history (isUnderpricedVsHistory: true, or an underpricedVsHistory signal) — this item's price just dropped to 15%+ below its own rolling average, computed entirely from this Actor's own persisted price history rather than eBay's sold/completed-listings data (which requires a logged-in session this Actor deliberately doesn't use). Needs at least 3 prior price points before it can fire, so a newly-tracked item can't trivially trigger it off a one- or two-point "average".

Both are computed entirely from the price history this Actor already persists for change24hPercent/change7dPercent/change30dPercent above — no extra scraping, no external API call. Each fires once, on the run it first becomes true, the same way the reliability and possibly-delisted alerts do — not re-flagged on every run while the condition continues to hold. Billed as a separate price-insight event, once per signal actually fired (an item can fire both signals in the same run, billed as two events).

Before enabling this, add a custom price-insight event with your own price to this Actor's monetization setup in the Apify Console (Actor pricing step) — it isn't billable until you do.

enableInsightSignals also changes the order rows are written to the dataset in: instead of scrape order, an item with a fired signal comes first (new all-time low, then underpriced vs. history, then a longer drop streak before a shorter one), and everything else follows sorted by its most recent 24-hour price-change magnitude, biggest mover first. This only reorders the dataset — nothing about diffing, billing, or the changes/webhook payload changes. With enableInsightSignals off, rows stay in plain scrape order as they always have.

Alert digests (optional)

Off by default — every alert-worthy change sends its own webhook immediately, as it always has. If you check often enough that this gets noisy, set alertDigestIntervalHours to hold price drop/increase alerts and send them together at most once per that many hours instead of one webhook per run.

A stock-out (stockHitZero) alert always sends immediately regardless of this setting — running out of stock is time-sensitive in a way a price change usually isn't, so it's never held back. Reliability, possibly-delisted, empty-listing-page, and price-insight alerts aren't held either; this only affects price drop/increase alerts specifically, since those are the noisy, frequent ones a digest is meant for.

When a digest fires, changes in the webhook payload contains every held price alert since the last digest was sent, not just this run's — each entry still carries its own detectedAt timestamp, so you can tell which run actually found it.

Tracking listing variations (optional)

Off by default. An eBay listing with a size/color/etc. picker only ever shows one price for the whole listing until a shopper actually picks a combination — this Actor's ordinary single-row extraction has no way to know which combination that displayed price/stock belongs to, and on a listing where nothing is pre-selected, it can end up reading an unrelated number entirely (a real example encountered while building this: a t-shirt listing returning a raw price of $750). Every tracked listing has always had this blind spot; trackListingVariations is what actually fixes it, rather than just working around it.

When enabled, and a tracked item URL has eBay's variation picker, this Actor clicks through every real combination (capped at 24 per listing) the same way a shopper would, and tracks each one as its own item — itemId becomes {parentItemId}::{variationKey} (e.g. 186349877487::Size M|Color Black), with parentItemId and variationKey on the dataset row so you can group combinations back to their shared listing. The ambiguous single-row price is never recorded for a listing with variations — only the real per-combination ones.

This changes cost, not just data shape. Each combination bills as its own result/checked-unchanged event, same as any other tracked item — a listing with 8 real combinations now costs roughly 8x what tracking it used to cost. Enable this only for listings where knowing each combination's actual price/stock is worth that.

Known limitations of this feature specifically (unlike the rest of this Actor):

  • eBay's variation picker is an undocumented custom widget, not a native HTML control — this clicks through it using its currently-observed markup, which could change without notice and silently stop matching. If a listing's combinations stop appearing in your output, that's the most likely reason.
  • Variation rows don't yet participate in the carry-forward safety net or possibly-delisted tracking that direct item URLs get — a combination that fails to scrape one run is simply missing from that run's output rather than falling back to its last known price.

Reliability & cost

Why the default input is a search query, not a specific item

Apify runs every published Actor daily against its default (prefilled) input and requires a successful run with a non-empty result — an Actor that fails this for 3 days straight gets flagged "under maintenance," and eventually deprecated if it stays broken. A single hardcoded item URL is a bad fit for that: individual listings end or sell out permanently, and a prefill pointing at a dead item would fail the check forever until someone noticed and updated it. The default input here (https://www.ebay.com/sch/i.html?_nkw=usb+cable) is a broad, high-volume search query instead — whatever's currently live shows up automatically, so there's no specific item ID to go stale (this is a constraint on the default, not a recommendation for your own input — see the pricing tip above for why your own tracked items are usually better off as direct item URLs). This is why search-page tracking is capped at 5 items per page (see above): it bounds cost and run time for this self-healing default just as much as for any user's own search-page input.

eBay's page markup changes periodically and can serve bot-detection challenges to plain HTTP requests. This Actor extracts from JSON-LD structured data first (the most stable source) and falls back to DOM selectors, then to a real browser (Playwright) if HTTP-only extraction comes back empty. If eBay changes its markup significantly, the DOM-selector fallback fields (shipping cost, watchers count, seller feedback score) are the most likely to need updating — itemId, title, and price, sourced primarily from JSON-LD, are more resilient. Note that some listings genuinely don't display a watcher count at all, so watchersCount: null isn't always a sign of a broken selector.

Proxy requirements

eBay's bot detection (Akamai) blocks Apify's default datacenter proxy outright — this Actor requires RESIDENTIAL proxy access on your Apify plan (Actor.createProxyConfiguration({ groups: ['RESIDENTIAL'] })), which is billed per GB. Without it, every request will fail with Request blocked - received 403 status code.

Even with residential proxy, eBay's blocking is probabilistic, not deterministic. Expect occasional request failures regardless of retries — this is normal, not a sign of misconfiguration. Several mitigations are built in:

  • Both crawlers retry each URL up to 3 times, and automatically detect blocked/403 responses and retry with a fresh proxy session rather than reusing a flagged one (retryOnBlocked).
  • A session pool of up to 50 residential identities is used per run, so a single flagged IP doesn't take down the whole run.
  • If an entire run fails to scrape a tracked item, its last known data is carried forward into the persisted snapshot instead of being treated as listingRemoved or silently dropped — so a bad run doesn't erase your price history or trigger false "removed" changes. You'll see a Failed to scrape ... carrying forward last known data warning in the log, and the run's carriedForwardCount output field, when this happens.

For a scheduled monitor, occasional failed items within an otherwise successful run are expected and self-correct on the next scheduled run — you don't need every single item to succeed on every run.

Automatic backoff on a near-total block rate. Occasional single-item failures are normal, as above — but if 80%+ of your directly-tracked items fully exhaust their retries without succeeding (summed across the last few runs if you track only 1-2 items at a time, so a small run's normal noise isn't misread as a pattern — at least 3 attempts total either way), that's different: it usually means eBay is hard-blocking outright, not just unlucky on a few items. When that happens, this Actor automatically reduces its own retry budget — for the rest of the run showing the pattern, and the start of the next one — instead of repeating a full-cost retry ladder that's already shown not to work. See proxyHealthDegraded in the run summary to see when this kicks in. It self-heals: once recent runs return to a normal success rate, the next run returns to the full retry budget automatically. This protects both a single run tracking many URLs (a bad early stretch won't compound into every later item paying the same wasted cost) and a schedule that only ever tracks one or two items per run (three bad runs in a row are recognized as a pattern, not treated as three unrelated one-offs).

Preventive pacing against eBay, on every run. The backoff above only kicks in after a run has already shown a degraded pattern — it's a reactive safety net, not a first line of defense. To reduce the odds of ever tripping it in the first place, this Actor caps itself to at most 2 concurrent requests to ebay.com and waits at least 1 second between consecutive requests to the same domain, on every run, regardless of history. This isn't configurable, because it's cheap (it costs at most a few extra seconds on a typical run) and it lowers baseline block risk for everyone, including a first-ever run with no prior signal to react to.

Running more than one schedule

If you run this Actor under more than one Apify Schedule or Task — for example, one tracking phone listings and another tracking shoe listings — set a different stateNamespace value for each one. This Actor's price history, carry-forward safety net, and every other piece of cross-run state live in Apify storage scoped to your whole account, not to an individual schedule; two schedules left at the default (empty) stateNamespace would silently share and corrupt each other's history rather than staying independent. A single schedule tracking one list of items never needs to set this — the default behaves exactly as it always has.

Separately from stateNamespace, also stagger the schedules' fire times — e.g. :00 and :15 past the hour rather than both at :00. Each run already paces its own requests to ebay.com (see above), but that per-run pacing can't see other runs happening at the same time under a different schedule; two schedules firing together effectively doubles the concurrent request rate against the same site, which raises block risk for both. A few minutes of offset is enough — there's no need to spread them further apart than that.

Each run also checks, right when it starts, whether any other run of this Actor is already active under your account, and warns in the log if so — surfaced as siblingRunsDetected in the run summary too. It stays 0 and silent if this run's token doesn't have permission to list other runs under the account. When it does detect a sibling run, this Actor waits a random amount of time (up to 2 minutes, surfaced as startupJitterMs) before sending its first request to ebay.com — neither run's own schedule keeps its original trigger time, but their actual request bursts stop landing at the same instant, which is what the combined request-rate risk actually comes from. This happens automatically and isn't configurable; a solo run with nothing else happening never waits at all. Staggering your schedules' fire times (above) still avoids the wait entirely, so it's worth doing regardless if you find this coming up often.

The proxy is not pinned to a specific country. Pinning (e.g. countryCode: 'US') shrinks the residential IP pool enough that eBay's detection burns through it noticeably faster than the full pool, so it's left unpinned in favor of a better success rate. The trade-off is that eBay auto-localizes price currency based on the proxy's apparent exit country, so consecutive runs may show the same real price in different currencies (e.g. USD one run, KRW or THB the next). This is handled by currency normalization (below) rather than by pinning the proxy, so it doesn't cost any reliability.

Currency normalization

Because the proxy is unpinned (see above), eBay may display a different currency from run to run for the same item. Comparing raw prices across a currency change would either misfire or require suppressing real price changes, so instead every price is converted to a single targetCurrency (default USD, configurable) via a free exchange-rate lookup — once per run, not once per item. The result is stored as normalizedPrice alongside the untouched raw price/currency, and normalizedPrice is what change detection actually compares.

normalizedPrice is only ever used for that internal comparison — it's never displayed to you as "the price." Every alert and dataset field showing a price shows the raw, actually- scraped price in its actual currency, so what you see always matches what you'll find when you open the listing. normalizedPrice exists purely so a percentage-change alert stays accurate even when the two runs it's comparing happened to be shown in different currencies.

Two free, keyless sources are queried in parallel and merged: Frankfurter (ECB reference rates, the more authoritative source but only ~30 currencies) is primary, and ExchangeRate-API's open endpoint (much broader coverage) fills in anything Frankfurter doesn't track, such as CLP. If both fail for a run (rare — network issue, both APIs down simultaneously), affected items get normalizedPrice: null and the Actor falls back to its previous behavior: comparing raw prices, and leaving priceChangePercent null on a currency mismatch rather than computing a misleading number. This never crashes the run — currency normalization degrades gracefully, it doesn't block scraping.

Troubleshooting

Run fails immediately with a 403 on every URL. Your Apify plan doesn't have residential proxy access enabled, or you've exhausted your proxy data allowance for the billing period. Check Proxy usage under your Apify account's Billing/Usage page.

unrecoverableCount is nonzero on an item's first run. The item failed to scrape and has no previous snapshot to fall back on. Run the Actor again — if it keeps failing for the same URL specifically (not intermittently), double check the URL is a valid, currently-live eBay item page.

Webhook never fires even though prices are clearly changing. Confirm priceDropThresholdPercent isn't set higher than the actual drop you're seeing — smaller drops are still recorded in the changes dataset but don't trigger a webhook alert by design. If priceChangePercent is null on an entry where the price obviously changed, check whether normalizedPrice is also null on either side — that means the exchange-rate lookup failed that run and the Actor fell back to raw-price comparison, which nulls the percent on a currency mismatch rather than guess (see Currency normalization).

Some fields (shippingCost, sellerFeedbackScore, watchersCount) are consistently null. These are sourced from DOM selectors that eBay can change without notice. null watchersCount in particular is often correct — many listings simply don't display one.

Questions or something not covered here?

Use the Issues tab on this Actor's Apify Store page — it's public, so you can see exactly how issues get handled (and how fast) before you even run it.