By the end of this tutorial, you will have a short Python script that turns a Sephora US category page into structured JSON: one row per product with its price in US dollars, sale price and sale type, the full list-price range, rating, review count, number of sizes or shades, and Sephora's own Bestseller, New, Limited Edition, Online Only and Exclusive flags.
We use the Apify Actor Sephora Scraper (reventadata/sephora-scraper). It is for anyone who tracks beauty prices, watches sales in a category, or compares brands by rating, and would rather read rows than product tiles.
⏱️ Time: the setup is a few minutes. Run time depends on how many pages you ask for: the Actor waits 5 seconds between page requests, the Crawl-delay in Sephora's robots.txt.
You will learn how to configure the input, run the Actor from Python, and read every field it returns.
Prerequisites
- A free Apify account (sign up for free).
- Your Apify API token, found at console.apify.com/settings/integrations.
-
Python 3.11 or newer, which
apify-clientrequires.
Install the client:
pip install "apify-client~=3.0"
No scraping code is needed: the Actor handles pages, proxy and parsing. You only send an input and read rows.
What you'll achieve
You will send one or more Sephora category URLs (for example https://www.sephora.com/shop/moisturizing-cream-oils-mists or https://www.sephora.com/shop/lipstick) and receive one clean row per product. A category page holds 60 products, and the Actor walks as many pages as you allow.
Every row comes from the product data Sephora embeds in the category page, so the Actor does not open product pages one by one. The flip side: a row holds what the tile holds. There is no description, ingredient list or per-shade price table.
Example use case: you want to know which moisturizers are on sale right now, cheapest first. Set sort to PRICE_LOW_TO_HIGH and onlyOnSale to true, and a small maxItems returns the top of that list.
Step 1: Access Sephora Scraper
Open the Sephora Scraper page on Apify Store. In Apify Console, an Actor opens on its Input tab, where you can fill the same fields this tutorial sets from code.
We will run it from Python, so keep your API token at hand and export it:
export APIFY_API_TOKEN="<your token>"
Step 2: Configure the category and the caps
The one required field is startUrls: Sephora US category pages of the form https://www.sephora.com/shop/<category>. Query strings are dropped, because the Actor sets page, sort and filters itself. Search pages, /browse/ pages and product pages are refused before any page is requested: Sephora's robots.txt disallows search and browse, and the Actor stays on the category pages it leaves open.
Here is the script. Its input is the Actor's own prefill: one category, 20 rows, one page.
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_API_TOKEN"])
run_input = {
"startUrls": [{"url": "https://www.sephora.com/shop/moisturizing-cream-oils-mists"}],
"maxItems": 20,
"maxPages": 1,
"sort": "RELEVANCE",
"proxyConfiguration": {
"useApifyProxy": True,
"apifyProxyGroups": ["RESIDENTIAL"],
"apifyProxyCountry": "US",
},
}
run = client.actor("reventadata/sephora-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
raise SystemExit(f"Run did not succeed (status: {run.status if run else 'unknown'})")
for item in client.dataset(run.default_dataset_id).iterate_items():
print(item["brand"], "|", item["title"], "|", item["price"], item["currency"], "|", item["saleType"], "|", item["rating"])
maxItems counts rows saved after the filters, across all start URLs. maxPages caps pages per start URL. The defaults are 100 and 5.
Step 3: Add sort order and filters
| Parameter | Type | Default | What it does |
|---|---|---|---|
sort |
string | RELEVANCE |
RELEVANCE, BEST_SELLING, TOP_RATED, PRICE_LOW_TO_HIGH, PRICE_HIGH_TO_LOW or NEW. Applied by the site. |
brands |
array | none | Brand names as Sephora spells them (Tatcha, LANEIGE). Several brands are OR. Applied by the site. |
minPrice, maxPrice
|
number | none | Price band in USD, applied by the site. |
onlyOnSale |
boolean | false |
Keeps rows whose saleType is FULL or PARTIAL. |
minRating |
number | none | Minimum average rating, 0 to 5. Unrated products are dropped when set. |
includeKeywords |
array | none | Every word must appear in the name, brand or category. |
excludeKeywords |
array | none | Drops a row when any word appears there. |
proxyConfiguration |
object | residential, US | Apify residential proxy in the United States. |
Three details worth knowing:
-
The site applies sort, brands and the price band before the Actor reads the page, so a small
maxItemsgets the top of the right list, not a filtered sample of the wrong one. - The price band covers the whole list-price range. A product listed as a range is kept only when both ends fall inside your band. It is not an overlap test.
- Keywords ignore case and accents and look at the name, brand and category together.
Step 4: Run the Actor
Run the script with python sephora.py. .call() starts the Actor on Apify and waits until it finishes. Behind the scenes the Actor fetches each category page through an Apify residential proxy in the US, waits 5 seconds between page requests, reads the product data, applies your filters and saves the rows.
It stops at the first empty page, or when it has read as many products as the category holds.
⚠️ Tip:
.call()blocks until the run ends. To fire and forget, use a webhook instead (see the pro tips below).
Step 5: View and download results
The script prints one line per row. In Apify Console, results appear in the run's Output tab, and Export downloads them. Datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.
Here is the first row of a moisturizer page, a sample from a capture dated 2026-10-21, parsed with the Actor's own row builder. listPriceText, sourceUrl and scrapedAt are left out here:
{
"id": "P519160",
"skuId": "2898419",
"title": "Glazing Milk Hydrating Ceramide Facial Essence",
"brand": "rhode",
"url": "https://www.sephora.com/product/glazing-milk-P519160?skuId=2898419",
"imageUrl": "https://www.sephora.com/productimages/sku/s2898419-main-zoom.jpg?imwidth=270&pb=clean-at-sephora",
"price": 20.0,
"priceMin": 20.0,
"priceMax": 32.0,
"salePrice": null,
"valuePrice": null,
"onSale": false,
"saleType": null,
"currency": "USD",
"rating": 3.94,
"reviewCount": 2921,
"variantCount": 1,
"variationType": "Size",
"isNew": false,
"isBestseller": true,
"isLimitedEdition": false,
"isOnlineOnly": false,
"isSephoraExclusive": true,
"badge": "clean-at-sephora",
"category": "Moisturizers",
"categoryUrl": "https://www.sephora.com/shop/moisturizing-cream-oils-mists",
"categoryTotal": 727,
"page": 1,
"position": 1
}
Understanding your results
-
priceis the lowest current price: the sale price when there is one, else the low end of the list price. -
priceMinandpriceMaxare the two ends of the list price;listPriceTextkeeps it exactly as Sephora prints it. -
salePrice,onSaleandsaleTypedescribe a markdown.FULLmeans every size or shade is marked down,PARTIALonly some. -
valuePriceis the value Sephora states, shown on sets and some sizes. -
ratingis null when a product has no reviews. -
categoryTotalis how many products the category holds under your sort and filters.
Here is a sale row from page 2 of the same capture, trimmed to the price fields:
{
"id": "P455369",
"title": "Peptide Moisturizer",
"brand": "The INKEY List",
"price": 12.6,
"priceMin": 18.0,
"priceMax": 18.0,
"salePrice": 12.6,
"onSale": true,
"saleType": "FULL",
"rating": 3.72,
"reviewCount": 979,
"position": 25
}
On page 1 of that capture, all 60 products were rated, 32 listed a price range rather than a single price, 52 carried the Bestseller flag, and none was on sale.
Pro tips and advanced usage
-
Let the site sort for you.
PRICE_LOW_TO_HIGHwithmaxItems: 20returns the first 20 products in Sephora's own low-to-high order, because the site applies the order before the Actor reads the page. -
Narrow by brand before you widen by pages. In the 2026-10-21 recon, filtering moisturizers to
Tatchatook the category from 727 products to 13. Fewer pages means a shorter run. -
Combine start URLs. Put several categories in
startUrls. A product that sits in two of them is saved once, de-duplicated byid. -
Hook it to your pipeline. An Apify webhook fires when a run finishes; its
resourceis "the response of the Get Actor run API endpoint" (docs.apify.com/integrations/webhooks/actions), which carriesdefaultDatasetId. -
Use the flags as columns.
isBestseller,isNewandisSephoraExclusivecome straight from Sephora, so you can count them per brand without guessing from titles.
For current pricing and details, see the Store page.
Troubleshooting common issues
-
"is not a category page". The start URL is a search,
/browse/or product page. Use ahttps://www.sephora.com/shop/<category>URL. - "0 rows for ...". The run fails on purpose when nothing is saved: the brand has no products in that category, the price band is too narrow, or your filters dropped every row.
- "minPrice ... is above maxPrice". Swap the two values.
-
Sort rejected.
sorttakes only the six values above; anything else is refused before the run starts. -
A moved category. The Actor follows a moved category for up to two hops when it points to another
/shop/page; a category that does not exist is logged and skipped. Check the run log.
Next steps
Save your input as an Apify task and add a schedule: schedules use cron expressions, so a daily sale check is one line (docs.apify.com/actors/running/schedules). The Apify Python client docs cover the rest of the API, and you can start small with the prefill input above and grow from there.
What are you planning to scrape on Sephora? Drop a comment! 💬
Top comments (1)
The FULL versus PARTIAL saleType distinction is useful here. A daily BrowserAct Cloud Bot should keep that field beside priceMin and priceMax, rather than alerting on the lowest price alone. With no per-shade price table, a changed tile minimum does not tell you whether the same size or shade became cheaper.