# Shopify Products Scraper — Barcode GTIN, Variants & Prices (`scrapersdelight/shopify-products-scraper`) Actor

Full product catalogs from any Shopify store: one row per product with every variant's barcode (GTIN/EAN/UPC), SKU, price, currency, availability and stock where exposed. Custom domains and headless stores resolved automatically. Measured barcode fill: 94.1% of variants across 6 stores.

- **URL**: https://apify.com/scrapersdelight/shopify-products-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** E-commerce, Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$1.00 / 1,000 per product returneds

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/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Shopify Products Scraper — Barcode GTIN, Variants & Prices

Full product catalogs from any Shopify store. One row per product with **title, vendor,
productType, tags, priceMin/priceMax, compareAtPrice, onSale, currency, availability,
images, options, description** and a complete **variants array** — and on every variant
the field the catalog feed itself never carries: the **barcode (GTIN/EAN/UPC)**, plus
**sku, price, currency, weight** and (where the store exposes it) **live
inventoryQuantity**. Works on custom domains, `*.myshopify.com` domains and headless
storefronts — paste `gymshark.com` and the Actor recovers `gymsharkusa.myshopify.com`
from the homepage HTML by itself. No login. No API key. No browser.

**Measured on 90 products / 629 variants across 6 live stores (2026-08-15): barcode on
94.1% of variants, SKU 100%, price 100%, currency 100%.** On the shipped default input
(300 products from allbirds.com + colourpop.com): barcode on 99.8% of variants, currency
100%, images 100%.

```json
{
  "storeUrls": ["allbirds.com", "colourpop.com"],
  "enrichVariants": true,
  "maxItems": 300,
  "maxItemsPerStore": 150,
  "maxConcurrency": 5,
  "proxyConfiguration": { "useApifyProxy": true }
}
```

Click **Try for free** and hit **Start** — that block is literally the input the Actor
ships with. A platform run on those shipped defaults returned **300 products (150 per
store) in 55 seconds for $0.30**, with barcode on 99.8% of variants, currency 100%,
inventory quantity 100%, images 100%.

***

### The wedge: the barcode is not in the feed

Every Liquid Shopify storefront serves a public catalog feed at
`/products.json?limit=250&page=N` — and most Shopify scrapers stop there. **That feed has
no barcode key at all.** The GTIN/EAN/UPC only exists on the per-product endpoint
(`/products/{handle}.json`), one request per product, which is exactly the request this
Actor spends when `enrichVariants` is on (the default). The same request also brings
per-variant **price\_currency**, **weight + weight\_unit**, **inventory\_policy** and — on
stores that expose it — **inventory\_quantity**, including negative values on oversold
variants (a real `-5` appears in the sample row below).

Why the barcode matters: it is the join key. A GTIN matches a Shopify variant to the same
product on Amazon, Google Shopping, eBay, a POS system or a wholesale price list —
matching by title never survives "Men's Strider - Medium Grey (Blizzard Sole)". Barcode-
level rows are what price-intel, marketplace-arbitrage and catalog-matching buyers
actually need, and generic "scrape any Shopify store" actors that read only the feed
cannot deliver them.

Measured fill, because it is store-dependent and you should see the honest number before
buying: across the 6-store sample, **85.6% of products carried a barcode on at least one
variant and 94.1% of all variants carried one** — but the spread matters: fashion and
beauty stores measured at or near 100%, while one coffee store in the sample published no
barcodes at all (0/3 variants on its sampled products). If your target store does not put
GTINs into Shopify, no scraper can conjure them; the `variantsWithBarcode` field on every
row tells you exactly what you got.

***

### Read this before you buy rows

1. **Barcode fill is a property of the store, not of this Actor.** 94.1% of variants
   across the 6-store benchmark — but a store that never entered GTINs yields zero
   (measured: 0% on one coffee store, 100% on Allbirds). Run 150 rows first, read
   `variantsWithBarcode`, then scale.
2. **The public feed hard-caps at 25,000 products per store.** Measured on a 100k-product
   catalog: `page=100` is the last valid page at `limit=250`; `page=101` and beyond return
   HTTP 400. A store bigger than 25,000 products cannot be fully enumerated through the
   public feed by this or any other feed-based scraper, and the Actor logs a warning when
   it hits the wall instead of pretending it got everything.
3. **Prices and currency follow the exit IP's country** (Shopify Markets). The same
   kith.com catalog priced in CAD from a Canadian home IP and in USD through a US exit.
   Every variant carries its own `currency` field so you always know what you got, and
   `proxyConfiguration` accepts a country pin if you need one consistent market.
4. **`inventoryQuantity` is only published by some stores** — 33.4% of variants across
   the 6-store sample, 100% on the two default stores. When it is `null` the store hides
   stock counts; `available` (true/false) still fills from the feed on every variant.
5. **Password-protected and pre-launch stores return nothing.** The Actor reports the
   store in the log, skips it, and bills nothing for it — but it cannot get behind
   Shopify's password page, by design.
6. **`onlyWithBarcode` needs the enrichment toggle.** Barcodes only exist on the
   per-product endpoint, so if you switch enrichment off and the barcode filter on, the
   Actor logs a warning and switches enrichment back on rather than failing the run.

***

### What you get

One row per product. Prices are numbers in major units (`91` = $91.00), dates are the
store's own ISO-8601 timestamps, `scrapedAt` is a full UTC timestamp.

| Group | Fields | Example |
|---|---|---|
| **Identity** | `store`, `fetchHost`, `myshopifyDomain`, `productId`, `handle`, `title`, `url` | `allbirds.com` · `7199699927120` · `mens-strider-medium-grey` |
| **Catalog** | `vendor`, `productType`, `tags[]`, `options[]`, `createdAt`, `publishedAt`, `updatedAt` | `Allbirds` · `Shoes` · Size 8–14 |
| **Pricing** | `priceMin`, `priceMax`, `compareAtPriceMin`, `compareAtPriceMax`, `onSale`, `currency` | `91` · was `130` · `true` · `USD` |
| **Stock** | `available`, `variantCount`, `variantsWithBarcode` | `true` · `13` · `13` |
| **Barcodes** | `barcodes[]` (unique GTINs across variants) | `["196942200889", …]` |
| **Variants** | `variants[]`: `variantId`, `title`, `sku`, **`barcode`**, `price`, `compareAtPrice`, `currency`, `available`, `position`, `option1..3`, `grams`, `weight`, `weightUnit`, `requiresShipping`, `taxable`, **`inventoryQuantity`**, `inventoryPolicy`, `inventoryManagement`, `createdAt`, `updatedAt` | 13 variants, every one with barcode + stock |
| **Media** | `featuredImage`, `images[]`, `imageCount` | 5 CDN URLs |
| **Content** | `descriptionText` (clean text), `descriptionHtml` (original) | the store's own copy |
| **Provenance** | `enriched`, `enrichSource`, `scrapedAt` | `true` · `product-json` |

The dataset ships with three saved **table views**: *Products* (overview), *Barcode
matching* (GTIN columns for catalog joins) and *Price intel* (price/was/on-sale/stock).

***

### Field fill — measured, not estimated

**Cross-store benchmark: 90 products / 629 variants across 6 stores** (Allbirds,
ColourPop, Kith, Gymshark, Death Wish Coffee, Fashion Nova — fashion, beauty, coffee;
custom domains and recovered headless stores), fetched through the Apify proxy,
2026-08-15. Sorted by fill; the store-dependent fields are bolded.

| Field | Fill | Notes |
|---|---|---|
| `title` / `handle` / `url` / `productId` | 100% | |
| `sku` (variant) | 100% | |
| `price` (variant) | 100% | |
| `currency` (variant) | 100% | from `price_currency`, needs `enrichVariants` |
| `weight` / `grams` (variant) | 100% | |
| `images` / `descriptionHtml` / `vendor` / `productType` / `publishedAt` | 100% | |
| `tags` | 98.9% | |
| **`barcode` (per variant)** | **94.1%** | **the wedge — and store-dependent: 100% on some stores, 0% on one** |
| **`barcode` (≥1 variant per product)** | **85.6%** | `variantsWithBarcode` tells you per row |
| **`compareAtPrice`** | **33.5%** | only exists while a variant is discounted — a real absence, not a gap |
| **`inventoryQuantity`** | **33.4%** | only stores that expose stock counts; `null` = hidden, `available` still fills |
| `option2` / `option3` (variant) | **0% on one-option catalogs** | filled only when the store uses 2–3 product options (e.g. size + colour). Measured 0/1,675 variants on the shipped defaults — Allbirds sells by size only, ColourPop by shade only. `option1` is 100%. |
| `myshopifyDomain` | **0% on custom-domain input** | filled only when the catalog is read from a `*.myshopify.com` host — you pasted one, or a headless storefront was recovered (`gymshark.com` → `gymsharkusa.myshopify.com`). A store served on its own domain returns `null` here: measured 0/300 on the shipped defaults. `fetchHost` always tells you which host actually served the rows. |

**The shipped default run (300 products, allbirds.com + colourpop.com, 1,675 variants,
re-audited on the platform dataset 2026-08-19):** barcode 99.8% of variants (1,671/1,675) ·
barcode ≥1 variant 98.7% of products (296/300) · currency 100% · inventoryQuantity 100% ·
sku 100% · vendor / productType / tags / images / description / options 100% ·
compareAtPrice 65.4% of variants and 31.3% of products · in stock 100% of products have at
least one purchasable variant · 300/300 unique product ids · 5.6 variants per product on
average. **Every run prints its own measured fill in the log**, so you can check any store
the same way.

***

### Reliability contract

- **A run always returns rows.** Send `{}`, send `{"helloWorld": 123}`, send a list of
  unparseable tokens — the Actor logs a warning and falls back to the documented sample
  (`allbirds.com`, `colourpop.com`) instead of failing. Measured: `{}` → 300 rows in 48 s,
  `{"helloWorld":123}` → 300 rows in 86 s.
- **A run never times out.** The Actor reads the run's own wall-clock deadline and stops
  collecting with ~30 s in reserve, flushes every row it already has, and finishes
  SUCCEEDED with a `Partial catalog: N products delivered…` status message. Measured on an
  unbounded input against a 120 s budget, three times: 533 / 500 / 285 rows at 91 / 90 /
  90 s, no timeout. Raise the run timeout to go deeper — nothing else changes.
- **Delivered rows are billed rows, and only once.** Rows are charged on push, in batches,
  behind the run's own max-total-charge headroom: a run that hits its charge cap stops on a
  whole row and says so, and no row is ever delivered unbilled or billed twice. Every run
  asserts that the product ids it pushed are unique before it reports success.
- **A filtered or duplicate product costs nothing.** `onlyAvailable` is applied from the
  catalog feed before any enrichment request is spent; `onlyWithBarcode` drops rows before
  they are pushed. Neither is billed.

***

### How to run it

#### Any store, any mix of domain styles

```json
{ "storeUrls": ["allbirds.com", "fnova.myshopify.com", "kith", "https://colourpop.com/collections/best-sellers"] }
```

Custom domains, myshopify domains, bare handles and full pasted URLs (the path is
ignored) all work. Stores are processed in order; requests within a store run at
`maxConcurrency` (default 5).

#### Headless storefronts — automatic recovery

Gymshark 403s `/products.json` on its own domain; Fashion Nova 404s it. Both still leak
their `*.myshopify.com` handle in the homepage HTML, and the Actor recovers it by itself
(verified live: `www.gymshark.com` → `gymsharkusa.myshopify.com`, `www.fashionnova.com` →
`fnova.myshopify.com`). You paste the brand's domain; the Actor finds the catalog.

#### Feed-only mode — half the requests, no barcodes

```json
{ "storeUrls": ["allbirds.com"], "enrichVariants": false, "maxItems": 1000 }
```

Skips the per-product request: roughly twice as fast, same price per product, but
`barcode`, `currency`, `weight`, `inventoryQuantity` stay `null` on every row. Use it
when you only need titles, prices and availability.

#### Barcode-only output — for catalog matching

```json
{ "storeUrls": ["allbirds.com"], "onlyWithBarcode": true, "maxItems": 500 }
```

Drops products with no GTIN on any variant *before billing* — you pay only for rows you
can join on.

#### Scheduling and integrations

Save the input as a **Task** (Console → *Save as Task*), attach a **Schedule** — daily
price/stock monitoring of a competitor set is the classic use. The dataset is available
over the REST API and the standard integrations (Zapier, Make, n8n, webhooks, MCP).
Starting a run from the API:

```bash
curl -X POST "https://api.apify.com/v2/acts/scrapersdelight~shopify-products-scraper/runs?token=YOUR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"storeUrls":["allbirds.com"],"maxItems":200}'
```

***

### Sample row

A real row from an actual run, captured 2026-08-15 (arrays truncated here for length —
`variants` has all 13 entries in the dataset, `images` all 5, `tags` all 21).

```jsonc
{
  "store": "allbirds.com",
  "fetchHost": "allbirds.com",
  "myshopifyDomain": null,
  "productId": 7199699927120,
  "handle": "mens-strider-medium-grey",
  "title": "Men's Strider - Medium Grey (Blizzard Sole)",
  "url": "https://allbirds.com/products/mens-strider-medium-grey",
  "vendor": "Allbirds",
  "productType": "Shoes",
  "tags": ["allbirds::edition => classic", "allbirds::gender => mens", "allbirds::hue => grey"],

  "priceMin": 91,
  "priceMax": 91,
  "compareAtPriceMin": 130,
  "compareAtPriceMax": 130,
  "currency": "USD",
  "onSale": true,
  "available": true,

  "variantCount": 13,
  "variantsWithBarcode": 13,
  "barcodes": ["196942200889", "196942200995", "196942201084"],

  "variants": [
    {
      "variantId": 41243839496272,
      "title": "8",
      "sku": "A11718M080",
      "barcode": "196942200889",
      "price": 91,
      "compareAtPrice": 130,
      "currency": "USD",
      "available": false,
      "position": 1,
      "option1": "8", "option2": null, "option3": null,
      "grams": 903, "weight": 1.9911, "weightUnit": "lb",
      "requiresShipping": true, "taxable": true,
      "inventoryQuantity": -5,
      "inventoryPolicy": "deny",
      "inventoryManagement": "shopify",
      "createdAt": "2025-07-23T14:43:12-07:00",
      "updatedAt": "2026-08-14T21:14:02-07:00"
    }
  ],

  "options": [{ "name": "Size", "values": ["8", "8.5", "9", "9.5", "10", "10.5", "11", "…"] }],
  "featuredImage": "https://cdn.shopify.com/s/files/1/1104/4168/files/A11729_25Q3_Strider-Medium-Grey-Blizzard-Sole_PDP_LEFT.png?v=1754951480",
  "imageCount": 5,
  "descriptionText": "Made to match the pace of life in motion, the Strider offers comfort and versatility…",

  "createdAt": "2025-07-23T14:43:12-07:00",
  "publishedAt": "2026-08-11T15:42:35-07:00",
  "updatedAt": "2026-08-14T21:14:02-07:00",
  "enriched": true,
  "enrichSource": "product-json",
  "scrapedAt": "2026-08-15T07:36:41.118Z"
}
```

Fields people misread:

- **`inventoryQuantity: -5` is real** — the store oversold that variant (backorders).
  `null` means the store hides stock counts; `0` means genuinely none left.
- **`compareAtPrice: null` is normal** — it only exists while a variant is discounted.
  `onSale` is derived from it, so 31% on-sale means 31% of that catalog was discounted.
- `available` at the product level means *at least one* variant is purchasable; check
  `variants[].available` per size/color.
- `priceMin`/`priceMax` collapse the variant spread; single-price products have both
  equal.

***

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `storeUrls` | string list | `["allbirds.com", "colourpop.com"]` | Stores to scrape — custom domain, myshopify domain, bare handle or pasted URL. |
| `enrichVariants` | boolean | `true` | One extra request per product for barcode/GTIN, currency, weight, stock. The wedge — leave on. |
| `onlyWithBarcode` | boolean | `false` | Only products with ≥1 GTIN. Dropped rows are never billed. Needs enrichment on. |
| `onlyAvailable` | boolean | `false` | Only in-stock products, filtered from the feed before any enrichment is spent. |
| `maxItems` | integer | `300` | **Your hard cost cap**, across all stores. 300 = $0.30, 1,000 = $1. |
| `maxItemsPerStore` | integer | `150` (prefill) | Per-store cap so one huge catalog cannot eat the run. 0 = off. |
| `maxConcurrency` | integer | `5` | Parallel requests within a store. Max 10. |
| `proxyConfiguration` | proxy | Apify datacenter | Shopify is not walled — the cheap rung is the default on purpose. Pin a country to pin the currency. |

***

### Pricing

**$0.001 per product returned — $1 per 1,000.** Charged on the `product-scraped` event.
**No start fee, no monthly fee from this Actor** — several incumbents charge an
actor-start event on top of their per-item rate; this one does not.

| Run | Products | Cost |
|---|---|---|
| The shipped default (2 stores sampled) | 300 | **$0.30** |
| One mid-size store, complete | 1,000 | **$1.00** |
| Competitor set, 10 stores × 500 | 5,000 | **$5.00** |
| The biggest single catalog the feed can serve | 25,000 | **$25.00** |

- **You are charged for rows delivered.** Products removed by `onlyWithBarcode` /
  `onlyAvailable`, duplicate product ids, and unreachable stores are never billed.
- **Rows are charged as they are pushed** (`Actor.pushData(items, 'product-scraped')`) —
  if you hit a budget cap you get whole, billed rows and a clear warning, never a
  half-billed dataset.
- Enrichment does not change the price: barcode rows cost the same $0.001. It buys our
  requests, not your money.

***

### Transport — measured, and why the cheap proxy is the default

Ladder, run against the same 8-request set (catalog page + per-product endpoint across 4
stores) on 2026-08-15:

| Rung | Result |
|---|---|
| Direct, no proxy | 8/8 |
| Apify proxy, datacenter (auto) | 8/8 |
| Apify RESIDENTIAL | 8/8 |
| Apify RESIDENTIAL + pinned session | 5/8 — the 3 misses were proxy-side `590 UPSTREAM504` on one flaky exit, not Shopify blocks |

**Sustained load through the Apify datacenter rung: 30/30 = 100.0%** across 5 stores at
concurrency 5. Shopify's storefront endpoints are not bot-walled, so the default is the
cheap datacenter rung and you are not paying residential rates you do not need. The
occasional transient (`430 security block`, proxy 59x) is retried on a fresh exit IP
automatically, up to 4 attempts with backoff.

`robots.txt` on Shopify stores speaks, and this Actor honours it: the standard file
disallows `/cart`, `/checkout`, `/account`, `/search`, `/recommendations/products` and
sort/filter query permutations — **none of which this Actor touches** (this is also why
currency comes from the per-product endpoint and not the common `/cart.js` trick, which
lives under a disallowed path). The routes it does read — `/products.json` and
`/products/{handle}.json|.js` — appear in no `Disallow` line on any store checked;
ColourPop's file explicitly `Allow`s `/products/`. Concurrency defaults to a modest 5
per store.

***

### Duplicates — measured

| Walk | Rows | Unique | Duplicates |
|---|---|---|---|
| kith.com, pages 1–6 contiguous | 1,500 | 1,500 | **0.0%** |
| colourpop.com, pages 1–4 contiguous | 1,000 | 1,000 | **0.0%** |

The feed paginates cleanly — no promoted/pinned cards, no page-boundary drift in the
measurement. The Actor still enforces a run-wide dedupe on `fetchHost:productId` *before
billing* (the same catalog can legitimately be reachable through two of your input
domains — the custom domain and its myshopify handle), and **asserts uniqueness across
everything it pushed** before the run may succeed; a violation fails the run rather than
handing you a dirty dataset.

***

### When a run fails

This Actor fails loudly rather than handing back a quiet empty dataset:

- **0 rows** → the run throws, and the message names each store's specific failure
  (password-protected, not Shopify, catalog route blocked) and whether your filters
  removed everything. Nothing is billed.
- **A store fails, others work** → the failed store is reported in the log with its
  reason and skipped; the run continues and only delivered rows are billed.
- **Barcode enrichment degrades** (>30% misses on a store) → a loud warning names the
  store, so silent `null` barcodes cannot masquerade as "that store has no GTINs".
- **The 25,000-product feed cap** → a warning tells you the catalog kept going; the rows
  you got are complete and correctly billed.
- **Budget cap hit** → whole rows, charged exactly, plus a warning — never a half-billed
  dataset.

***

### Who buys this

- **Price-intelligence and repricing teams** — competitor price, `compareAtPrice`,
  `onSale` and stock across any set of Shopify stores, on a schedule, joined to your own
  catalog by GTIN.
- **Marketplace arbitrage / dropship researchers** — the barcode is the join key to
  Amazon/eBay/Google Shopping listings; `onlyWithBarcode` gives you only the sellable,
  matchable rows.
- **Catalog and PIM teams** — enrich a distributor feed with images, descriptions,
  option structures and GTINs from brand storefronts.
- **Ecommerce competitive intel** — assortment size, price architecture, discount depth
  (`compareAtPrice` spread), launch cadence (`publishedAt`) per competitor.
- **Agencies and app developers** auditing merchant catalogs — barcode coverage,
  duplicate SKUs and untagged products, straight from the storefront.

***

### FAQ

**Does this need an account, a login or an API key?**
No. Both endpoints are public storefront routes every Liquid Shopify store serves. There
is no login, no cookie, no CAPTCHA solving, no browser.

**Do I really get barcodes/GTINs?**
Where the store entered them, yes: 94.1% of 629 variants across the 6-store benchmark,
99.8% on the shipped default run. Where a store never entered GTINs, no scraper can
invent them — `variantsWithBarcode` on every row tells you exactly what you got, and
`onlyWithBarcode` makes you pay only for matchable rows.

**Which stores work?**
Any Shopify store whose storefront is public: custom domains, myshopify domains, and
headless builds whose homepage leaks the myshopify handle (Gymshark and Fashion Nova
both verified). Password-protected stores are reported and skipped, never billed.

**Can I get a store's entire catalog?**
Up to 25,000 products (100 pages × 250), which covers the overwhelming majority of
stores completely. Beyond that the public feed itself returns HTTP 400 — a platform cap,
not an Actor limit — and the run warns instead of pretending.

**Why do prices show a currency I did not expect?**
Shopify Markets localizes price and currency by exit-IP country. Every variant carries
`currency`, and pinning a proxy country pins the market.

**Are stock numbers live?**
`inventoryQuantity` is what the store publishes at scrape time, on the ~⅓ of stores that
expose counts (100% of the two default stores). `available` fills on every variant
regardless. Negative values are real oversells.

**Two runs — will I get duplicates?**
Within a run, never: dedupe on `fetchHost:productId` is asserted before the run may
succeed. Across runs, rows carry stable `productId`/`variantId` keys to merge on.

**Do I get charged for filtered or failed rows?**
No. `onlyWithBarcode`, `onlyAvailable`, duplicates, and stores that could not be read are
all dropped before billing.

**Will a run ever succeed with zero rows?**
No. Zero rows throws, with per-store reasons in the message. Nothing is billed on a
thrown run.

**Is scraping a store's catalog legal?**
The data is the store's public storefront output. You are responsible for complying with
each store's terms of service and applicable law in how you collect and use it —
especially if you resell the data.

***

### Honest limits

- **No reviews, no collections tree, no customer data.** This Actor does one thing —
  the product catalog with variant-level barcodes — completely. Review scraping is a
  different function (and a different consent surface) on purpose.
- **The 25,000-product cap per store is Shopify's, not ours** (measured: page 101 =
  HTTP 400). Mega-catalogs need a different enumeration strategy than the public feed.
- **Barcode fill is the store's data hygiene**, not a constant: 0% to 100% by store.
  Measure with 150 rows before you buy 15,000.
- **`inventoryQuantity` is absent on ~⅔ of stores** (33.4% of benchmark variants carried
  it). `available` is the universal stock signal.
- **Draft/unpublished products never appear** — the public feed only serves what the
  store publishes. `status` fields from the Admin API are not accessible without the
  merchant's own credentials, and this Actor uses none.
- **Currency follows the exit IP** unless you pin a proxy country. The `currency` field
  makes every row self-describing either way.

***

### Legal & fair use

This Actor reads public, unauthenticated storefront endpoints (`/products.json`,
`/products/{handle}.json`, `/products/{handle}.js`) and, only when a headless store
requires it, the public homepage HTML. It does not log in, does not touch carts,
checkouts, accounts, search or any path a standard Shopify `robots.txt` disallows, and
collects no personal data — rows describe products, not people. Store and brand names
belong to their owners; this Actor is not affiliated with, endorsed by, or connected to
Shopify Inc. or any store it reads. **You are responsible for complying with each
store's terms and with how you use the data.**

***

### Feedback

Found a store shape this misses or want a field added? Open an issue on the **Issues**
tab — and if the Actor earns it, a review on the **Reviews** tab helps other buyers find
it.

# Actor input Schema

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

One Shopify store per line. Measured on the shipped defaults: barcode on 94.1% of variants, currency on 100%, SKU on 100%. A store that turns out to be password-protected or not on Shopify is reported in the log and skipped — it never bills anything.

## `enrichVariants` (type: `boolean`):

ON (default): one extra request per product fills barcode, currency, weight, inventory policy and inventory quantity per variant. OFF: feed-only mode — roughly half the requests and runtime, but barcode/currency/stock stay null on every row. Price per product is identical either way.

## `onlyWithBarcode` (type: `boolean`):

Keep only products where at least one variant has a barcode/GTIN. Measured across 6 stores: 85.6% of products pass. Products dropped by this filter are never billed. Requires the enrichment toggle above to stay on.

## `onlyAvailable` (type: `boolean`):

Keep only products with at least one purchasable variant. Applied from the catalog feed BEFORE the enrichment request is spent, so filtered products cost nothing and are never billed.

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

Hard cap on products returned across all stores, and therefore your cost ceiling: 300 = $0.30, 1,000 = $1, 25,000 (the biggest single catalog the public feed can serve) = $25.

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

Per-store cap so one huge catalog cannot eat the whole run. 0 = no per-store cap. The shipped default (150) with two stores gives a balanced 300-row sample.

## `maxConcurrency` (type: `integer`):

Parallel requests within one store (stores are processed in order). Measured 30/30 = 100% sustained at concurrency 5 through the Apify proxy; going past 10 buys little and leans on the stores harder.

## `proxyConfiguration` (type: `object`):

Apify datacenter proxy by default — Shopify is not walled (measured 100% sustained through the cheap rung), so you are not paying for residential IPs you do not need. ONE thing the proxy country changes: Shopify Markets localizes price AND currency by exit-IP country (the same catalog priced CAD from Canada, USD from the US). Pin a country here if you need one consistent currency; every variant row also carries its own currency field.

## Actor input object example

```json
{
  "storeUrls": [
    "allbirds.com",
    "colourpop.com"
  ],
  "enrichVariants": true,
  "onlyWithBarcode": false,
  "onlyAvailable": false,
  "maxItems": 300,
  "maxItemsPerStore": 150,
  "maxConcurrency": 5,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per product: title, vendor, type, tags, prices, images and the full variants array with per-variant barcode (GTIN/EAN/UPC), SKU, price, currency, availability and inventory where exposed.

# 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"
    ],
    "enrichVariants": true,
    "maxItems": 300,
    "maxItemsPerStore": 150,
    "maxConcurrency": 5,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/shopify-products-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",
    ],
    "enrichVariants": True,
    "maxItems": 300,
    "maxItemsPerStore": 150,
    "maxConcurrency": 5,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/shopify-products-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"
  ],
  "enrichVariants": true,
  "maxItems": 300,
  "maxItemsPerStore": 150,
  "maxConcurrency": 5,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call scrapersdelight/shopify-products-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/shopify-products-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/xLz8irwfaFpkQd85h/builds/9u6S4pvn4hEMewTwE/openapi.json
