# Shopify Product Scraper — GTIN & Collections (`spool/shopify-product-feed`) Actor

Scrape every product and variant from any Shopify store, with the GTIN barcodes, subscription plans and wholesale price breaks that /products.json does not contain.

- **URL**: https://apify.com/spool/shopify-product-feed.md
- **Developed by:** [Spool](https://apify.com/spool) (community)
- **Categories:** E-commerce, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.40 / 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 Product Scraper — GTIN, Variants & Collections

**Every product and variant from any Shopify store — with the GTIN barcodes,
subscription plans and wholesale prices that `/products.json` does not contain.**

```
rothys.com            964 variants    81% with barcode   newest 100%, oldest 49%
finisterre.com         92 variants   100% with barcode   £50 → $66.83
deathwishcoffee.com    62 variants     6 subscription products, real subscriber price
```

***

### The field almost no Shopify scraper returns

Every Shopify store serves its catalog at `/products.json` — no key, no login,
permitted by robots.txt. Nearly every Shopify scraper reads that one endpoint
and returns what it finds.

It does not contain the barcode.

**GTIN / UPC / EAN is what connects a Shopify product to the rest of commerce** —
to Amazon, to Google Shopping, to a distributor's price list, to your own ERP.
Without it you have a pile of product titles and the matching is manual.

It lives one layer down, on `/products/{handle}.js`. Measured across eight
stores, it came back on **97–100% of variants for six of them**. The other two are
the merchant's doing, not the endpoint's: Rothy's is 81% — its newest products
are fully barcoded, its oldest half are not — and Death Wish Coffee is 26%:

```
rothys.com           81%      organicbasics.com    97%
allbirds.com         98%      gymshark.com        100%
finisterre.com      100%      kith.com            100%
tentree.com         100%      deathwishcoffee.com  26%
```

That endpoint costs one request per product, which is why most tools skip it.
It is the entire point of this one.

***

### Nine other fields that endpoint has and `/products.json` does not

| | what it tells you |
|---|---|
| **`barcode`** | GTIN/UPC/EAN — match to Amazon, Google Shopping, retail |
| **`selling_plan_groups`** | Subscription offers, with the **real subscriber price** |
| **`quantity_price_breaks`** | Wholesale volume tiers |
| **`quantity_rule`** | Order minimums, maximums and pack increments |
| **`inventory_management`** | Whether stock is tracked **at all** |
| `weight`, `media`, `price_varies` | Shipping weight, video/3D media, price ranges |

`inventory_management` deserves a note. Every scraper reports `available`, but
when a merchant does not track inventory, `available` is a default rather than a
fact. `inventoryTracked` tells you which kind of "in stock" you are looking at.
Nobody else reports the difference.

Subscription pricing is read from the variant's own plan allocation, not guessed
from the plan's name — so `subscriptionDiscountPercent` is the actual saving.

***

### What the merchant says is selling

`/collections.json` is the store's own merchandising, and it is public. A product
sitting in the merchant's `best-sellers` collection is a demand signal **the
store is publishing itself** — not an estimate.

Reading all of it is wasteful: Rothy's has 196 collections, Allbirds 1,000, and
most are colour and size groupings. So collections are **ranked, not matched** —
best sellers first, then new arrivals, then trending, then sale, each capped so
one kind cannot crowd out another. Internal plumbing (`Discount Eligible
Products Excluding Markdown`, test and hidden collections) is dropped.

On Allbirds that turns 1,000 collections into 23 useful reads:

```
6 best-seller lists · 4 new-arrivals · 3 trending · 10 sale
```

You get `isBestSeller`, `isNewArrival` and `inSaleCollection` per product.
`inSaleCollection` is deliberately separate from `onSale`: a product can sit in
the sale collection at full price, or be discounted without being merchandised
as sale, and those are different facts.

***

### Prices you can actually compare

`products.json` carries prices as bare numbers with **no currency anywhere in
it**. A UK store showing `50` and a US store showing `50` are £50 and $50.

So the currency is read from the store profile and every price is reported twice:

```
finisterre.com   GBP   £50   →  $66.83
rothys.com       USD   $65   →     $65
```

When no rate is available the USD fields are **null** rather than a guess.

There is a second trap here worth naming, because it is easy to get wrong:
`/products.json` gives prices as decimal strings, while `/products/{handle}.js`
gives them as integer **cents**. Mixing the two silently divides every enriched
price by a hundred. Each source is normalised on the way in.

***

### Built for a merchant feed

Set `requireBarcode` and every row carries a GTIN. `feedReady` tells you whether
a row has everything Google Merchant Center requires — id, title, link, image,
brand, price, availability and GTIN — so you know what needs work before upload
rather than discovering it at import.

```json
{ "storeUrls": ["rothys.com"], "requireBarcode": true }
```

***

### Quick start

```json
{ "storeUrls": ["rothys.com", "deathwishcoffee.com"] }
```

Domains, full URLs and product links all work. `www.` is stripped, and
`www.x.com` and `x.com` are merged so you are never billed twice for one store.

***

### What you get back

One row per variant by default — the shape a price monitor or a feed needs,
because barcode, price and stock are all per-variant. Switch `rowsPer` to
`product` to get variants nested instead.

```json
{
  "store": "deathwishcoffee.com",
  "storeName": "Death Wish Coffee Company",
  "currency": "USD",

  "title": "Coconut Caramel Coffee",
  "vendor": "Death Wish Coffee",
  "url": "https://deathwishcoffee.com/products/coconut-caramel-coffee",

  "variantTitle": "1 bag",
  "sku": "DW-CC-12",
  "barcode": "810063344313",

  "price": 13.99,
  "priceUsd": 13.99,
  "compareAtPrice": null,
  "onSale": false,

  "available": true,
  "inventoryTracked": true,

  "subscriptionAvailable": true,
  "subscriptionPlans": ["Deliver every 2 weeks", "Deliver every 30 days"],
  "subscriptionPriceMin": 12.59,
  "subscriptionDiscountPercent": 10,

  "collections": ["labor-day-sale-25-off", "new-arrivals"],
  "isBestSeller": false,
  "isNewArrival": true,
  "inSaleCollection": true,

  "feedReady": true
}
```

Eight dataset views ship with it: **Products**, **Merchant feed**, **Pricing**,
**Subscriptions**, **Wholesale**, **Stock**, **Merchandising** and
**Unavailable**.

***

### Recipes — copy, paste, run

**Build a Google Merchant feed from a store**

```json
{ "storeUrls": ["rothys.com"], "requireBarcode": true }
```

**Find every subscription product and what subscribing saves**

```json
{ "storeUrls": ["deathwishcoffee.com"], "requireSubscription": true }
```

**Everything on sale under $50, across several competitors**

```json
{
  "storeUrls": ["rival-one.com", "rival-two.com"],
  "onlyOnSale": true,
  "maxPrice": 50
}
```

**Wholesale and B2B offers**

```json
{ "storeUrls": ["..."], "rowsPer": "variant" }
```

Then read the **Wholesale** view — `wholesaleBreaks` carries the volume tiers.

**A fast, cheap catalog dump with no barcodes**

```json
{ "storeUrls": ["gymshark.com"], "enrich": false, "collectionsMode": "none" }
```

***

### Every option

| Option | Default | What it does |
|---|---|---|
| **`storeUrls`** | — | **Required.** Domains, URLs or product links, mixed freely |
| **`enrich`** | `true` | **Read barcodes, subscriptions and wholesale.** One extra request per product. Turn off for a fast catalog dump |
| `rowsPer` | `variant` | `variant` for feeds and price monitoring, `product` for catalog analysis |
| `maxProductsPerStore` | `1000` | How deep to read. The main lever on run time and cost |
| `collectionsMode` | `key` | `key` reads only signal collections, `all` up to 40, `none` skips them |
| **`requireBarcode`** | `false` | **Keep only rows with a GTIN** — the feed-building filter |
| `onlyInStock` | `false` | Keep only available variants |
| `onlyOnSale` | `false` | Keep only discounted variants |
| `requireSubscription` | `false` | Keep only subscription products |
| `minPrice` / `maxPrice` | — | Compared in USD, so it means the same on every store |
| `vendors` / `productTypes` / `tags` | — | Filter by brand, type or tag |
| `includeDescription` | `false` | Adds the full description. Large |
| `maxConcurrency` | `5` | Parallel enrichment requests. Lower it if a store returns 429 |

***

### What it covers, honestly

**About five stores in six work.** Tested against 18 real stores: 15 served the
catalog, 3 did not. Those three return `not_a_shopify_store` — they have
disabled the endpoint or are not on Shopify. A miss is always reported, never
silent.

**Barcode coverage is the merchant's choice, not ours.** Six of eight stores
returned barcodes on 97–100% of variants. Rothy's returned 81%, because its
older products were never given one; Death Wish Coffee 26%, because they simply
have not entered them. `enriched` tells you whether the
field was read; a null barcode means the merchant left it empty.

**`available` depends on whether the merchant tracks stock.** `inventoryTracked`
says which, rather than reporting a confident boolean that means nothing.

**Currency is what the store serves *you*.** Shopify switches storefront by
geography, so a store may answer in USD when read from outside its home market.
Figures are reported exactly as served rather than normalised into something
tidier but invented.

**Prices and stock are a snapshot.** They are true at the moment of the run.

**Enrichment costs one request per product.** A 1,000-product store is ~1,010
requests. That is the trade for barcodes; `enrich: false` avoids it entirely.

**Direct requests first; Apify Proxy only for what a store refuses.** Shopify
throttles the platform's shared addresses by address, not by rate — Allbirds
enriched 30% of its catalog directly from the platform and 96% once the refused
products were retried through Apify Proxy, while the same requests pass 100% from
an ordinary home connection. So every product is tried directly, and only the
ones the store turned down go through the proxy, one fresh session each. It is
included in the price; the log says how many it recovered, and anything still
missing is reported as missing rather than passed off as "no barcode".

***

### Is this legal?

Everything read here is a public endpoint Shopify serves by design, requires no
authentication, and is permitted by robots.txt: `/products.json`,
`/products/{handle}.js`, `/collections.json` and `/meta.json`.

**What this Actor deliberately does not do:** Shopify's `/cart/add.js` will
reveal a variant's exact remaining stock if you request an absurd quantity, and
tools advertising "real inventory counts" appear to use it. Shopify's default
robots.txt **disallows `/cart`** on every store we checked, and `/search` with
it. Neither is touched here. Exact stock counts are not worth ignoring a
merchant's robots.txt for.

Everything returned is catalog data — products, prices, barcodes, availability.
No customer data, no personal information.

***

### When a store can't be read

| `error` | Meaning |
|---|---|
| `not_a_shopify_store` | No public catalog at `/products.json` |
| `catalog_blocked` | The store returned 403 or 429 |
| `catalog_empty` | Catalog exists but has no products |
| `all_products_filtered_out` | Read fine, but your filters matched nothing |
| `invalid_input` | Couldn't read a domain from that value |

A breakdown is saved to the key-value store as `RUN_SUMMARY`.

***

### FAQ

**How do I get GTIN or UPC codes from a Shopify store?**
Turn on `enrich` (it is on by default). Barcodes come from
`/products/{handle}.js`, which `/products.json` does not include.

**How is this different from other Shopify product scrapers?**
Most read one endpoint and return titles, prices and images. This adds the
barcode, subscription plans with real subscriber prices, wholesale price breaks,
order minimums, whether stock is actually tracked, and which of the merchant's
own collections each product sits in.

**Can I scrape all Shopify stores at once?**
No. You supply the store list. This reads the stores you name, thoroughly.

**Does it work on any Shopify store?**
Roughly five in six. Some merchants disable the public catalog, and those are
reported rather than guessed at.

**Will it get me blocked?**
It makes plain HTTPS requests at a concurrency you control. Lower
`maxConcurrency` if a store returns 429.

***

### How to use it

1. Open the Actor and paste store domains into **Shopify stores**.
2. Leave **enrich** on for barcodes. Set **requireBarcode** if you are building a merchant feed, or **requireSubscription** to find subscription products.
3. Click **Start**. 800 products take under a minute.
4. Open the **Merchant feed**, **Subscriptions** or **Wholesale** view, or export as CSV, Excel or JSON.
5. To keep a feed fresh, **Schedule** a daily run; to push rows into Google Sheets, a database or a webhook, use the integrations tab.

***

### How much does it cost?

Pay per event — you are charged **per product**, not per row. A product with 20 size/colour variants is 20 rows and **one charge**.

| Plan | per product, enriched (barcode, subscriptions, wholesale) | per product, catalog only (`enrich: false`) |
|---|---|---|
| Free | $0.002 | $0.0005 |
| Bronze | $0.0018 | $0.00045 |
| Silver | $0.0016 | $0.0004 |
| Gold | $0.0014 | $0.00035 |

Actor start is $0.002. Stores that could not be read cost nothing.
If a store throttles the per-product requests, the products that could not be
enriched are billed at the catalog-only price, not the enriched one — you pay for
barcodes only when you get them.

**Worked examples**

- Rothy's, **40 products → 827 variant rows** with barcodes → 40 × $0.002 = **$0.08**
- A **1,000-product catalog** with GTINs → **$2.00** (Gold plan: $1.40)
- The same catalog with `enrich: false` → **$0.50**
- The **free plan's $5 credit** covers about **2,500 enriched products**

For comparison, tools that bill per dataset row would charge that Rothy's run 827 times.

***

### More Shopify tools from spool

- **[Shopify Stores API — Velocity, Pricing & Brand Model](https://apify.com/spool/shopify-stores-api)** — one row per store: launch velocity measured properly, price bands in USD, discount depth, brand model, and the apps the storefront runs
- **[Shopify Scraper — Stores & Products](https://apify.com/spool/shopify-store-products-scraper)** — both of the above in one run — the store summary plus the full catalog

All three read the same public endpoints, respect robots.txt, and report a miss rather than guessing.

***

### Support

Open an issue on the **Issues** tab and you will get a reply, usually the same
day. Requests for extra fields are welcome — tell me what you need.

# Actor input Schema

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

Domains, full URLs or product links — mixed freely. `www.` is stripped, and `www.x.com` and `x.com` are merged so one store is never read twice.

About five stores in six serve a public catalog. A store that does not is reported as `not_a_shopify_store` rather than silently skipped.

## `enrich` (type: `boolean`):

**This is the reason to use this Actor.** `/products.json`, which every other Shopify scraper reads, does not contain the GTIN/UPC barcode, subscription plans, wholesale price breaks or whether stock is even tracked.

Those live on a second endpoint, one request per product. Measured across eight stores, barcodes came back on **97-100% of variants for six of them**, 81% on Rothy's and 26% on Death Wish Coffee.

Turn this off for a fast, cheap catalog dump with no barcodes.

## `rowsPer` (type: `string`):

**Variant** gives one row per buyable SKU, each with its own barcode, price and stock. This is the shape a price monitor or a Google Merchant feed needs.

**Product** gives one row per product with the variants nested, which is easier for catalog analysis.

## `maxProductsPerStore` (type: `integer`):

Shopify serves 250 products per request, newest-published first.

With enrichment on, each product costs one extra request, so this is also the main lever on run time and price.

## `collectionsMode` (type: `string`):

Which of the merchant's own collections to map each product against. A product sitting in the store's `best-sellers` collection is a demand signal the merchant is publishing themselves.

**Signal collections** reads only the ones that mean something — best sellers, sale, clearance, new arrivals, trending. Rothy's publishes 196 collections; reading all of them would cost hundreds of requests to learn mostly colour and size groupings.

## `requireBarcode` (type: `boolean`):

Keep only rows carrying a barcode. Use this when you are building a Google Merchant Center feed or matching against Amazon, where a row without a GTIN is unusable.

Needs enrichment on.

## `onlyInStock` (type: `boolean`):

Keep only available variants. Note that `available` is only meaningful when the merchant tracks inventory — `inventoryTracked` in the output says whether they do.

## `onlyOnSale` (type: `boolean`):

Keep only variants whose price is below their compare-at price. `discountPercent` carries the depth.

## `requireSubscription` (type: `boolean`):

Keep only products offering a subscription or subscribe-and-save plan, with the real subscriber price and discount. Needs enrichment on.

## `minPrice` (type: `integer`):

Compared against the USD price, so it means the same thing on a GBP store as on a USD one.

## `maxPrice` (type: `integer`):

Compared against the USD price.

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

Brand names as they appear in the store. Useful on multi-brand retailers — Kith stocks 138 vendors.

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

The store's own product types, such as `Socks` or `Men's Jackets`.

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

Keeps a product carrying any one of these tags.

## `includeDescription` (type: `boolean`):

Adds the full description to every row. Off by default because it is large and most uses do not need it.

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

How many per-product requests run at once. Shopify throttles by address rather than by rate — the built-in Apify Proxy fallback handles that — so this mostly trades speed for politeness.

## Actor input object example

```json
{
  "storeUrls": [
    "allbirds.com",
    "https://gymshark.com"
  ],
  "enrich": true,
  "rowsPer": "variant",
  "maxProductsPerStore": 1000,
  "collectionsMode": "key",
  "requireBarcode": false,
  "onlyInStock": false,
  "onlyOnSale": false,
  "requireSubscription": false,
  "includeDescription": false,
  "maxConcurrency": 5
}
```

# Actor output Schema

## `products` (type: `string`):

Every product or variant returned, with all fields.

## `merchantFeed` (type: `string`):

Only the rows carrying everything Google Merchant Center needs, including a GTIN.

## `subscriptions` (type: `string`):

Products sold on a subscription plan, with the real subscriber price.

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

Rows returned, products enriched, and any stores that could not be read.

# 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": [
        "deathwishcoffee.com",
        "allbirds.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("spool/shopify-product-feed").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": [
        "deathwishcoffee.com",
        "allbirds.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("spool/shopify-product-feed").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": [
    "deathwishcoffee.com",
    "allbirds.com"
  ]
}' |
apify call spool/shopify-product-feed --silent --output-dataset

```

## MCP server setup

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

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/3Kt7rfbRQlsTbOjOP/builds/7e80PXm3cYwEcKWEQ/openapi.json
