Shopify Merchant Feed Discrepancy Checker
Pricing
from $40.00 / 1,000 product diagnoseds
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
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.jsonorproduct.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
| Field | Required | What to put |
|---|---|---|
stores | No | One or more public Shopify origins. The default is the Dawn theme demo. label, market, and currency are optional. |
feedUrl or feedText | No | Merchant feed. TSV, CSV, or XML. feedFormat can be auto. |
feedGeneratedAt | No | When that feed was generated. A feed_generated_at column or XML lastBuildDate overrides this. |
additionalFeeds | No | Older or supplemental feeds for the same items. They are timeline context, not the comparison baseline. |
affectedProductsUrl or affectedProductsText | No | CSV 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. |
useBuiltInSample | No | Default true. Used only when you did not supply a feed or an affected-products file. |
maxItems | No | Default 25, maximum 2000. |
maxCatalogPages | No | Default 3 pages of products.json (up to 250 products each) when a row has no product URL. |
alertOnlyOnChange | No | Deliver and bill only new, changed, fixed, or reopened rows. |
enableBrowserEscalation | No | Default false. Raise memory to 2048 MB before turning it on. |
recheckOpenIncidents | No | Also recheck incidents still open in the state store, using the feed values saved last time. |
stateStoreName | No | Named 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.
| Priority | Meaning |
|---|---|
p1 | The 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. |
p2 | The feed disagrees with the catalog or with the rendered page, and the initial HTML and JSON-LD were not the conflicting layer. |
p3 | The current feed matches the storefront, but another supplied feed does not, or the page layers disagree with each other. |
review | The variant was ambiguous, or the storefront could not be read. These rows are not billed. |
none | Price, 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
- Variant id in the feed link (
?variant=) or a variant id column. - GTIN against the catalog barcode.
- SKU or MPN against the catalog SKU.
- Product handle, then variant title, when the link has no variant id.
- 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:
| Event | Price | When |
|---|---|---|
product-diagnosed | $0.04 | A variant was resolved and the static comparison was delivered. |
rendered-diagnostic | $0.14 | Browser escalation completed for that variant. This replaces the $0.04 charge for that item. |
| Platform start | $0.005 per GB | Charged 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 backblocked. - 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
ProductJsonand 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.jsonsometimes 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 atmaxCatalogPages.- 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
fixedresult. - 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.txtpython -m srcpytest
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
OUTPUTkey 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
alertequals true, or tofixQueuePriorityequalsp1. MapincidentId,variantUrl,priceFeed,priceHtml, andfixQueueReasoninto the ticket. - Google Sheets. Append the dataset. Sort by
fixQueuePriority, then filterrecheckStatustonew,still-broken, andreopened.
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.