# Ulta Beauty Scraper - Beauty Price & Sale Tracker (USA) (`superslowsloth/ulta-beauty-products`) Actor

Ulta Beauty scraper: category and brand pages to one row per product with USD list and sale price (ranges split), discount, promo, rating, review count, badges, shade count, link and photo. For beauty price and sale watching by category or brand.

- **URL**: https://apify.com/superslowsloth/ulta-beauty-products.md
- **Developed by:** [Superslow Sloth](https://apify.com/superslowsloth) (community)
- **Categories:** E-commerce, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.28 / 1,000 product scrapeds

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/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

## Ulta Beauty scraper: beauty price and sale tracker by category or brand (USA)

This Ulta Beauty scraper reads [Ulta Beauty](https://www.ulta.com) (the US beauty
retailer) category and brand pages and returns one flat row per product: the
list price and the sale price in **USD** (a price range split into low and high),
the discount in dollars, the promotion line, rating and review count, badges,
how many shades the product has, the product link and the photo. Run it on a
schedule and diff the dataset to get a **beauty price and sale watch**: which
foundations or Maybelline products were marked down since last week, and by how
much.

No account, no API key, no login, no browser. One request returns 64 products.

### Input

| Field | Notes |
|---|---|
| `startUrls` | One or more Ulta pages that list products: a **category** (`https://www.ulta.com/shop/makeup/face/foundation`, `/shop/skin-care/moisturizers`) or a **brand** (`https://www.ulta.com/brand/maybelline`, `/brand/olaplex`). A site path works as well as a full URL. Each page is read separately, in Ulta's own order. A product found by two pages is returned once, under the first. Anything after the path (`?page=`, filters, `sort`) is ignored. |
| `maxItems` | Products to read per page (default 100, up to 10,000). 64 per page. |
| `proxyConfiguration` | Apify datacenter proxy by default. |

A top-level category with no list of its own (`/shop/makeup`, `/shop/skin-care`)
shows only featured products, so the actor refuses it with a message instead of
returning those; pick a sub-category such as `/shop/makeup/face/foundation`. A
page Ulta does not have (`/brand/fenty-beauty`, a misspelt category) fails that
page at no charge and the run goes on with the others.

Example input:

```json
{
  "startUrls": [
    { "url": "https://www.ulta.com/shop/makeup/face/foundation" },
    { "url": "https://www.ulta.com/brand/maybelline" }
  ],
  "maxItems": 300
}
```

### Output

Prices are numbers in US dollars; `currency` is `USD` (every page measured).

| Field | Notes |
|---|---|
| `source`, `position` | The Ulta page the product came from (`/shop/makeup/face/foundation` or `/brand/maybelline`), and its place in Ulta's order for that page (1 is first, counted over every card of the pages read). |
| `product_id`, `sku_id` | Ulta's product id (`pimprod2057355`, mixed case kept as sent) and the default SKU id. **`product_id` is the unique key.** Ulta groups the shades and sizes of a product under one id, so a row is a product, not a shade. |
| `name`, `brand` | As the page shows them. `brand` is null if the page sends none. |
| `url`, `image_url` | The product link (it carries `?sku=` for the default SKU) and the photo, as sent. |
| `list_price`, `list_price_max` | The list price. Most products have one price; when the shades or sizes differ the page shows a range (`$27.50 - $55.00`), and then `list_price` is the **low** end and `list_price_max` the high end. `list_price_max` is null for a single price, never equal to `list_price`. |
| `sale_price`, `sale_price_max` | The current price when marked down, split into low and high the same way. **Null when not on sale**, never 0. |
| `on_sale` | True when the page sent a sale price. Ulta sends one when some version of the product is cheaper than its list price. For a product with a price range the low ends can be equal (a `$4.49 - $10.00` product on sale at `$4.49 - $5.00` was read on 2026-10-19), so compare the high ends too. No sale price read was above its list price. |
| `discount_amount` | The dollars off the page shows (`$10.00` is 10.0). For a product with a price range this is the page's own figure for the group and need not equal `list_price` minus `sale_price`. Null when the page shows none, which includes some products that are on sale. |
| `promo_text` | The card's promotion line (`Free Gift with purchase`, `Bundle & Save!`, `5 for $10`, `Replenish & Save 20%`). Null if none. |
| `rating`, `review_count` | The average star rating (1 to 5) and how many reviews it is from. **Both are null for a product with no reviews**: the page sends null, which is not a rating of 0. |
| `badges` | The card's badges in the order shown (`Online only`, `Only at Ulta`, `New`, `Sale`). Empty list if none. |
| `variant_label` | How many shades, sizes or scents the product groups (`57 colors`, `3 sizes`). Null for a single version. |
| `limited_stock`, `sponsored` | The page's limited-stock flag and paid-placement flag. Null if absent. |
| `currency` | `USD`. |

Example row (real values, 2026-10-19):

```json
{
  "source": "/shop/makeup/face/foundation",
  "position": 2,
  "product_id": "xlsImpprod10791919",
  "sku_id": "2206255",
  "name": "Even Better Makeup Broad Spectrum SPF 15 Foundation with Vitamin C",
  "brand": "Clinique",
  "url": "https://www.ulta.com/p/even-better-makeup-broad-spectrum-spf-15-foundation-with-vitamin-c-xlsImpprod10791919?sku=2206255",
  "image_url": "https://media.ultainc.com/i/ulta/2206255",
  "currency": "USD",
  "list_price": 39.0,
  "list_price_max": null,
  "sale_price": 29.0,
  "sale_price_max": null,
  "on_sale": true,
  "discount_amount": 10.0,
  "promo_text": "Bundle & Save!",
  "rating": 4.4,
  "review_count": 4159,
  "badges": ["Sale"],
  "variant_label": "50 colors",
  "limited_stock": false,
  "sponsored": false
}
```

#### Honest nulls

A field the page did not send is `null`, never `0` or `""`. `badges` is an empty
list when there are none. Measured on 2026-10-19, of 708 products read:
94 (13%) had no reviews (`rating` and `review_count` null) and 24 were on sale.

- **No per-shade rows or prices.** The list carries one price (or one range) per
  product. `variant_label` says how many shades it groups.
- **No stock count, description or ingredients.** This is the category and brand
  list, one row per product.
- **No percent off.** The page shows the discount in dollars only; compute a
  percent from `list_price` and `sale_price` for a single-price product.

### Pricing

Pay per event:

- **$0.002** per run (`actor-start`), charged only after your input is
  validated, so a run that fails on bad input costs nothing.
- **$0.00028** per product row written (`product-scraped`), i.e. $0.28 per
  1,000 products. A row that is not written is not charged: repeats of a
  product another page already returned are free.

### How it fetches

It reads the product list that ulta.com's own listing pages embed in their page
state (`window.__APOLLO_STATE__`). Measured 2026-10-19: HTTP 200 HTML (1.2 to
4.5 MB a page) from a plain home connection and from Apify datacenter and
residential addresses (14 of 15 pages on a datacenter exit in the pick step), no
key or cookie needed. A refusal (403, 429, 5xx), a timeout, a 200 that carries
no page data (Ulta's small "Our Apologies" waiting-room page), a page other than
the one asked for, or a dropped connection is treated as the address being
blocked, and the actor rotates to a fresh one and retries.

Things worth knowing, all measured:

- **Paging uses `page=`, 1-based, 64 products a page.** Foundation has 253
  products over four pages (64, 64, 64, 61), disjoint; Maybelline has 98 over
  two. A page past the end is HTTP 200 with no products, and the actor stops
  there. The page says which page it rendered, and the actor treats a mismatch
  as a refusal rather than return the first page again and charge for it.
- **The total** is the page's own product count (253 for foundation). It is not
  taken from the page title, which omits it on some listings (`New Beauty
  Products` has 3,886 products and no count in its title). It drifts with
  Ulta's stock.
- **A trailing slash loses the page.** `/shop/makeup/face/foundation/?page=2`
  is redirected to page 1; the actor always sends the path without one.
- **Depth.** At most 10,000 products per page are read. The largest listing
  checked, New Beauty Products (3,886 products), answers 61 pages (60 full pages of 64, then
  46\) and an empty page 62; moisturizers (2,154) ends at page 34.
- **Ulta can sort** with a `sort` parameter, but the values were not mapped, so
  none is offered and a query string in your URL is ignored. Sort the dataset by
  `sale_price` or `discount_amount` instead.
- **A path that does not exist** is HTTP 404 (`/brand/fenty-beauty`) or Ulta's
  waiting-room page (`/shop/sale`); the second is retried from fresh addresses
  before the page is given up on.

If Ulta changes its page data, the run fails with a message instead of returning
empty rows.

# Actor input Schema

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

Ulta Beauty pages to read in full, one per line: a category such as https://www.ulta.com/shop/makeup/face/foundation or a brand such as https://www.ulta.com/brand/maybelline. A site path (/shop/skin-care/moisturizers) works too. Use a page that lists products: a top-level category such as /shop/makeup only shows featured products and is refused, and so is a page Ulta does not have. A product found by two pages is returned once, under the first. Anything after the path (?page=, filters, sort) is ignored.

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

Read at most this many products from each category or brand page. Ulta lists 64 per page, so 250 reads four pages. Up to 10,000 are accepted.

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

Use Apify's automatic datacenter proxy (the default). ulta.com answered Apify datacenter addresses with HTTP 200 when this actor was built (14 of 15 pages), and residential addresses too. A refused request, a waiting-room page or a timeout is retried from a fresh address.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://www.ulta.com/shop/makeup/face/foundation"
    }
  ],
  "maxItems": 100,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

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

No description

# 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": [
        {
            "url": "https://www.ulta.com/shop/makeup/face/foundation"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("superslowsloth/ulta-beauty-products").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": [{ "url": "https://www.ulta.com/shop/makeup/face/foundation" }],
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("superslowsloth/ulta-beauty-products").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": [
    {
      "url": "https://www.ulta.com/shop/makeup/face/foundation"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call superslowsloth/ulta-beauty-products --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,superslowsloth/ulta-beauty-products"
        }
    }
}
```

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/7PoG2NfbJIaAxbrEj/builds/eXk71IL3KUPB9woGL/openapi.json
