# Shopify Scraper - Products, Inventory, Variants, SKU, Prices (`snow_leo_data/shopify-inventory-scraper`) Actor

Shopify products and inventory scraper that recovers shops whose own /products.json is closed: 43 of 47 stores read where the plain path reads 33, measured. Prices, SKUs, barcodes, named options and real stock counts where the shop publishes them. Shopify product scraper, products JSON.

- **URL**: https://apify.com/snow\_leo\_data/shopify-inventory-scraper.md
- **Developed by:** [Snow Leo Data](https://apify.com/snow_leo_data) (community)
- **Categories:** E-commerce, Integrations, MCP servers
- **Stats:** 1 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.20 / 1,000 products

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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 Inventory & Products Scraper — any store, $1.20 per 1,000 products

Reads the public product feed of any Shopify storefront and returns **42 fields
per product**: every variant, every price, the shop's own compare-at price,
SKUs, barcodes, stock counts, collection membership and the full description.
No API key, no login, no browser, no proxy.

It also does the thing every other Shopify products scraper skips. **When a shop
refuses `/products.json` on its own domain, this Actor finds the shop's Shopify
origin and reads the catalogue there.** On a measured sample of 47 live Shopify
storefronts that recovered **10 of the 14 shops that refuse the feed**, which is
the difference between 43 stores readable and 33.

Shopify products, Shopify prices, Shopify variants, Shopify inventory, Shopify
stock, Shopify SKU lists, Shopify collections and the whole Shopify catalog of
a store come out of one run, in one table — product inventory, product prices
and product variants side by side.

***

#### What problem does this actually solve?

A Shopify store publishes its catalogue at `/products.json`. That endpoint is
the backbone of every Shopify scraper on this store, and it works — until the
merchant puts a firewall in front of the domain, moves the storefront to a
headless frontend, or installs an app that blocks the feed. Then the endpoint
answers `403` or `404`, and a scraper built on it returns nothing at all.

That is not a rare corner. Measured on 12 September 2026 across 60 well-known
direct-to-consumer domains, of which 47 turned out to be Shopify storefronts:

| Outcome | Stores |
|---|---|
| `/products.json` open on the store's own domain | **33** |
| Domain refuses it (403, 404) | **14** |
| Of those, recovered through the shop's Shopify origin | **10** |
| Still unreachable | 4 |
| **Catalogue reachable in total** | **43 of 47 (91%)** |

A scraper that only tries the store's own domain gets 33 of 47 — **70%**.

Reproduce the whole table yourself:

```
python3 tools/measure_coverage.py tools/stores.txt
```

The recovery is not a guess. The shop's Shopify origin address is published by
the storefront itself, and before a single product is read the Actor asks that
origin who it belongs to and compares the answer with the domain you typed. A
store that does not match is never used — a name-based guess can land on a
completely unrelated merchant, and serving you someone else's catalogue would be
worse than returning nothing.

#### What does one row look like?

One row per product. Every row carries the store it came from, so several stores
in one run stay easy to split apart.

| Group | Fields |
|---|---|
| Store | `store_domain`, `store_origin`, `store_name`, `store_country`, `store_currency`, `catalog_source` |
| Identity | `product_id`, `handle`, `title`, `url`, `vendor`, `product_type`, `tags` |
| Dates | `published_at`, `created_at`, `updated_at`, `scraped_at` |
| Text | `description_text`, `description_html` |
| Media | `featured_image`, `images`, `image_count` |
| Price | `price_min`, `price_max`, `compare_at_price_max`, `on_sale`, `discount_percent`, `currency` |
| Stock | `available`, `available_variant_count`, `variant_count`, `total_inventory` |
| Codes | `sku_list`, `barcode_list` |
| Structure | `options`, `variants`, `collections`, `recommended_handles` |
| Detail mode | `image_alt_texts`, `subscription_available` |
| Change mode | `change_type`, `changed_fields` |

`variants` holds every size, colour and pack the shop sells, each with its own
`price`, `compare_at_price`, `sku`, `available`, option values and image — and,
when stock detail is on, `barcode`, `inventory_quantity`, `inventory_policy`,
`weight` and `weight_unit`.

#### Which fields does the bulk product feed not carry?

This is the second measured gap, and it is the reason the Actor has a stock
detail switch at all. The bulk feed and a product's own card are two different
documents, and the card is richer. Measured on a live storefront on 12 September
2026, a variant in the bulk feed carries exactly these keys:

```
available, compare_at_price, created_at, featured_image, grams, id,
option1, option2, option3, position, price, product_id,
requires_shipping, sku, taxable, title, updated_at
```

A variant on the product's own card adds **eight more**:

```
barcode, inventory_quantity, inventory_management, inventory_policy,
weight, weight_unit, quantity_rule, quantity_price_breaks
```

So the exact stock count and the GTIN/EAN/UPC barcode — the two fields most
catalogue work actually needs — are simply not in the feed everyone reads. Turn
on **Include stock counts and barcodes** and the Actor fetches each product's
card and fills `barcode_list`, `total_inventory` and the per-variant stock
fields, plus `image_alt_texts` and `subscription_available`. On a 25-product
sample of one live store, 24 products came back with a barcode filled in.

The same is true of the images. In the bulk feed an image has `src`, `width`,
`height` and nothing else — the alt text a merchant wrote for accessibility and
SEO lives only on the product card.

#### Can I get the shop's own "customers also bought" list?

Yes. Switch on **Include recommended products** and `recommended_handles` fills
with the handles Shopify's own recommendation engine returns for that product.
That is the shop's real merchandising, not a similarity score invented by the
scraper. It costs one request per product and, unlike the leading Actor in this
niche, it is not charged as an extra.

That switch costs one extra request per product, so it is off by default. Nothing
else in the Actor depends on it: no filter and no view needs a field that only
the card provides, which is why turning it off can never empty your dataset.

#### How is the Shopify catalog paged, and where does it stop?

The feed hands out at most **250 products per page**, and it does so silently:
ask for 1,000 and you get 250 without a word. The Actor walks pages until the
store runs out, so a full catalogue of any size comes back in one run. Measured
on one live store, the walk returned 1,006 products over 5 pages, and the store's
own sitemap listed exactly the same 1,006 — when the feed is open it is complete,
not a sample.

#### How do I point it at the right products?

Put whatever you have into **Store URLs**. All four shapes work:

| What you paste | What you get |
|---|---|
| `allbirds.com` | the whole catalogue |
| `allbirds.com/collections/sale` | only that collection |
| `allbirds.com/products/some-handle` | that single product |
| `some-shop.myshopify.com` | the Shopify origin directly |

A collection link is not a filter applied after collection. Shopify serves a
collection at its own address, so the Actor asks the store for that collection
and never downloads the rest — which is both faster and cheaper for you.

#### How much does a run cost?

You pay **$1.20 per 1,000 products** delivered to the dataset, plus the usual
per-run start event. Nothing else is billed: filtered-out products, unchanged
products in change mode, blocked stores and empty pages all cost nothing. Stock
counts, barcodes and collection mapping are included in that price rather than
charged as extras.

Every filter in the input runs **before** anything is written, so you are never
charged for rows you asked the Actor to throw away.

#### How do I watch a competitor's prices without paying twice?

Turn on **Only new and changed products** and put the Actor on a schedule. It
remembers every product it has delivered, in a named store tied to your Apify
account, and on the next run it returns only what moved. `changed_fields` names
what moved for each row:

| Value | Meaning |
|---|---|
| `price` | `price_min` or `price_max` moved |
| `availability` | a variant went out of stock or came back |
| `variants` | a size or colour was added or removed |
| `content` | title, type, tags or description were edited |

A scheduled run over a catalogue where nothing changed writes **zero rows and
charges nothing**, and it says so in the log and in `empty_reason` rather than
finishing silently. That matters: a successful run with an empty dataset usually
means something is broken, so an empty run that is legitimate has to name itself.

The fingerprint deliberately ignores `updated_at`. Shopify moves that timestamp
on housekeeping the merchant never sees, and a monitor built on it reports the
whole catalogue as "changed" every morning.

#### Why does an empty dataset never happen silently?

Because the worst failure in this business is the quiet one: the run is green,
the buyer is charged for the start, and no rows arrive with no explanation. Every
empty run here ends with a named reason in the run report and a warning in the
log:

| `empty_reason` | What happened |
|---|---|
| `nothing-changed` | change mode, and nothing moved since the last run |
| `filtered-out` | products were read and your filters removed all of them — the report says which filter took how many |
| `store-not-readable` | no store served a feed anywhere |
| `not-found` | the store is readable but the collection handle or product handle you asked for is not in it |
| `source-returned-nothing` | the store's catalogue is genuinely empty |
| `charge-limit-reached` | your own spending limit for the run was already used up |
| `no-store-given` | the input had no store in it |

When a collection handle is missing, the log prints how many collections the
store does publish and the first few handles, so the fix takes one look instead
of one support ticket.

#### What do the filters do, exactly?

Every filter is applied before billing, and every one of them leans only on
fields the bulk feed always carries. That is a deliberate rule, not an accident:
a filter that depends on a field which only exists in some mode will quietly
return zero rows on a green run.

- **Only these collections** — asked from the store, not filtered afterwards.
- **Title or description contains** / **Exclude these words** — plain word
  matching over title and description.
- **Vendors**, **Product types**, **Tags** — a product with that field empty is
  **kept**, because missing data is not a mismatch.
- **Minimum / maximum price** — on the cheapest variant, in the store's own
  currency. A reversed range is swapped silently.
- **In stock only** — at least one variant the shop still sells.
- **On sale only** — compare-at price above the selling price.
- **Changed in the last N days** — on `updated_at`; a product with no timestamp
  is kept.

**Max products returned** limits the dataset, not the crawl. With a filter
switched on the Actor keeps reading past the limit until it has enough surviving
rows, so asking for 30 discounted products returns 30 discounted products rather
than an empty table.

**Max products per store** gives each store its own quota. Without it, one large
catalogue eats the whole limit and a ten-store run looks like a scraper of a
single shop.

#### What does it cost in time?

Each page of 250 products is one request. A 1,000-product catalogue is 5
requests, a 250-product catalogue is one. Measured on the Apify cloud at 512 MB:
**1,000 products in 5.7 seconds**, and 200 products from two stores in 3.7
seconds.

Stock detail is the slow mode, because it is one request per product: **200
products with stock counts and barcodes took 160 seconds**. Collection mapping
costs one pass over the store per collection, and large shops publish hundreds —
one measured store publishes 525, another 1,346. Left unbounded that is the one
setting that can spend ten minutes before the first row appears, so it runs
inside two limits: the **40 largest collections** and a budget of **60 pages**
across all of them. The run report says how many were actually mapped and whether
the budget ran out, and the log says so too. Name the handles you actually need
in **Only these collections** and exactly those are mapped, with a far larger
budget, because then you have told the Actor what you want.

Both extras are opt-in for that reason — and neither of them costs extra money.
You pay per product, whatever is in it.

#### What are the honest limits?

- **Some stores cannot be read at all.** 4 of the 47 measured stores refuse the
  feed on the domain and publish no usable Shopify origin. For those the Actor
  reports the HTTP code it got and returns nothing rather than inventing data.
- **Only what the storefront publishes.** Unpublished and draft products, and
  products hidden from the online store channel, are not in the feed and cannot
  be recovered from it.
- **No reviews.** Product reviews live in third-party apps, not in the storefront
  feed. If you need them, that is a different job.
- **No metafields and no per-product SEO title or description.** Measured, not
  assumed: a metafields path on a live store returns the product itself and
  ignores the path, and the SEO pair lives in the product page's HTML head. An
  Actor that lists those fields is parsing each product page as HTML, which is a
  different and much more fragile job than reading JSON.
- **No sales or revenue figures.** Anyone quoting a Shopify store's revenue is
  estimating it, and this Actor does not guess.
- **Stock counts are what the shop chooses to publish.** Merchants who hide
  inventory return no count, and the field stays empty rather than being filled
  with a zero that would read as "sold out".
- **Collection handles can differ** between a custom domain and the Shopify
  origin. When a handle is missing, the log prints the handles the store really
  has.
- **Prices are in the store's own currency**, as published. No conversion is
  applied, because a converted price is a guess with a timestamp attached.

#### How does this compare with the other Shopify products scrapers?

Feature by feature, against what the established Actors on this store publish:

| | This Actor | Typical Shopify product scraper |
|---|---|---|
| Reads a store that blocks `/products.json` | yes, measured 10 of 14 | no |
| Stock counts and barcodes | yes, optional | sometimes, usually charged extra |
| Collection membership per product | yes | rarely |
| Price and stock change monitor | built in | usually a separate Actor |
| Shopify's own recommended products | yes, included | leader charges extra |
| Names the reason for an empty run | yes, 7 reasons | no |
| Per-store quota | yes | no |
| Product metafields and SEO pair | no | one competitor has them |
| Price per 1,000 products | **$1.20** | $1.00 to $18.00 |

Where they are ahead is listed above under honest limits: product reviews,
metafields and the per-product SEO pair. All three need the product page's HTML
rather than the JSON this Actor reads.

#### Frequently asked questions

**Do I need a Shopify API key or a private app?**
No. Everything this Actor reads is what the storefront serves to any visitor.
There is no login, no token and no app install.

**Does it work on `myshopify.com` addresses?**
Yes, and you can paste one directly. If you paste a custom domain that refuses
the feed, the Actor looks the Shopify origin up for you and says in the log and
in `catalog_source` which route it used.

**Will it read a store that is not on Shopify?**
No, and it will tell you so instead of returning an empty table with no
explanation. Non-Shopify domains end the run with `store-not-readable` and the
HTTP code the domain gave.

**How do I get just the products that are on sale?**
Switch on **On sale only**. Discounts are computed from the shop's own
compare-at price: `discount_percent` is the drop from the highest compare-at
price down to the cheapest variant price.

**Can I get one row per variant instead of one per product?**
The dataset gives one row per product with variants nested, which keeps billing
honest — you pay per product, not per colour. Use the dataset API's `unwind`
parameter on `variants` to flatten it into one row per variant at export time.

**How big can a catalogue be?**
The walk goes up to 250 pages of 250 products, which is 62,500 products. The
largest catalogue in the measured sample was 19,524.

**Does it slow the store down?**
No. A full 1,000-product catalogue is five requests, the same five a price
comparison site would make. The client backs off on its own when a host asks it
to, and the run report shows any host that did.

**What happens if the run is cut off half way?**
Rows already pushed are yours, and in change mode a product is marked delivered
only after it has actually been written. The next run picks up exactly what did
not arrive, so nothing is silently skipped and nothing is billed twice.

**Why is `total_inventory` empty?**
Either stock detail is switched off, or the merchant does not publish inventory
for that product. Both are normal, and the field stays empty rather than
pretending the answer is zero.

**Which currency are prices in?**
The store's own, given in `store_currency` and `currency`. A store that sells in
several markets publishes one catalogue per market; point the Actor at the market
address you care about.

#### What else is this called?

Names buyers use: Shopify scraper, Shopify products API, Shopify inventory levels, Shopify variant SKU, myshopify store data, store catalog export. Each row carries the variant SKU and the inventory number, so stock work and catalog work are the same run.

More field names: store origin canonical domain (`store_origin`), barcode and GTIN product export (`barcode_list`). `price_min` together with `compare_at_price_max` is the data people read for MAP violation monitoring; the Actor returns the prices, the policy check is yours.

# Actor input Schema

## `storeUrls` (type: `array`):

One line per store. A bare domain (allbirds.com) reads the whole catalogue. A collection URL (.../collections/sale) reads only that collection, asked from the store itself rather than filtered afterwards. A product URL (.../products/handle) reads that single product. A .myshopify.com address works too.

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

Hard stop on how many products land in the dataset, counted across all stores. This is a limit on what you are charged for, not on what is read. 0 means no limit; an empty run stops at 200 so a first try stays cheap.

## `maxItemsPerStore` (type: `integer`):

Quota for each store separately. Without it one large catalogue eats the whole limit and the run looks like a scraper of a single shop. 0 means no per-store quota.

## `includeVariants` (type: `boolean`):

Every size, colour and pack as its own record inside the product row: SKU, price, compare-at price and in-stock flag.

## `includeInventoryDetail` (type: `boolean`):

Adds barcode (GTIN/EAN/UPC), inventory\_quantity, inventory\_policy, weight and weight unit. These are missing from the bulk product feed and come from each product's own card, so this costs one extra request per product and makes the run slower.

## `includeCollections` (type: `boolean`):

Fills the collections field with the handles of every collection a product sits in. The bulk feed never says this, so it costs one pass per collection.

## `includeRecommendations` (type: `boolean`):

Adds the handles Shopify's own recommendation engine returns for each product — the shop's real "customers also bought" list, not a guess. One extra request per product.

## `collectionHandles` (type: `array`):

Handles or titles, for example sale or new-arrivals. Read the list from the store's own /collections.json. Leave empty for the whole catalogue.

## `titleContains` (type: `array`):

Keep a product when its title or description contains any of these words. Leave empty to keep everything.

## `excludeKeywords` (type: `array`):

Drop a product when its title or description contains any of these words, for example gift-card or sample.

## `vendors` (type: `array`):

Keep only these vendors (brands). A product with no vendor set is kept: missing data is not a mismatch.

## `productTypes` (type: `array`):

Keep only these product types, for example Shoes. A product with no type set is kept.

## `tags` (type: `array`):

Keep only products carrying any of these tags. A product with no tags is kept.

## `priceMin` (type: `integer`):

Lowest variant price, in the store's own currency. 0 means no lower bound.

## `priceMax` (type: `integer`):

Lowest variant price must not exceed this, in the store's own currency. 0 means no upper bound. A reversed range is swapped silently.

## `availableOnly` (type: `boolean`):

Keep only products with at least one variant the store still sells.

## `onSaleOnly` (type: `boolean`):

Keep only products whose compare-at price is above the selling price.

## `updatedWithinDays` (type: `integer`):

Keep only products the store touched within this many days. 0 means any age.

## `onlyNew` (type: `boolean`):

Remember every product delivered and, on the next run, return only the ones that are new or whose price, stock, variants or text moved. A scheduled run then costs a handful of rows instead of the whole catalogue. Memory is kept per Apify account in a named store.

## `emitUnchanged` (type: `boolean`):

Return untouched products as well, marked UNCHANGED. Off by default so you are not charged twice for the same rows.

## `descriptionFormat` (type: `string`):

Plain text is easiest to read, HTML keeps the shop's markup, both gives you the two columns, none drops the description entirely and makes rows much smaller.

## `compactOutput` (type: `boolean`):

Keep only the 21 columns most people use: identity, price, discount, stock and SKUs. Useful for AI agents and for spreadsheets.

## `excludeEmptyFields` (type: `boolean`):

Leave out keys with no value at all. False and zero are kept: not in stock is an answer, not a gap.

## Actor input object example

```json
{
  "storeUrls": [
    "allbirds.com",
    "colourpop.com"
  ],
  "maxItems": 200,
  "maxItemsPerStore": 0,
  "includeVariants": true,
  "includeInventoryDetail": false,
  "includeCollections": false,
  "includeRecommendations": false,
  "collectionHandles": [],
  "titleContains": [],
  "excludeKeywords": [],
  "vendors": [],
  "productTypes": [],
  "tags": [],
  "priceMin": 0,
  "priceMax": 0,
  "availableOnly": false,
  "onSaleOnly": false,
  "updatedWithinDays": 0,
  "onlyNew": false,
  "emitUnchanged": false,
  "descriptionFormat": "text",
  "compactOutput": false,
  "excludeEmptyFields": false
}
```

# Actor output Schema

## `results` (type: `string`):

All collected rows

# 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 = {
    "storeUrls": [
        "allbirds.com",
        "colourpop.com"
    ],
    "maxItems": 200
};

// Run the Actor and wait for it to finish
const run = await client.actor("snow_leo_data/shopify-inventory-scraper").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 = {
    "storeUrls": [
        "allbirds.com",
        "colourpop.com",
    ],
    "maxItems": 200,
}

# Run the Actor and wait for it to finish
run = client.actor("snow_leo_data/shopify-inventory-scraper").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 '{
  "storeUrls": [
    "allbirds.com",
    "colourpop.com"
  ],
  "maxItems": 200
}' |
apify call snow_leo_data/shopify-inventory-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,snow_leo_data/shopify-inventory-scraper"
        }
    }
}
```

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/S9dgazSY1zJMPlWeK/builds/vwMb5qiABJA8dpteW/openapi.json
