# Sephora API — search sephora.com for makeup, skincare, haircare, fragrance and beauty tools, and pull one product in full: the whole image gallery, every shade/size variant with its own Sephora SKU id, price and stock, the specification attributes, ingredients, brand, availability, star rating and the top customer reviews with their text inline — no API key or account required

> Search the Sephora catalog. Give a `query` ('mascara', 'vitamin c serum', 'dior lipstick', 'rare beauty blush') and get back 60 products per page, each with Sephora's own product id and default SKU id, brand, price (both ends when Sephora quotes a range), sale flag, star rating, review count, how many shades the product has, the image set, and Sephora's own merchandising flags (bestseller, new, limited edition, Sephora exclusive, same-day/pickup eligibility). Narrow by price band, sort by rating, price or newest. The live facet vocabulary Sephora publishes for your query comes back in `facets`, and the category counts and its own related searches come with it.
> ReefAPI engine `sephora` · 3 endpoints · clean JSON, no scraping or browsers to manage.

## How to call
- **Endpoint:** `POST https://api.reefapi.com/sephora/v1/<action>` with a JSON body.
- **Auth:** header `x-api-key: <YOUR_REEFAPI_KEY>` — create one free (1,000 credits, no card): https://reefapi.com/signup
- **Response (every call):** `{ ok: boolean, data: ..., meta: { record_count, credits, ... }, error: { code, message } }` — branch on `ok`. Failed or blocked calls are free.
- **One key + one shared credit pool** across every ReefAPI API. Per-call credits are listed on each endpoint below.
- **Use it from an AI agent (MCP):** connect `https://api.reefapi.com/mcp` (remote streamable-http, `Authorization: Bearer <key>`) and your assistant can call these actions directly.

## Endpoints

### POST /sephora/v1/search — 1 credit
Search the Sephora catalog. Give a `query` ('mascara', 'vitamin c serum', 'dior lipstick', 'rare beauty blush') and get back 60 products per page, each with Sephora's own product id and default SKU id, brand, price (both ends when Sephora quotes a range), sale flag, star rating, review count, how many shades the product has, the image set, and Sephora's own merchandising flags (bestseller, new, limited edition, Sephora exclusive, same-day/pickup eligibility). Narrow by price band, sort by rating, price or newest. The live facet vocabulary Sephora publishes for your query comes back in `facets`, and the category counts and its own related searches come with it.

**Parameters:**
- `query` (string, required) — What to search sephora.com for — a category ('mascara', 'vitamin c serum', 'perfume'), a brand ('rare beauty'), a brand + category ('dior lipstick') or a product name. Sephora runs this through its own NLP search, so it is forgiving of phrasing. ⚠ It is forgiving to a fault: Sephora's search NEVER returns zero — a nonsense query still comes back with ~56 loosely-matched products (measured). Read `search.keyword` and judge the rows; do not treat a non-empty response as proof the term matched.
- `max_results` (integer, optional, default 60) — How many products to return (1-300). Sephora paginates 60 per page; asking for more fetches the following pages automatically, up to five.
- `page` (integer, optional, default 1) — Which result page to start from (1-based, 60 products per page). Page until `meta.pagination.has_more` is false. Sephora repeats a small number of sponsored rows across pages; those are de-duplicated by `product_id` before you see them.
- `sort` (enum, optional) — Result ordering, using Sephora's own sort tokens. Only the five listed here were MEASURED to change the ordering — Sephora's code also defines BEST_SELLING, EXCLUSIVE and BRAND_NAME, and all three return the identical default order on this endpoint, so they are deliberately not offered rather than accepted and ignored. An unknown value is rejected, not silently dropped (Sephora itself silently ignores it). [one of: relevance, top_rated, price_low, price_high, newest]
- `price_min` (number, optional) — Only products priced at or above this, in US dollars. This is Sephora's own `pl`/`ph` filter and it is the ONE filter this endpoint applies at all. ⚠ It NARROWS hard but it is not a strict predicate: 'foundation' goes 191 → 39 with price_max=25, and 8 of those 39 are priced entirely above $25 (verified variant-by-variant — they do not have a cheaper size). Those rows are passed through rather than dropped, and `meta.filters.applied[].rows_outside_band` counts them for the call you actually made.
- `price_max` (number, optional) — Only products priced at or below this, in US dollars. Sephora prices a multi-shade product as a RANGE ('$16.00 - $25.00'); the filter is Sephora's own and it decides how a range is matched, not us. Same caveat as `price_min`: it narrows hard but lets some out-of-band rows through, and `meta.filters` counts them.
- `brand` (string, optional) — Keep only products from this brand, matched against the `brand` on each row (case-insensitive, exact then 'contains'). ⚠ HONEST NOTE: this is applied BY THIS ENGINE to the pages it fetched, not by Sephora. Sephora publishes a Brand facet on this endpoint and then ignores it — every brand value returns the identical unfiltered 191 results (measured, both bracket spellings, with and without its NLP engine). So `meta.pagination.total_results` stays Sephora's unfiltered number and `meta.filters` reports exactly what was dropped and by whom. The live brand vocabulary for your query is in `facets`.

**Returns:** products[]{product_id, sku_id, name, brand, url, price, price_max, price_display, currency, on_sale, on_sale_label, rating, review_count, variant_count, image, images[]{url, url_250, alt}, is_sponsored, is_new, is_bestseller, is_limited_edition, is_limited_time_offer, is_online_only, is_sephora_exclusive, is_app_exclusive, ships_to_home, same_day_eligible, next_day_eligible, store_pickup_eligible} + search{keyword, total_results, engine, categories[]{name, category_id, level, product_count}, related_searches[]} + facets[]{name, type, values[]{label, query_fragment, status}}. meta.pagination{page, per_page, total_results, returned, has_more, next_page}; meta.filters{applied, note}. Honest caveats, all measured: Sephora's NLP search NEVER returns zero — a nonsense query still yields ~56 loosely-related products, so judge the rows rather than the count; `price` is the LOW end when Sephora quotes a range and `price_display` is its own string verbatim; `rating`/`review_count` arrive from Sephora as strings here and as numbers on the product page and are pinned to float/int in both; `variant_count` is Sephora's `moreColors` and counts shades, which for a single-shade product is 1; search rows carry NO per-shade price, stock, ingredients or reviews — that is what `product_detail` is for; and `facets` is returned because it is real published data about your query, NOT because it filters — this endpoint ignores every facet value except the price band (proven in the BUILD-LOG), which is why `price_min`/`price_max` are the only upstream filters offered and `brand` says plainly that it is applied here rather than by Sephora. Even the price band is a narrowing filter rather than a strict predicate, so `meta.filters.applied[].rows_outside_band` reports how many rows of YOUR result set fall outside the band you asked for.

**Example request body:**
```json
{
  "query": "mascara",
  "max_results": 30
}
```

### POST /sephora/v1/price — 2 credits
Re-check ONE shade's price and stock, cheaply. Give it the Sephora SKU id you already hold and it returns the current price, the regular price, whether that shade is in stock and whether Sephora is flagging it as nearly gone — and nothing else: no description, images, ingredients, shade matrix or reviews. Built for refreshing a catalogue you already have rather than discovering products. It reads Sephora's own SKU-scoped route instead of the whole product document, so it costs a fraction of `product_detail` — measured live on ten products in ten categories: 7.9-14.3 KB against 59.9-421.5 KB for a full `product_detail` (5.2x-45.9x), or 32.5-350.5 KB with its reviews switched off (2.9x-38.1x). The saving grows with the shade count, because the product document carries every sibling shade and this one carries yours. `sku_id` is REQUIRED, because Sephora prices and stocks each shade and size separately: a 49-shade foundation had 9 shades sold out while the product was on sale, so a product-level answer would be right about the product and wrong about the shade you are actually selling.

**Parameters:**
- `sku_id` (string, optional) — REQUIRED. Sephora's own SKU id for the ONE shade or size you want re-priced — digits only, exactly as `search` and `product_detail` publish it (`2837375`). It is required, not optional, because Sephora prices and stocks every shade and size separately: the same lipstick can be on sale in one shade and sold out in the next, so there is no single product-level answer to give you. A SKU Sephora does not have returns NOT_FOUND. You may pass a sephora.com product URL carrying `?skuId=…` here or in `url` instead.
- `product_id` (string, optional) — Optional, and it is a SAFETY CHECK rather than a lookup key: pass the product id you have stored against this SKU and the call fails with NOT_FOUND if Sephora says the SKU belongs to a different product. That is how you find out your catalogue has drifted, instead of quietly writing another product's price into it. Omit it and you simply get Sephora's product id for the SKU back in the response.
- `url` (string, optional) — A sephora.com product URL carrying `?skuId=…` — an alternative to `sku_id`. A URL without a `skuId` cannot be priced, because it names a product rather than a purchasable shade.

**Returns:** product_id, sku_id, variant_label, variant_type, current_price, regular_price, currency, on_sale, in_stock, is_low_stock. `current_price` is what a shopper pays now and `regular_price` is the struck-through one; when Sephora prints a single price the two are equal. `in_stock` is null — NEVER false — when Sephora states nothing, because silence is not an out-of-stock claim; the same rule applies to `on_sale` and `is_low_stock`. `variant_label` is Sephora's own name for the shade or size ('Stone', '1.7oz/50ml') so you can see the answer is about the variant you meant. `regular_price_max`/`current_price_max` appear only when Sephora prints a range for that SKU. Honest caveats: (1) an unknown SKU returns NOT_FOUND — measured, Sephora's SKU route rejects a SKU it does not have rather than substituting a default shade, and the response is additionally checked against the SKU that was asked for; (2) pass `product_id` too and a SKU that has moved to a different product fails with NOT_FOUND instead of silently repricing the wrong item; (3) stock is a BOOLEAN, because Sephora publishes no unit count anywhere; (4) there is no `condition` field — Sephora is a first-party retailer of new goods with no marketplace and no used stock; (5) every value is read from the same Sephora keys `product_detail` reads for the same SKU, so the two actions agree by construction — verified live on 13 SKUs including on-sale and out-of-stock ones.

### POST /sephora/v1/product_detail — 2 credits
Get one Sephora product in full, by its product id (or its sephora.com URL). One call returns: the complete image gallery; the complete variant matrix — one row per shade/size with Sephora's own SKU id, that variant's price, sale price, in-stock flag, low-stock flag, swatch, image and URL; the specification set Sephora publishes (size, the structured attribute map — coverage, finish, formulation, skin type, skin concerns, ingredient preferences —, the highlight badges with their explanations, the full ingredient list and the how-to-use copy); the brand with its id and page; availability and price range; the star rating with its distribution; and the top customer reviews with their full text, titles, dates, helpfulness votes, reviewer skin-tone/skin-type/eye/age context and photos. It also returns the product's UPC/EAN barcodes, which Sephora's own product API does not publish anywhere.

**Parameters:**
- `product_id` (string, required) — Sephora's own product id — the `P…` code at the end of every sephora.com/product/… URL and the `product_id` on every `search` row. A 'P' followed by 5-9 digits (`P427418`, and 8-digit ones like `P87985432` are real). Always a string, taken verbatim; never hashed, composed or re-derived. You may pass the full product URL in `url` instead of this.
- `url` (string, optional) — A full sephora.com product URL — an alternative to `product_id`. A `?skuId=` on the URL is honoured and selects that shade as `selected_variant`.
- `sku_id` (string, optional) — Which shade/size to return as `selected_variant`. Sephora's own SKU id, exactly as `search` and the variant rows publish it (digits only). Omit to get the SKU Sephora shows by default. Every variant comes back either way — this only moves the `is_selected` flag. An id that does not belong to the product fails with NOT_FOUND and lists the ones that do; it never quietly returns a different shade.
- `include_reviews` (boolean, optional, default true) — Return the product's top customer reviews inline, with full text, title, star rating, date, helpfulness votes, reviewer skin-tone/skin-type/eye/age context and photo URLs — plus the rating distribution and the product's UPC/EAN codes. Costs one extra upstream call (~27 KB at the default limit). Set false to skip it.
- `reviews_limit` (integer, optional, default 8) — How many reviews to embed (0-100). 100 is the upstream ceiling — bisected, 101 is refused. 0 keeps the summary (total, average, rating distribution, UPC/EAN) and returns no review bodies. Reviews are ~2.5 KB each, so this is the main lever on response size.
- `reviews_sort` (enum, optional, default "most_helpful") — Which reviews count as 'top'. These are exactly the five options Sephora's own review widget offers, with the same underlying sort keys — read out of the product payload's own `reviewFilters`, not invented. [one of: most_helpful, newest, oldest, highest_rating, lowest_rating]
- `reviews_offset` (integer, optional, default 0) — Skip this many reviews before returning `reviews_limit` of them — page through a product's full review history (`review_summary.total_reviews` tells you how many there are; some products have more than 17 000).
- `include_variant_images` (boolean, optional, default false) — By default every variant carries its swatch and its own main image, and the FULL gallery is returned once at product level. Set true to also get each variant's complete alternate-image set — no extra request, just a much larger response (a 60-shade foundation carries about nine images per shade).

**Returns:** product{product_id, name, headline, brand, brand_id, brand_url, brand_description, url, category, category_id, breadcrumbs[]{name, url}, product_type, description, description_html, short_description, short_description_html, quick_look, seo_title, seo_description, loves_count, related_links[]} + images[]{url, url_250, alt} + specifications{size, variation_type, variation_type_label, attributes{<sephora attribute>: [values]}, highlights[]{id, name, description, icon}, ingredients, ingredients_html, how_to_use, how_to_use_html, warnings, is_electronic, hawaii_hazmat} + availability{in_stock, in_stock_variant_count, variant_count, is_low_stock, price_min, price_max, currency, ships_to_home, store_pickup_eligible, same_day_eligible, is_online_only, is_returnable, free_shipping_message} + rating{average, review_count, best_rating, average_schema_org, review_count_schema_org} + variants[]{sku_id, is_selected, variation_type, variation_type_label, variation_value, variation_description, size, sku_type, price, price_max, price_display, sale_price, value_price, currency, on_sale, in_stock, is_low_stock, availability_schema_org, price_schema_org, max_purchase_quantity, swatch_text, swatch_image, image, images[], url, is_new, is_limited_edition, is_limited_time_offer, is_sephora_exclusive, is_online_only, is_returnable, is_free_shipping, free_shipping_message, is_replenishment_eligible, store_pickup_eligible, same_day_eligible} + selected_variant{…same…} + reviews[]{review_id, title, text, rating, rating_range, is_recommended, submitted_at, updated_at, author, author_id, location, helpful_votes, unhelpful_votes, total_votes, comment_count, is_syndicated, is_featured, is_ratings_only, badges[], pros, cons, reviewer_context{}, secondary_ratings{}, photos[]{url, thumbnail}, bazaarvoice_product_id, reviewed_product_name} + review_summary{total_reviews, returned, average_rating, recommended_count, rating_distribution{}, with_photos} + gtins{upcs[], eans[], isbns[], mpns[], model_numbers[]}. Honest caveats, every one measured across ten product categories: (1) Sephora publishes NO shipping weight and NO product dimensions anywhere in this payload — the only physical figure is the fill volume ('1.7 oz / 50 mL') inside `specifications.size`, which is contents, not parcel weight; do not compute shipping from it. (2) `gtins` is PRODUCT-level and family-wide, not per-variant: a 51-shade lipstick returns 51 EANs with no mapping from EAN to `sku_id`, because the source publishes no such mapping. It comes from the catalogue feed Sephora sends its reviews provider, so it needs `include_reviews` (the same single call). (3) Stock is a BOOLEAN — `in_stock` plus Sephora's own `is_low_stock` — because Sephora publishes no unit count; a caller needing quantities will not find them here. (4) `variants` is one row per real SKU: single-shade products legitimately return exactly one (hair tools 0/3 had a shade matrix; mascara, perfume, moisturizer and shampoo 4/4 did). (5) Per-variant price IS returned verbatim and is never copied down from the product; where a variant has no sale price the field is null, not filled in. (6) `description`, `short_description`, `ingredients` and `how_to_use` are HTML at source and are published BOTH ways — stripped, and raw in the `_html` twin. (7) `reviews[].author` is the reviewer's public Bazaarvoice display nickname and `author_id` their stable id there. The gateway's redactor removes emails and phone numbers, NOT display names — so the switch for a caller who does not want that data is `include_reviews=false`. (8) `bazaarvoice_matches_product_id` in meta tells you when Sephora's reviews provider filed this product's reviews under a sibling id.

## More
- Try it live, no code: https://reefapi.com/playground?engine=sephora
- Human docs page: https://reefapi.com/docs/sephora
- Every ReefAPI API in one file (for your AI): https://reefapi.com/llms-full.txt
