# Shopify Store Scraper: Products, Variants & Inventory (`arman-bd/shopify-store-scraper`) Actor

Scrape any Shopify store's complete catalogue through the public /products.json endpoint: titles, variants, prices, SKUs, images, tags and availability. Millions of stores, no key, no browser.

- **URL**: https://apify.com/arman-bd/shopify-store-scraper.md
- **Developed by:** [Arman Hossain](https://apify.com/arman-bd) (community)
- **Categories:** E-commerce, Lead generation, MCP servers
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 record scrapeds

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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 Store Scraper: Products, Variants & Inventory

![Shopify Store Scraper: Products with variants, price ranges and stock, from any Shopify storefront](https://api.apify.com/v2/key-value-stores/ZQOcNAOHrIgTacAmy/records/shopify-store-scraper.jpg)

**Shopify Store Scraper** reads any Shopify storefront's complete catalogue through the public `/products.json` endpoint, titles, variants, prices, compare-at prices, SKUs, images, tags, product types and stock flags.

Every Shopify store publishes this data. It is the same catalogue the storefront's own theme renders from, so there is **no browser, no proxy, no login and no credentials to manage.** A 291-product catalogue comes back in seconds.

**Agent skill: [SKILL.md](https://api.apify.com/v2/key-value-stores/t7YoTxpZEJOWvw4Ug/records/shopify-store-scraper.md)**

```
https://api.apify.com/v2/key-value-stores/t7YoTxpZEJOWvw4Ug/records/shopify-store-scraper.md
```

### What you get

| Output field | Meaning |
|---|---|
| `store`, `storeName` | Domain you passed, and the shop name from the store's own record |
| `productId`, `title`, `handle` | Shopify product ID, display title, and the URL slug |
| `vendor`, `productType`, `tags` | Brand, Shopify's product type, and the full tag list as an array |
| `priceMin`, `priceMax`, `currency` | Cheapest and dearest variant, in the store's own currency |
| `available` | True when at least one variant is purchasable |
| `variantCount`, `variants` | Variant count, and the nested list with `price`, `compareAtPrice`, `sku`, `available`, `options` |
| `images`, `featuredImage` | Every product image URL, and the first one |
| `bodyPlain` | Product description as clean plain text, HTML stripped, entities decoded |
| `publishedAt`, `updatedAt`, `url` | Posting timestamps and the direct product URL |
| `scrapedAt` | Run timestamp |

A `RUN_SUMMARY` record in the key-value store holds per-run counts, the filters used, and any store that refused.

### Common use cases

- **Competitor price and assortment monitoring.** Daily snapshot per SKU, diff on `price` and `available`.
- **Dropshipping and sourcing research.** Filter thousands of catalogues by `productType`, `vendor` and price band.
- **New-product launch tracking.** Watch `publishedAt` across a set of brands.
- **Lead qualification.** Run `detectOnly` over a prospect list to find which domains are Shopify and how big their catalogue is.

### Quick start

Two stores, first 250 products each:

```json
{
 "stores": ["allbirds.com", "kith.com"]
}
```

Price monitoring, one row per size/colour, ready to join on SKU:

```json
{
 "stores": ["allbirds.com"],
 "outputMode": "variant",
 "collectionHandles": ["mens"],
 "maxProductsPerStore": 0
}
```

Qualify a prospect list without paying to scrape it:

```json
{
 "stores": ["allbirds.com", "gymshark.com", "example.com"],
 "detectOnly": true
}
```

### Input

| Field | Type | Default | Notes |
|---|---|---|---|
| `stores` | array | - | **Required.** Bare domains, full URLs, or `.myshopify.com` hosts. Everything after the host is stripped, so pasting a collection URL is fine. |
| `collectionHandles` | array | `[]` | Restrict to collections. The handle is the slug after `/collections/`. Full URLs accepted. Empty = whole catalogue. |
| `maxProductsPerStore` | integer | `250` | Cap on products read per store. `0` = everything Shopify will serve. |
| `outputMode` | string | `product` | `product` = one row per product, variants nested. `variant` = one flat row per variant. |
| `includeVariants` | boolean | `true` | Embed the variant list in product rows. Ignored in variant mode. |
| `minPrice` | number | `0` | Drop cheaper items, in the store's own currency. No FX conversion. |
| `detectOnly` | boolean | `false` | Report Shopify-or-not per domain and stop. Two requests per domain, no product rows. |

#### Which combinations make sense

- `outputMode: "variant"` with `maxProductsPerStore`, the cap counts **products**, not rows. A 250-product apparel catalogue is routinely 3,000+ variant rows, and each one is a billed event.
- `collectionHandles` **plus** `maxProductsPerStore`, the cap is per store, not per collection, and collections are read in the order given.
- `includeVariants: false` in product mode, the fastest, narrowest shape. You keep `priceMin`, `priceMax`, `variantCount` and `available`, which is enough for assortment tracking.
- `detectOnly` ignores every other filter.

### Output example

A real product record from a live run against `allbirds.com` (variant list abridged to two of thirteen):

```json
{
 "store": "allbirds.com",
 "storeName": "Allbirds",
 "productId": 7292464955472,
 "title": "Men's Cruiser - Shadow Blue (Natural White Sole)",
 "handle": "mens-cruiser-shadow-blue-natural-white-sole",
 "vendor": "Allbirds",
 "productType": "Shoes",
 "tags": ["DNAM BRANDS", "EC STOCK"],
 "currency": "USD",
 "url": "https://allbirds.com/products/mens-cruiser-shadow-blue-natural-white-sole",
 "variantCount": 13,
 "priceMin": 105,
 "priceMax": 105,
 "available": true,
 "variants": [
 {
 "id": 41990816759888,
 "title": "8",
 "sku": "A12856M080",
 "price": 105,
 "compareAtPrice": null,
 "available": true,
 "position": 1,
 "options": ["8"],
 "grams": 1000,
 "requiresShipping": true,
 "taxable": true,
 "updatedAt": "2026-08-06T04:38:01-07:00"
 },
 {
 "id": 41990816923728,
 "title": "10.5",
 "sku": "A12856M105",
 "price": 105,
 "compareAtPrice": null,
 "available": false,
 "position": 6,
 "options": ["10.5"],
 "grams": 1000,
 "requiresShipping": true,
 "taxable": true,
 "updatedAt": "2026-08-06T04:38:01-07:00"
 }
 ],
 "images": ["https://cdn.shopify.com/s/files/1/1104/4168/files/All-birds_0010.png?v=1783535519"],
 "featuredImage": "https://cdn.shopify.com/s/files/1/1104/4168/files/All-birds_0010.png?v=1783535519",
 "bodyPlain": "Inspired by a classic court style, this signature shoe delivers the right balance of style and comfort…",
 "publishedAt": "2026-07-08T11:32:23-07:00",
 "updatedAt": "2026-08-06T04:32:26-07:00",
 "scrapedAt": "2026-08-06T11:37:56.404Z"
}
```

In `detectOnly` mode you get one row per domain instead:

```json
{
 "store": "gymshark.com",
 "isShopify": true,
 "productsEndpointOpen": true,
 "storeName": "Gymshark US",
 "myshopifyDomain": "gymshark.myshopify.com",
 "currency": "USD",
 "country": "GB",
 "publishedProductsCount": 9301,
 "publishedCollectionsCount": 1723,
 "detail": "shop record found",
 "scrapedAt": "2026-08-06T11:39:12.008Z"
}
```

#### `RUN_SUMMARY`

```json
{
 "storesRequested": 4,
 "storesFailed": 2,
 "failures": [
 { "store": "shop.tesla.com", "page": 1, "error": "blocked (403), this storefront sits behind a bot wall (CDN or headless front end) and refuses the public endpoint" },
 { "store": "example.com", "page": 1, "error": "endpoint disabled or not a Shopify storefront (404)" }
 ],
 "requestsMade": 8,
 "productsListed": 389,
 "recordsSaved": 10,
 "filters": { "collectionHandles": [], "maxProductsPerStore": 5, "minPrice": 0, "outputMode": "product", "includeVariants": true, "detectOnly": false },
 "finishedAt": "2026-08-06T11:38:03.780Z"
}
```

`productsListed` vs `recordsSaved` separates "the store is small" from "my filters were too tight".

### How to tell whether a domain is Shopify

Set `detectOnly: true` and the Actor answers it for you: it returns the shop record with `myshopify_domain`, `currency`, `country` and the published product and collection counts. Domains that are not Shopify storefronts are reported as such rather than guessed at.

Two independent signals matter, and they can disagree:

- **`isShopify`**: the domain is a Shopify storefront.
- **`productsEndpointOpen`**: its catalogue JSON is actually readable. Some brands run a headless front end (Vercel, Next.js) or an enterprise CDN in front of Shopify; those answer `/products.json` with 403, 404 or 429 even though the store is Shopify.

Manually, the fastest check is `curl -sI https://{domain}/products.json | grep -i powered-by`, a genuine Shopify origin answers `powered-by: Shopify`.

### Finding a collection handle

Open the collection page and read the URL: `allbirds.com/collections/mens` → `mens`. You can paste the whole URL, the Actor extracts the handle. An unknown handle is **not** an error, Shopify serves an empty page for it, so the Actor logs a warning rather than failing the store.

### API example

```bash
curl -X POST "https://api.apify.com/v2/acts/arman-bd~shopify-store-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \
 -H "Content-Type: application/json" \
 -d '{
 "stores": ["allbirds.com"],
 "outputMode": "variant",
 "maxProductsPerStore": 50
 }'
```

### JavaScript example

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

const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('arman-bd/shopify-store-scraper').call({
 stores: ['allbirds.com', 'kith.com'],
 outputMode: 'variant',
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const v of items) {
 console.log(`${v.store} ${v.sku}, ${v.price} ${v.currency} ${v.available ? '' : '(out of stock)'}`);
}
```

### Limits and behaviour

- **Pagination is `?page=N&limit=250`.** 250 is Shopify's hard maximum for `limit`; asking for 300 silently returns 250. An empty `products` array marks the end. There is no cursor and no total count in the response.
- **25,000 products is Shopify's ceiling.** `page * limit` above 25,000 returns HTTP 400 (`"Page * Limit exceeds the 25000 limit."`), so 100 pages is the most any store will serve through this endpoint.
- **Not every store leaves it open.** Of eight well-known brands probed, five answered 200 and three refused: 403 behind Akamai, 404 and 429 behind a Vercel-hosted headless front end. Refusals are per-store failures with a message that names the cause; the run continues.
- **There is no stock quantity here.** The public endpoint exposes `available` as a boolean and nothing more, no `inventory_quantity`, no `barcode`, no `inventory_management`. Those exist only in the authenticated Admin API. This Actor does not invent them.
- **Prices are unconverted.** Each store reports in its own currency, as published by the store itself. `minPrice` compares raw numbers, so mixing a EUR and a USD store in one run with a price floor will not do what you want.
- **Transient errors are retried.** 429 and 5xx get three attempts with backoff, honouring `Retry-After`. 400/403/404 and non-JSON responses are fatal for that store and not retried.
- **Requests are paced 300 ms apart.** 40 back-to-back requests drew no throttling, so this is courtesy rather than necessity.
- **Public data only.** No authentication, no personal data, no access-control bypass.

### FAQ

**Do I need a proxy?** No. Proxy configuration is not required to run this Actor.

**Do I need a Shopify account or API key?** No. You supply no credentials.

**Why does one of my stores return nothing?** Three possibilities, and `RUN_SUMMARY` distinguishes them: the domain is not Shopify (`404`/non-JSON). It is Shopify but fronted by a CDN that blocks the endpoint (`403`/`429`), or your `collectionHandles` / `minPrice` filtered everything out. Run with `detectOnly: true` to settle the first two in one cheap pass.

**Can I get stock levels?** Only `available` (true/false) per variant. Shopify does not publish quantities on the storefront endpoint.

**Product rows or variant rows?** Product rows for catalogue snapshots and assortment analysis; variant rows for price monitoring, since each size and colour has its own price, SKU and stock flag. Switch with `outputMode`.

**Does `www.` matter?** Sometimes. Apex and `www.` hosts occasionally sit behind different edges, `gymshark.com` answers 200 while `www.gymshark.com` answers 403. If one form is refused, try the other.

**Can I schedule it?** Yes, it is designed for scheduled runs. A daily variant-level sweep of one 250-product store is two requests.

**Can I integrate it with something else?** Yes, Apify API, client libraries, webhooks, scheduled runs, dataset exports (JSON/CSV/Excel) or MCP. Output is structured JSON.

# Actor input Schema

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

Storefront domains. A bare domain ('allbirds.com'), a full URL ('https://www.allbirds.com/collections/mens') or the internal host ('weareallbirds.myshopify.com') all work. everything after the host is stripped. A domain that is not Shopify, or one whose owner disabled the endpoint, is reported in RUN\_SUMMARY.failures and the run carries on with the rest.

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

Restrict the crawl to specific collections instead of the whole catalogue. The handle is the slug after /collections/ in the URL (allbirds.com/collections/mens → 'mens'); full URLs are accepted too. An unknown handle is not an error on Shopify's side. it just returns an empty page, so check spelling if a collection yields nothing. Leave empty for every published product.

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

Cap on products read per store, applied after the price filter. Set 0 for the whole catalogue. Shopify itself stops at 25,000 products (100 pages of 250). In variant output mode this still counts products, so a 250-product cap can produce several thousand variant rows.

## `outputMode` (type: `string`):

'product' gives one row per product with its variants nested (the compact shape for catalogue snapshots and CSV exports. 'variant' flattens to one row per size/colour with its own price, SKU and stock flag) the shape you want for price monitoring and joins on SKU.

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

Embed the full variant list inside each product record. Turn off for a much narrower dataset when you only need titles and the price range. Ignored when Record granularity is set to 'variant'.

## `minPrice` (type: `number`):

Drop anything cheaper than this, in the store's own currency (no conversion is applied, and stores differ. check the 'currency' field). In product mode a product is kept if any of its variants clears the floor; in variant mode each variant is judged on its own. 0 keeps everything.

## `detectOnly` (type: `boolean`):

Skip the catalogue and just report, one record per domain, whether it is a Shopify storefront and whether its JSON endpoint is readable. Two cheap requests per domain. use it to qualify a prospect list before paying to scrape it.

## Actor input object example

```json
{
  "stores": [
    "allbirds.com",
    "https://www.brooklinen.com"
  ],
  "collectionHandles": [
    "mens",
    "new-arrivals"
  ],
  "maxProductsPerStore": 250,
  "outputMode": "product",
  "includeVariants": true,
  "minPrice": 0,
  "detectOnly": false
}
```

# Actor output Schema

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

Every record the run produced.

## `runsummary` (type: `string`):

The RUN\_SUMMARY record from the run's key-value store.

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

// Run the Actor and wait for it to finish
const run = await client.actor("arman-bd/shopify-store-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 = { "stores": [
        "allbirds.com",
        "kith.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("arman-bd/shopify-store-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 '{
  "stores": [
    "allbirds.com",
    "kith.com"
  ]
}' |
apify call arman-bd/shopify-store-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,arman-bd/shopify-store-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/tMatdIztZLTckEw3F/builds/OsXQop6NaJUC5sNII/openapi.json
