# Shopify Store Scraper: Products, Prices, Variants & Images (`hyperbach/shopify-products-scraper`) Actor

Scrape every product of any Shopify store by domain, or single products by URL: title, vendor, type, tags, description, price and compare-at price, availability, variants with SKU and options, images, dates and URL. Several stores per run, a cap per store, a free store row with a verdict.

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

## Pricing

from $0.50 / 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 Store Scraper: Products, Prices, Variants & Images

**Every product of a Shopify store, logged out: store domains, store URLs, `*.myshopify.com` addresses or product URLs in, one row per product out — the title, handle, vendor, product type, tags, the description as text and HTML, the lowest and highest price, the compare-at price, on sale, in stock, every variant with its SKU, options, availability and prices, the options, the image URLs, the created, updated and published dates, the product URL and the store's currency — several stores in one run, a cap per store.** One free `store` row per input carries a completeness verdict: COMPLETE (the feed's own end, checked against the store's own product count), CAPPED (your limit), TRUNCATED (Shopify's 25,000-product feed limit) or PARTIAL. A password-protected store, a domain with no Shopify feed, an unknown store: free typed rows. No login, no API key, no browser, proxies handled for you.

### Why this Shopify products scraper

- **Several stores in one run.** Give store domains (`allbirds.com`), store URLs, `*.myshopify.com` addresses or product URLs: each store gets its own cap (`maxProductsPerStore`), and the run's cap sits on top (`maxItems`). The store's name and currency come with every row.
- **Every field the store publishes.** The title, handle, vendor, product type and tags; the description as plain text and as HTML; the lowest and highest variant price, the highest compare-at price and whether any variant is on sale; in stock; every variant with its id, title, SKU, named options, availability, price, compare-at price, weight and shipping flags; the options with their values; the image URLs; the created, updated and published dates; the product URL.
- **Whole catalogues, checked.** Products come 250 a call in the store's own feed, page after page, to the feed's own end, and the store's own product count says whether the walk got them all: our test read Allbirds' 692 of 692 and Death Wish Coffee's 153 of 153 (2026-10-22). The free `store` row says how many products were delivered from how many pages and why the walk stopped: the feed's end (COMPLETE), your cap (CAPPED), Shopify's feed limit of 25,000 products a store (TRUNCATED, with the store's count) or a refusal or the time limit (PARTIAL).
- **Single products by URL.** A product URL (`/products/<handle>`, with or without a collection or a `?variant=`) reads that product with the same fields, its availability per variant included.
- **Honest runs.** A password-protected store is a free `password` row, a domain that serves no Shopify feed (a headless store or not a Shopify store) a free `no_feed` row with the hint to give its `*.myshopify.com` address, an unknown store or product a free `not_found` row (two exits agreed), a value that is not a store a free `invalid` row, a repeated value or the same store under two addresses a free `duplicate` row. Every input gets a row; a product is charged once a run.
- **Fast and light.** Plain HTTP on the store's own public feed: 250 products a call, about 0.6 KB on the wire a product. A store of 692 products took 3 calls on our test.

### Who it's for

- **E-commerce and pricing teams** — a competitor's whole catalogue with prices, compare-at prices and availability per variant, read again on a schedule to see what changed.
- **Market and product researchers** — the assortments of a list of brands in one run: vendors, product types, tags, price bands, new products by their created and published dates.
- **Data and analytics teams** — typed rows keyed by `store` and `product_id`, a free row per store with the status, the store's count and the verdict, and a run summary to check a pipeline against.

### Quick start

**A store's first 50 products and one product by URL**

```json
{
  "startUrls": [
    "https://www.allbirds.com",
    "https://www.allbirds.com/products/mens-tree-runners"
  ],
  "maxProductsPerStore": 50
}
```

**Two whole catalogues**

```json
{
  "startUrls": [
    "deathwishcoffee.com",
    "allbirds.com"
  ]
}
```

**A headless store on its Shopify address**

```json
{
  "startUrls": [
    "gymshark.myshopify.com"
  ],
  "maxProductsPerStore": 500
}
```

### Output

One row per product (and one free store row per input):

| field | meaning |
|---|---|
| `row_type` | `product` (one product, charged) or `store` (the input's row with the walk's verdict; free). |
| `input_index` | The 1-based position in `startUrls` of the input this row answers. |
| `input_value` | The input as you gave it (the domain or URL). |
| `input_kind` | What the input names: `store`, `product`, `invalid` or `duplicate`. |
| `store` | The store's host as given (`www.allbirds.com`). |
| `store_name` | The store's name, as the store publishes it. |
| `currency` | The store's currency (ISO 4217: `USD`, `GBP` …); every price on the row is in it. |
| `status` | A store row: `ok`, `password`, `no_feed`, `not_found`, `invalid`, `duplicate`, `failed` or `not_read` (see the FAQ). A product row: `ok`. |
| `status_detail` | Why, in words, when the status is not `ok`. |
| `scraped_at` | When the row was read (ISO 8601, UTC). |
| `product_id` | Shopify's product id. |
| `handle` | The product's handle (the last part of its URL). |
| `title` | The product's title. |
| `vendor` | The product's vendor (the brand), as the store sets it. |
| `product_type` | The product's type, as the store sets it (null when none). |
| `tags` | The product's tags. |
| `description` | The product's description as plain text (paragraphs on their own lines). |
| `description_html` | The product's description as the store's HTML. |
| `price` | The lowest variant price, in `currency`. |
| `price_max` | The highest variant price, in `currency`. |
| `compare_at_price` | The highest compare-at ("was") price among the variants, in `currency`; null when none is set. |
| `on_sale` | True when a variant's compare-at price is above its price. |
| `available` | True when at least one variant is available to buy. |
| `variant_count` | How many variants the product has. |
| `variants` | One object per variant: `variant_id`, `title`, `sku`, `options` (option name → value), `available`, `price`, `compare_at_price`, `grams`, `requires_shipping`, `taxable`, `position`, `image`, `created_at`, `updated_at`. |
| `options` | The product's options with their values (`[{name: Size, values: [8, 9, 10]}]`). |
| `image_count` | How many images the product has. |
| `featured_image` | The product's first image. |
| `images` | Every image URL of the product, in the store's order. |
| `created_at` | When the product was created in the store (ISO 8601, the store's time zone). |
| `updated_at` | When the product was last changed (ISO 8601, the store's time zone). |
| `published_at` | When the product was published to the store (ISO 8601, the store's time zone). |
| `url` | The product's page. |
| `store_url` | A store row: the store's address (or the product's, for a product URL). |
| `products_count` | A store row: the store's own count of published products (null when the store did not answer). |
| `products_delivered` | A store row: the product rows delivered for this input. |
| `pages_read` | A store row: how many calls of the store's feed were read. |
| `verdict` | A store row: `COMPLETE` (the feed's own end, at the store's count), `CAPPED` (your cap), `TRUNCATED` (the feed ended short of the store's count, or Shopify's 25,000 limit) or `PARTIAL` (a refusal or the time limit). |
| `verdict_detail` | A store row: the verdict in words — products delivered, pages read, the store's count, why the walk stopped. |

Example record:

```json
{
  "row_type": "product",
  "input_index": 2,
  "input_value": "deathwishcoffee.com",
  "input_kind": "store",
  "store": "deathwishcoffee.com",
  "store_name": "Death Wish Coffee",
  "currency": "USD",
  "status": "ok",
  "status_detail": null,
  "scraped_at": "2026-10-22T21:24:43Z",
  "product_id": 6703976153143,
  "handle": "shell-toddy-system",
  "title": "Toddy Cold Brew System",
  "vendor": "Toddy",
  "product_type": "Storage & Brewing",
  "tags": [
    "At Home",
    "Merch",
    "Storage & Brewing"
  ],
  "description": "Coffee as cold as your heart.﻿\nCold brew all year round? Sign me up. The Toddy Cold Brew System is a nonelectrical coffee maker that produces a smooth, rich liquid concentrate to m …(truncated for display)",
  "description_html": "<h4>Coffee as cold as your heart.﻿</h4>\n<p>Cold brew all year round? Sign me up. The Toddy Cold Brew System is a nonelectrical coffee maker that produces a smooth, rich liquid concentrate to make the best cold brew right from your kitchen. Skip the cafe lines and B.Y.O.B—be your own barista. <br></p>",
  "price": 45.0,
  "price_max": 45.0,
  "compare_at_price": null,
  "on_sale": false,
  "available": true,
  "variant_count": 1,
  "variants": [
    {
      "variant_id": 39897030885431,
      "title": "Default Title",
      "sku": "UOTODCBS",
      "options": {
        "Title": "Default Title"
      },
      "available": true,
      "price": 45.0,
      "compare_at_price": null,
      "grams": 1084,
      "requires_shipping": true,
      "taxable": true,
      "position": 1,
      "image": null,
      "created_at": "2022-08-31T14:34:37-04:00",
      "updated_at": "2026-10-22T17:24:42-04:00"
    }
  ],
  "options": [
    {
      "name": "Title",
      "values": [
        "Default Title"
      ]
    }
  ],
  "image_count": 3,
  "featured_image": "https://cdn.shopify.com/s/files/1/0271/7209/products/Toddy-100.jpg?v=1666098019",
  "images": [
    "https://cdn.shopify.com/s/files/1/0271/7209/products/Toddy-100.jpg?v=1666098019",
    "https://cdn.shopify.com/s/files/1/0271/7209/products/Toddy-101.jpg?v=1666098019",
    "https://cdn.shopify.com/s/files/1/0271/7209/products/Toddy-104.jpg?v=1666098025"
  ],
  "created_at": "2022-08-31T14:34:37-04:00",
  "updated_at": "2026-10-22T17:24:42-04:00",
  "published_at": "2024-08-06T09:05:03-04:00",
  "url": "https://deathwishcoffee.com/products/shell-toddy-system",
  "store_url": null,
  "products_count": null,
  "products_delivered": null,
  "pages_read": null,
  "verdict": null,
  "verdict_detail": null
}
```

### Pricing

**Pay for the products a run delivers**, plus one run start; no platform usage is billed to you. The product price falls with your Apify plan.

| event | unit | Free | Starter | Scale | Business |
|---|---|---|---|---|---|
| Product (`product`) | per 1,000 | $1.00 | $0.85 | $0.70 | $0.50 |
| Run start (`start`) | per run | $0.0013 | $0.0013 | $0.0013 | $0.0013 |

A product is **$1.00 per 1,000** on the Free plan ($0.001 a product). Each run that delivers a charged product also pays the **run start, $0.0013 once**; a run that delivers no charged product pays nothing at all. So a store of 700 products costs $0.7013 on the Free plan and $0.5963 on Starter; several stores in one run share one start. The free `store` row of every input, a password-protected store (`password`), a domain with no Shopify feed (`no_feed`), an unknown store or product (`not_found`), a value that is not a store (`invalid`), a repeated value (`duplicate`), a store left at the cap or the time limit (`not_read`) and a refusal the run could not pass (`failed`) are never charged. A product is charged once a run. A run that stops early (a timeout, an abort) is charged for the products it delivered. `maxItems` counts the charged rows; before the first request the log names the price of each row at your plan and the most the run can cost. Enterprise plans have their own rates: the Actor's Pricing tab shows the price for your plan.

### Usage patterns

- **Stores and products** — `startUrls` takes a store's domain (`allbirds.com`), a store URL (`https://www.allbirds.com/` — any page of the store reads the whole store), a store's own Shopify address (`yourstore.myshopify.com`) or a product URL (`https://www.allbirds.com/products/mens-tree-runners`). A headless store (its site is not Shopify's storefront) serves no feed on its own domain: give its `*.myshopify.com` address. A collection URL is not read as a collection: give the store's URL.
- **What a row is** — `row_type` = `product`: one product with the store's record; `variants` holds one object per variant. `row_type` = `store`: the free row of one input value — `products_count` (the store's own count of published products), `products_delivered`, `pages_read`, the `verdict` with its `verdict_detail`, and the `status` with its reason.
- **How far a store goes** — `maxProductsPerStore` = 0 (the default) reads every product the store's feed lists — up to 25,000, Shopify's own limit for the feed. `maxProductsPerStore` = 100 is the first 100 in the feed's order. `maxItems` caps the charged rows of the whole run. The store row's `verdict` says where the walk stopped and why.

### Input configuration

| field | type | default | what it does |
|---|---|---|---|
| `startUrls` | `array` |  | What to read: a store's domain (`allbirds.com`), a store URL (`https://www.allbirds.com/`), a store's own Shopify address (`yourstore.myshopify.com`) for every product of the store, or a product URL (`https://www.allbirds.com/products/mens-tree-runners`) for one product. Up to 1,000 values a run. A headless store whose own domain serves no Shopify product feed reads on its `*.myshopify.com` address. A value that is not a store gets a free `invalid` row, a value given twice a free `duplicate` row. |
| `maxProductsPerStore` | `integer` | `0` | Stop each store after this many product rows (0 = every product the store's feed lists; Shopify's feed lists at most 25,000 products a store). |
| `maxItems` | `integer` | `0` | Stop the run after this many product rows across every store (0 = no limit). Store rows are free and not counted; a store left unread at the cap gets a free `not_read` row. |

### Errors

**A run does not fail because of your input.** When an input cannot be used — a date that is not a date, an id in the wrong form, two settings that contradict each other — the run ends **Succeeded**, charges nothing, and says what to change:

- the status message starts with `INPUT REJECTED`;
- the dataset holds one row, `{"error": true, "code": "…", "message": "…"}`, and no results;
- the key-value store record `ERROR` holds the same object.

From code, check `error` on the first row before reading results.

A store whose products are not public is not an error: a password-protected store gets one free `store` row with `status` password, and the run goes on with the others. A run that ends **Failed** is a fault on our side, never your input; it charges nothing, and we are alerted.

### FAQ

**Which status is which?**

A store row's `status` is `ok` (products served), `password` (the store is password-protected: no product is public), `no_feed` (the domain serves no Shopify product feed: not a Shopify store, or a headless one — give its `*.myshopify.com` address), `not_found` (no such store, or no such product; two exits agreed), `invalid` (a value that is not a store or product URL, or a collection URL), `duplicate` (the same value, or the same store under another address: read once), `failed` (a refusal or an error; free) or `not_read` (left at `maxItems`, the charge limit or the run's time limit). A product row's `status` is `ok`.

**Does it give the exact stock count?**

No. The store's public feed says whether each variant is available, not how many are in stock; the row carries `available` per variant and per product.

**Why are some prices without a compare-at price?**

The compare-at price is the store's own "was" price; most products have none, and a store that writes 0 means none. `on_sale` is true when a variant's compare-at price is above its price.

**Are the prices in my currency?**

The prices are the store's own catalogue prices in the store's currency (`currency` on every row). On our test the feed gave the same prices to visitors from the US, Germany and the UK.

**Does it need a Shopify account or API key?**

No. Everything is read from the store's public product feed, logged out.

### Integration

#### JavaScript

```javascript
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('hyperbach/shopify-products-scraper').call({"startUrls": ["https://www.allbirds.com", "https://www.allbirds.com/products/mens-tree-runners"], "maxProductsPerStore": 50});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

#### Python

```python
from apify_client import ApifyClient
client = ApifyClient('YOUR_TOKEN')
run = client.actor('hyperbach/shopify-products-scraper').call(run_input={'startUrls': ['https://www.allbirds.com', 'https://www.allbirds.com/products/mens-tree-runners'], 'maxProductsPerStore': 50})
items = client.dataset(run['defaultDatasetId']).list_items().items
```

#### CLI

```bash
apify call hyperbach/shopify-products-scraper --input '{"startUrls": ["https://www.allbirds.com", "https://www.allbirds.com/products/mens-tree-runners"], "maxProductsPerStore": 50}'
```

#### REST

```bash
curl -X POST "https://api.apify.com/v2/acts/hyperbach~shopify-products-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H 'Content-Type: application/json' -d '{"startUrls": ["https://www.allbirds.com", "https://www.allbirds.com/products/mens-tree-runners"], "maxProductsPerStore": 50}'
```

### Support

apify@hyperbach.com

*This page is generated from the Actor's schemas and a live sample — it cannot describe a field the Actor does not have.*

# Actor input Schema

## `startUrls` (type: `array`):

What to read: a store's domain (`allbirds.com`), a store URL (`https://www.allbirds.com/`), a store's own Shopify address (`yourstore.myshopify.com`) for every product of the store, or a product URL (`https://www.allbirds.com/products/mens-tree-runners`) for one product. Up to 1,000 values a run. A headless store whose own domain serves no Shopify product feed reads on its `*.myshopify.com` address. A value that is not a store gets a free `invalid` row, a value given twice a free `duplicate` row.

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

Stop each store after this many product rows (0 = every product the store's feed lists; Shopify's feed lists at most 25,000 products a store).

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

Stop the run after this many product rows across every store (0 = no limit). Store rows are free and not counted; a store left unread at the cap gets a free `not_read` row.

## Actor input object example

```json
{
  "startUrls": [
    "https://www.allbirds.com",
    "https://www.allbirds.com/products/mens-tree-runners"
  ],
  "maxProductsPerStore": 50,
  "maxItems": 100
}
```

# Actor output Schema

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

All scraped records in the default dataset. One row per product (and one free store row per input):

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

How the run went: product rows charged, free rows, inputs by status, the walk per store (products delivered, pages read, repeated ids skipped, the store's count, the verdict), the source counters (calls, served, refused, exits, rotations, bytes) and the memory watch.

# 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 = {
    "startUrls": [
        "https://www.allbirds.com",
        "https://www.allbirds.com/products/mens-tree-runners"
    ],
    "maxProductsPerStore": 50,
    "maxItems": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("hyperbach/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 = {
    "startUrls": [
        "https://www.allbirds.com",
        "https://www.allbirds.com/products/mens-tree-runners",
    ],
    "maxProductsPerStore": 50,
    "maxItems": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("hyperbach/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 '{
  "startUrls": [
    "https://www.allbirds.com",
    "https://www.allbirds.com/products/mens-tree-runners"
  ],
  "maxProductsPerStore": 50,
  "maxItems": 100
}' |
apify call hyperbach/shopify-products-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,hyperbach/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/s0p8YWZ5ZTFLPuW6x/builds/UwQKj8hSIimUhgoHH/openapi.json
