Shopify Merchant Feed Discrepancy Checker avatar

Shopify Merchant Feed Discrepancy Checker

Pricing

from $40.00 / 1,000 product diagnoseds

Go to Apify Store
Shopify Merchant Feed Discrepancy Checker

Shopify Merchant Feed Discrepancy Checker

Resolve each Shopify variant and show whether the feed, public catalog, initial HTML, or JSON-LD offer disagrees on price, currency, or availability.

Pricing

from $40.00 / 1,000 product diagnoseds

Rating

0.0

(0)

Developer

Olalekan Johnson

Olalekan Johnson

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

2 days ago

Last modified

Share

Unofficial diagnostic for shopping-ad agencies that manage Shopify stores. It answers a narrower question than a feed linter: for one variant, which layer disagrees on price, currency, or availability, and did that same incident clear on the next run?

You supply a Google Merchant feed (TSV, CSV, or XML) and/or an affected-products export. The actor resolves each row to a Shopify variant, then compares the feed with three public storefront layers:

  • the store's public catalog (products.json or product.js)
  • the initial HTML for that variant URL
  • the JSON-LD offer bound to that same variant

The dataset is a fix queue. Each row has the disagreeing layers, a short evidence snippet, a priority, and a stable incident id. Run the same input again and the row comes back fixed, still-broken, or reopened.

This actor is not affiliated with Shopify or Google. It does not log into Merchant Center, and it cannot see which feed Google actually ingested unless you supply that file. A result describes the public page at observedAt. It does not establish what Googlebot fetched on an earlier crawl.

Who it is for

Agencies running shopping ads across several Shopify stores, and in-house marketers who already have a disapproval export or a feed file and need to know where to edit. One run can cover multiple stores. Rows are flat, so a webhook, n8n, Make, or a spreadsheet can sort fixQueuePriority without unpacking nested objects.

Default run

Leave the input empty, or keep the prefilled Dawn theme demo store and useBuiltInSample on. The actor reads the public catalog, writes a four-row feed from live variants, and deliberately changes three of them:

  • one price is raised by 15
  • one availability value is flipped
  • one currency code is swapped
  • one row is left untouched, as a control

It also writes an older supplemental feed that still matches the storefront, so the price, availability, and currency rows include a feed-source timeline. A checked run of that demo returned four complete diagnoses in a few seconds: three p1 mismatches and one clean row. sampleData is true on those rows, and sampleMismatch is true only on the planted errors. The demo feed is rebuilt on every run, so those three incidents stay still-broken until you pass a real feed.

Input

FieldRequiredWhat to put
storesNoOne or more public Shopify origins. The default is the Dawn theme demo. label, market, and currency are optional.
feedUrl or feedTextNoMerchant feed. TSV, CSV, or XML. feedFormat can be auto.
feedGeneratedAtNoWhen that feed was generated. A feed_generated_at column or XML lastBuildDate overrides this.
additionalFeedsNoOlder or supplemental feeds for the same items. They are timeline context, not the comparison baseline.
affectedProductsUrl or affectedProductsTextNoCSV of the rows you care about. If a feed is also present, only matching rows are diagnosed, and an issue column is copied onto the result.
useBuiltInSampleNoDefault true. Used only when you did not supply a feed or an affected-products file.
maxItemsNoDefault 25, maximum 2000.
maxCatalogPagesNoDefault 3 pages of products.json (up to 250 products each) when a row has no product URL.
alertOnlyOnChangeNoDeliver and bill only new, changed, fixed, or reopened rows.
enableBrowserEscalationNoDefault false. Raise memory to 2048 MB before turning it on.
recheckOpenIncidentsNoAlso recheck incidents still open in the state store, using the feed values saved last time.
stateStoreNameNoNamed key-value store for incident baselines. Default shopify-feed-discrepancy-state.

A feed row is recognized by common Merchant columns: id, title, link, price, sale_price, availability, gtin, mpn, sku, brand, and item_group_id. An affected-products file can use item id, offer id, link, price, gtin, sku, variant id, and issue. The effective feed price is sale_price when that column is present, otherwise price.

Private hosts, link-local addresses, and non-HTTP URLs are rejected.

What you get

One dataset row per variant, highest fix-queue priority first.

PriorityMeaning
p1The feed disagrees with the initial HTML or the JSON-LD offer. This is the landing-page mismatch Merchant Center's public price guidance tells merchants to fix.
p2The feed disagrees with the catalog or with the rendered page, and the initial HTML and JSON-LD were not the conflicting layer.
p3The current feed matches the storefront, but another supplied feed does not, or the page layers disagree with each other.
reviewThe variant was ambiguous, or the storefront could not be read. These rows are not billed.
nonePrice, currency, and availability agree on the layers that were read.

coverageStatus is one of complete, partial, empty, blocked, or unsupported. A partial row is missing a layer. A blocked row means the storefront refused the request. Unsupported means the URL did not return a Shopify catalog.

recheckStatus is new, still-broken, fixed, reopened, clean, or not-rechecked. The incident id is a hash of the store host and the variant identity, so it survives a retry and a later scheduled run. An item that fails or stays ambiguous does not replace a previously saved good baseline.

matchMethod is variant_id, gtin, sku, link, link_options, or title, with matchConfidence of high, medium, low, or none. Variant id, GTIN, and SKU are preferred. A product URL that points at several variants with different prices is inconclusive and is not billed.

The key-value store record OUTPUT is the run summary: counts, coverage, and how many events were charged. When the demo feed is used, SAMPLE-FEED and SAMPLE-FEED-PREVIOUS hold the exact files that were compared.

Example: planted price mismatch

This is the shape of a default-run row. Values come from the public Dawn theme demo and the synthetic feed, not from a merchant's Merchant Center account.

{
"incidentId": "inc_fef5549d9ebff214",
"recheckStatus": "new",
"fixQueuePriority": "p1",
"diagnosis": "price_mismatch",
"primaryDisagreement": "feed_vs_html",
"disagreeingLayers": "feed,html,json_ld,catalog",
"coverageStatus": "complete",
"storeUrl": "https://theme-dawn-demo.myshopify.com",
"title": "Puff",
"variantTitle": "Chocolate",
"variantId": "39577057001561",
"sku": "10-039-067",
"variantUrl": "https://theme-dawn-demo.myshopify.com/products/puff-olive-leaf?variant=39577057001561",
"matchMethod": "variant_id",
"matchConfidence": "high",
"matchConfidenceScore": 0.99,
"priceFeed": "480.00",
"currencyFeed": "CAD",
"availabilityFeed": "in_stock",
"priceCatalog": "465.00",
"currencyCatalog": "CAD",
"availabilityCatalog": "in_stock",
"priceHtml": "465.00",
"currencyHtml": "CAD",
"availabilityHtml": "in_stock",
"htmlPriceSource": "selected-variant",
"priceJsonLd": "465.00",
"currencyJsonLd": "CAD",
"availabilityJsonLd": "in_stock",
"jsonLdMatch": "exact",
"themeHint": "dawn",
"feedName": "primary-sample",
"feedConflict": true,
"feedConflictSummary": "primary-sample disagrees with supplemental-previous on price 480.00 vs 465.00. The storefront matches the older feed.",
"sampleData": true,
"sampleMismatch": true,
"chargedEvent": "product-diagnosed",
"observedAt": "2026-10-23T04:38:02Z",
"evidenceSnippet": "feed: 480.00 CAD | catalog: 465.00 available true | html: selected variant price 46500 minor units | json_ld: 465.00 CAD InStock",
"alert": true
}

The same run's untouched control row is diagnosis: "clean", fixQueuePriority: "none", recheckStatus: "clean", with feed, catalog, HTML, and JSON-LD all at 465.00 CAD and out_of_stock.

An unresolved row looks like this and is not billed:

{
"incidentId": "inc_example_unresolved",
"diagnosis": "inconclusive",
"fixQueuePriority": "review",
"recheckStatus": "not-rechecked",
"coverageStatus": "partial",
"matchMethod": "link_product",
"matchConfidence": "low",
"matchConfidenceScore": 0.35,
"chargedEvent": null,
"definitive": false,
"fixQueueReason": "The variant could not be resolved confidently, so no comparison was billed."
}

OUTPUT for the default run:

{
"coverageStatus": "complete",
"itemsConsidered": 4,
"delivered": 4,
"discrepancies": 3,
"clean": 1,
"newIssues": 3,
"charged": { "product-diagnosed": 4, "rendered-diagnostic": 0 },
"chargeLimitReached": false,
"sampleData": true
}

A second run of the same demo keeps the incident ids and returns still-broken for the three planted mismatches. alert is false on those rows because the fingerprint did not change. Turn on alertOnlyOnChange when a schedule should deliver only rows that newly appeared, changed, cleared, or came back.

How matching and layers work

  1. Variant id in the feed link (?variant=) or a variant id column.
  2. GTIN against the catalog barcode.
  3. SKU or MPN against the catalog SKU.
  4. Product handle, then variant title, when the link has no variant id.
  5. Title only when that title hits one variant.

HTML price prefers Dawn's data-selected-variant JSON or a Debut-style ProductJson block, where Shopify's embedded price is in minor units (46500 means 465.00 in CAD or USD). If the HTML selected a different variant than the link, that price is not borrowed. Meta tags (product:price:amount) and schema.org microdata are the fallback. JSON-LD Product and ProductGroup / hasVariant offers are a separate layer and are used only when the offer URL, @id, or a single offer belongs to the requested variant.

Browser escalation stays off unless you enable it. It runs only after a variant is already resolved, and only when the initial HTML and JSON-LD do not yield a comparable offer, or they name a different variant, or they disagree with each other. A completed browser read is billed as rendered-diagnostic instead of product-diagnosed. A failed browser read is not billed as a rendered diagnostic. If the static comparison was already definitive, that static comparison can still be billed.

Pricing

Prices, charged only for a comparison that was actually delivered:

EventPriceWhen
product-diagnosed$0.04A variant was resolved and the static comparison was delivered.
rendered-diagnostic$0.14Browser escalation completed for that variant. This replaces the $0.04 charge for that item.
Platform start$0.005 per GBCharged by the platform once per run. Default memory is 512 MB.

Inconclusive, blocked, unsupported, and empty rows are not billed. A retry of the same result in the same run is not billed twice. alertOnlyOnChange does not bill rows it holds back. Set the run's maximum total charge in Apify; the actor stops before the next comparison that would exceed it. maxItems caps how many variants are fetched.

Worked examples, before the platform start event:

  • Demo or trial: 4 static diagnoses = 4 × $0.04 = $0.16.
  • Weekly agency pass: 3 stores × 80 variants, all static = 240 × $0.04 = $9.60 per run. Four weekly runs are about $38.40.
  • Mixed audit: 500 static and 500 rendered diagnoses = 500 × $0.04 + 500 × $0.14 = $90.

You pay for a completed comparison, including a clean row, because the check was done. You do not pay per issue found.

Scope and limits

  • Shopify storefronts that publish products.json. Password-protected shops come back blocked.
  • Price, currency, and availability only. Shipping, tax, subscriptions, checkout, sale windows, and minimum quantity are out of scope.
  • Public catalog JSON, initial HTML, and JSON-LD. The actor does not discover a private Merchant Center feed configuration.
  • Dawn selected-variant JSON, Debut ProductJson and product meta tags, microdata, and JSON-LD Product / ProductGroup. A theme that paints the price only after JavaScript, and does not emit those signals, returns a partial row unless browser escalation is on.
  • products.json sometimes omits barcodes that the product page JSON-LD includes. A GTIN-only row needs the barcode in the scanned catalog, a product URL, or a search hit. The catalog scan stops at maxCatalogPages.
  • The requested currency is sent as context. Shopify Markets can still render a different price than a buyer sees in another country. The row reports the currency the storefront actually returned.
  • The built-in sample always replants the same three errors. Use your own feed when you want a fixed result.
  • Browser escalation needs the Playwright image and about 2048 MB. It is off by default so ordinary runs stay at 512 MB.

Run it locally

pip install -r requirements.txt
python -m src
pytest

An empty input runs the Dawn demo. Results land in ./storage unless you set APIFY_LOCAL_STORAGE_DIR. To exercise pay-per-event charging locally, set ACTOR_TEST_PAY_PER_EVENT=true. Nothing is billed outside Apify.

The Docker image is apify/actor-python-playwright:3.12 so the optional browser path has Chromium. Static runs do not launch it.

Integrations

Fields are flat strings, numbers, and booleans.

  • Webhook. Subscribe to the run succeeded event. Read the default dataset, or fetch the OUTPUT key for the counts.
  • n8n or Make. Run the actor on a schedule with the same feed URL and state store name. Filter the dataset to alert equals true, or to fixQueuePriority equals p1. Map incidentId, variantUrl, priceFeed, priceHtml, and fixQueueReason into the ticket.
  • Google Sheets. Append the dataset. Sort by fixQueuePriority, then filter recheckStatus to new, still-broken, and reopened.

FAQ

Will this get a product approved? No. It shows which public layer disagrees and whether a later run still sees that disagreement.

What if two feeds disagree and the page matches one of them? The named comparison feed is the baseline. The other feed is in feedTimelineJson. If the page matches the baseline, the diagnosis is feed_source_conflict at p3. If the page matches the older feed and the current feed is wrong, you get a p1 or p2 diagnosis plus feedConflict: true.

Why was a row not billed? The variant was ambiguous, the page could not be read, or alertOnlyOnChange suppressed an unchanged confirmation. chargedEvent is null in those cases.

Can I run more than one client? Yes. Put each store in stores. A feed row is routed by its store column, then by the link host, then to the only store when the input has one.

Source and contact

Storefront data is read from the public Shopify catalog and product pages the store already publishes: products.json, product.js, cart.js, and the variant URL. The feed is whatever you supply. The actor does not republish the catalog. Product information remains the merchant's.

Unofficial, not affiliated with Shopify or Google.

Questions: plusonecares@gmail.com.