# Shopify Merchant Feed Discrepancy Checker (`plusonets/shopify-merchant-feed-discrepancy-checker`) Actor

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

- **URL**: https://apify.com/plusonets/shopify-merchant-feed-discrepancy-checker.md
- **Developed by:** [Olalekan Johnson](https://apify.com/plusonets) (community)
- **Categories:** E-commerce, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $40.00 / 1,000 product diagnoseds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Shopify Merchant Feed Discrepancy Checker

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

| 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.

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

| 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 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

```bash
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.

# Actor input Schema

## `stores` (type: `array`):

Shopify stores to check. Leave this as the Dawn demo to try the actor. Agencies can list several client stores.

## `feedUrl` (type: `string`):

Public URL of a Google Merchant TSV, CSV, or XML feed. Used instead of the built-in sample.

## `feedText` (type: `string`):

Paste a TSV, CSV, or XML feed. Used when you do not have a feed URL.

## `feedFormat` (type: `string`):

How to read the feed. Auto detects TSV, CSV, and XML.

## `feedName` (type: `string`):

Name of the comparison feed in the timeline.

## `feedGeneratedAt` (type: `string`):

When this feed was generated, as an ISO timestamp. Row-level dates in the file win when they exist.

## `additionalFeeds` (type: `array`):

Older or supplemental feeds for the same items. They are not the comparison baseline. They show whether a conflict started in a specific feed.

## `affectedProductsUrl` (type: `string`):

CSV or TSV export of the products you need checked. When a feed is also provided, only matching rows are diagnosed.

## `affectedProductsText` (type: `string`):

Paste an affected-products CSV. Recognized columns include id, title, link, price, availability, gtin, sku, and issue.

## `useBuiltInSample` (type: `boolean`):

When no feed and no affected-products file are provided, build a four-row feed from the live catalog and plant a price, availability, and currency mismatch.

## `maxItems` (type: `integer`):

Maximum variants to diagnose in this run.

## `maxCatalogPages` (type: `integer`):

Pages of products.json to scan when a row has no product link. Each page has up to 250 products.

## `alertOnlyOnChange` (type: `boolean`):

Deliver and bill only new, changed, fixed, or reopened rows. Unchanged confirmations update the incident baseline and are not billed.

## `enableBrowserEscalation` (type: `boolean`):

Off by default. For a resolved variant whose initial HTML and JSON-LD do not identify the offer, open the variant URL in a browser. Raise run memory to 2048 MB when you enable this.

## `recheckOpenIncidents` (type: `boolean`):

Also recheck incidents still open in the state store, using the feed values saved last time.

## `stateStoreName` (type: `string`):

Named key-value store that keeps incident baselines across runs. An errored item does not replace a good baseline.

## Actor input object example

```json
{
  "stores": [
    {
      "storeUrl": "https://theme-dawn-demo.myshopify.com",
      "label": "Dawn theme demo",
      "market": "CA",
      "currency": "CAD"
    }
  ],
  "feedFormat": "auto",
  "feedName": "primary",
  "useBuiltInSample": true,
  "maxItems": 25,
  "maxCatalogPages": 3,
  "alertOnlyOnChange": false,
  "enableBrowserEscalation": false,
  "recheckOpenIncidents": false,
  "stateStoreName": "shopify-feed-discrepancy-state"
}
```

# Actor output Schema

## `diagnoses` (type: `string`):

Per-variant discrepancy rows, ordered as a fix queue.

## `runSummary` (type: `string`):

Counts, coverage, and charged events for this run.

## `records` (type: `string`):

Run summary, sample feeds, and optional rendered-page screenshots.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "stores": [
        {
            "storeUrl": "https://theme-dawn-demo.myshopify.com",
            "label": "Dawn theme demo",
            "market": "CA",
            "currency": "CAD"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("plusonets/shopify-merchant-feed-discrepancy-checker").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "stores": [{
            "storeUrl": "https://theme-dawn-demo.myshopify.com",
            "label": "Dawn theme demo",
            "market": "CA",
            "currency": "CAD",
        }] }

# Run the Actor and wait for it to finish
run = client.actor("plusonets/shopify-merchant-feed-discrepancy-checker").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "stores": [
    {
      "storeUrl": "https://theme-dawn-demo.myshopify.com",
      "label": "Dawn theme demo",
      "market": "CA",
      "currency": "CAD"
    }
  ]
}' |
apify call plusonets/shopify-merchant-feed-discrepancy-checker --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,plusonets/shopify-merchant-feed-discrepancy-checker"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/p4uS7DUY3UxSThv4y/builds/HGttbKonwCMzghIOT/openapi.json
