# Ulta Beauty API — browse or keyword-search the ulta.com (US) beauty catalogue and pull one product in full: every shade/size variant with its own price and stock and Ulta's own sku, the complete image and video gallery, brand, availability, rating and top customer reviews inline — no API key, account or cookie required

> Find products on ulta.com (US). Browse a `category` — `/shop/makeup/face/foundation`, `/shop/hair/shampoo-conditioner/shampoo`, `/shop/skin-care/moisturizers` — for the precise, fully pageable result set with Ulta's own total, or give a free-text `query` ('mascara', 'vitamin c serum', 'dyson airwrap') to let Ulta's own type-ahead decide which category answers it. Every product comes back with Ulta's own product id and sku, the name, brand, image, list and sale price (a range when the shades differ), the star rating and review count, the shade/size count and Ulta's badges and promo text. Narrow with `brand`, `min_rating`, `sort` and any facet Ulta publishes for that category; the live facet vocabulary comes back WITH counts in every category response, so a filter value is never a guess.
> ReefAPI engine `ulta` · 3 endpoints · clean JSON, no scraping or browsers to manage.

## How to call
- **Endpoint:** `POST https://api.reefapi.com/ulta/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 /ulta/v1/search — 2 credits
Find products on ulta.com (US). Browse a `category` — `/shop/makeup/face/foundation`, `/shop/hair/shampoo-conditioner/shampoo`, `/shop/skin-care/moisturizers` — for the precise, fully pageable result set with Ulta's own total, or give a free-text `query` ('mascara', 'vitamin c serum', 'dyson airwrap') to let Ulta's own type-ahead decide which category answers it. Every product comes back with Ulta's own product id and sku, the name, brand, image, list and sale price (a range when the shades differ), the star rating and review count, the shade/size count and Ulta's badges and promo text. Narrow with `brand`, `min_rating`, `sort` and any facet Ulta publishes for that category; the live facet vocabulary comes back WITH counts in every category response, so a filter value is never a guess.

**Parameters:**
- `query` (string, optional) — A beauty keyword — a product type, a brand, or a brand + product ('mascara', 'vitamin c serum', 'dyson airwrap', 'MAC lipstick'). This runs Ulta's OWN type-ahead and then browses the category Ulta suggests for that keyword, which is what a shopper gets when they type it into ulta.com's search box. Provide `query` OR `category` (a category is the more precise input, and the only one that guarantees paging over a full result set). IMPORTANT, so you can plan around it: ulta.com's own keyword results page is rendered entirely in the browser and the request behind it is not addressable, so this API does NOT claim to reproduce Ulta's keyword ranking. When the type-ahead maps the keyword to a category (measured: 9 of 12 common keywords do) you get that category's full, pageable result set and `meta.search_mode='keyword_to_category'`. When it does not, you get Ulta's own top-result cards for the keyword and `meta.search_mode='keyword_top_results'` — a handful of products, and with `list_price`, `sale_price`, `rating` and `review_count` null, because Ulta's type-ahead does not publish them. Either way `suggestions` comes back so you can see what Ulta matched and browse a suggested category directly on the next call.
- `category` (string, optional) — An ulta.com category to browse, as the path from a ulta.com/shop/... URL — `/shop/makeup/face/foundation`, `/shop/hair/shampoo-conditioner/shampoo`, `/shop/skin-care/moisturizers`, `/shop/fragrance/womens-fragrance`. A full https://www.ulta.com/shop/... URL and a leading-slash-free form are both accepted. This is the precise mode: real total, real paging, live filter vocabulary with counts, and sorting. The authoritative list of every category Ulta publishes is its own sitemap, https://www.ulta.com/sitemap/shop.xml (275 entries at the time of writing) — a path that is not a real category returns NOT_FOUND rather than an empty grid, so a typo can never look like an out-of-stock category.
- `page` (integer, optional, default 1) — Which page of the category to return (1-based). Ulta's own page size is 64 products and is not adjustable, so page 2 is products 65-128. Page until `meta.pagination.has_more` is false. Paging applies to category browsing (including keyword searches that resolved to a category); the type-ahead's top results are a single short page.
- `max_results` (integer, optional, default 64) — How many products to return (1-384). Ulta serves 64 per upstream page, so asking for more than 64 fetches additional pages — each one is a further megabyte-class request against Ulta, so ask for what you will use. Values above the category's own total simply return the total.
- `sort` (enum, optional) — Result ordering, using Ulta's own sort vocabulary. Omit for Ulta's default, which IS `best_sellers` — passing `best_sellers` explicitly is measured to return the identical order, and this API tells you so in `meta.filters` rather than pretending it re-sorted anything. `price_asc`, `price_desc`, `new_arrivals` and `top_rated` all measurably change the result order. [one of: best_sellers, price_asc, price_desc, new_arrivals, top_rated]
- `brand` (string, optional) — Keep only products from one brand, using Ulta's own brand spelling. The brands that actually exist in the category you are browsing come back in `facets.filters` (group `BRAND`) with a product count each, so this never has to be a guess — take the value from there. Ulta matches the brand exactly, and the filtered `meta.pagination.total_results` is measured to equal the count Ulta declared for that brand.
- `min_rating` (integer, optional) — Keep only products rated at least this many stars (1-5), using Ulta's own rating filter. Verified to bite: on Foundation, `4` narrows 225 products to the 184 Ulta itself declares for that band.
- `filters` (object, optional) — Any other Ulta facet, as {facet parameter: value} taken VERBATIM from `facets.filters` in a previous response — e.g. {"finish": "matte"}, {"price": "25-75"}, {"special offer": "sale"}, {"skin type": "dry"}, {"preference": "cruelty free"}. The available facets are per-category (Foundation has Form/Finish/Coverage/Skin Type/SPF, Shampoo has different ones), which is why they are published as live data rather than a fixed enum. Each facet value in `facets.filters` carries the count Ulta declares for it, and applying it is measured to return exactly that count. A comma-separated `key=value` string is accepted too. Values you invent are passed to Ulta as-is and will simply match nothing.

**Returns:** products[]{product_id, sku_id, url, name, brand, image, image_alt, list_price, list_price_max, sale_price, sale_price_max, price, currency, on_sale, discount_amount, rating, review_count, variant_label, badge, badge_tags[], promo_text, additional_offers_text, sponsored, limited_stock} + category{path, url, title, result_count, page, page_size} (when a category answered) + suggestions[]{label, type, url, product_id, sku_id} (when you searched by keyword — what Ulta's own type-ahead matched, so the next call can browse a suggested category directly) + facets{sort[]{value,label}, filters[]{group_id, title, select, param, values[]{value,label,count,disabled}}} (category mode; the values are Ulta's own and each one is measured to narrow the result set to exactly the count it declares). meta.search_mode says which route answered — `category` (you browsed one), `keyword_to_category` (Ulta's type-ahead mapped your keyword to a category and it was browsed) or `keyword_top_results` (Ulta had no category for the keyword, so these are its own top-result cards). meta.pagination{page, per_page, total_results, returned, has_more, next_page} and meta.filters{applied, ignored, notes} carry the counts and say exactly which parameters were sent to Ulta. HONEST LIMITS: a `keyword_top_results` card has list_price, sale_price, rating and review_count NULL because Ulta's type-ahead does not publish them — the same product through `product_detail`, or through its category, has all four. Search cards never carry variants, description, ingredients or per-shade stock; `product_detail` is where those live. Ulta publishes no barcode/UPC and no shipping weight anywhere.

**Example request body:**
```json
{
  "category": "/shop/makeup/face/foundation",
  "max_results": 20
}
```

### POST /ulta/v1/price — 2 credits
Re-check ONE variant's price and stock, cheaply. Give it the product_id and the sku_id you already hold and it returns the current price, the regular price, whether that variant is in stock, and nothing else — no description, images, ingredients, variants or reviews. Built for refreshing a catalogue you already have rather than discovering products: it reads the same small module Ulta's own shade swatch fires, so it costs a fraction of a full product_detail. Note that sku_id is REQUIRED, because Ulta stocks and prices every shade and size separately — a product can be on sale and buyable in one size while another is sold out, so there is no single product-level answer.

**Parameters:** `product_id` (required), `sku_id` (required), `url`

**Returns:** product_id, sku_id, variant_label, current_price, regular_price, currency, on_sale, in_stock. An unknown sku returns NOT_FOUND rather than the product's default variant — Ulta silently substitutes it, and that substitution is checked and rejected. current_price is what a shopper pays now and regular_price is the struck-through price; when Ulta prints only one price they are equal. in_stock is null (never false) when Ulta states nothing — silence is not an out-of-stock claim. regular_price_max / current_price_max appear only when Ulta prints a range for that sku.

### POST /ulta/v1/product_detail — 2 credits
Get one Ulta Beauty product in full, by Ulta's product id or by its ulta.com URL — brand, name, the marketing copy in both plain text and its original Markdown, the full ingredient list, how-to-use, size, category path, list and sale price, availability, star rating and question count, the complete image AND video gallery with dimensions, Ulta's merchandising highlights and active promotions, one row per shade/size variant carrying that variant's OWN sku, price, sale price, stock flag, swatch and product images, and the customer reviews inline with the rating histogram, the recommended ratio, the most helpful positive and negative review, and the reviewer-reported attribute rollups. One call, everything the product page shows.

**Parameters:**
- `product_id` (string, optional) — Ulta's own product id, exactly as `search` returns it — e.g. `pimprod2057355`. Ulta also uses `xlsImpprod…`, `mkt…`, `gwp…`, `VP…` and `fs…` prefixes; all are accepted verbatim. This alone is enough: the slug in an Ulta product URL is decorative and the id resolves the product on its own. Provide `product_id`, or a product `url`.
- `sku_id` (string, optional) — Optional. Ulta's numeric sku for ONE variant (shade/size). Every variant of the product is returned whichever sku you pass — the sku only decides which one Ulta treats as selected, and therefore which shade's price, images and 'selected' flag appear at the top level. Omit it to get Ulta's default variant.
- `url` (string, optional) — A full ulta.com product URL — an alternative to `product_id`. The `?sku=` suffix is honoured if present.
- `variant_prices` (enum, optional, default "auto") — How hard to work for the per-variant price matrix. This knob exists because of a real Ulta behaviour: an ulta.com product page prices ONLY the shade or size it has selected — the other swatches carry no price at all — so every additional variant price is a separate (small) request. `auto`, the default, fetches all of them when the product has 12 or fewer variants, which covers every size/format set (a 4-size perfume really is $37/$110/$145/$180 and a 5-size moisturiser $20/$35/$42/$50/$66, so those must never be guessed). On a big shade set it samples 3 shades spread across the range; measured across four products with 26-71 shades, every sampled shade of a given product had the identical price, so if the 3 agree it stops there and tells you in `product.price_note` — and if they disagree it fetches the rest. `all` always fetches every variant (capped at 60). `none` skips it. A variant whose price was NOT measured is null and `price_measured` is false; the product price is never copied down onto it. `meta.coverage.variant_pricing` reports exactly what was fetched. [one of: auto, all, none]
- `reviews` (integer, optional, default 8) — How many written reviews to inline with the product (0-25; 25 is the review host's own per-page ceiling, established by bisection — 100 is rejected by it). Set 0 to skip the review call entirely: the rating, the review count and the two most-helpful reviews still come from the product page itself, so you lose the review list, the rating histogram and the reviewer-reported attribute rollups, and save one upstream request.

**Returns:** product{product_id, sku_id, url, canonical_url, name, brand, brand_url, category, category_path[]{label,url}, list_price, list_price_max, sale_price, sale_price_max, price, currency, on_sale, discount_amount, in_stock, availability, coming_soon, rating, review_count, question_count, summary, description, description_markdown, ingredients, usage, restrictions, prop65_warning, highlights[]{group,label,description}, size, size_label, variant_type, variant_label, variant_count_label, variant_count, variants[]{sku_id, product_id, name, description, list_price, list_price_max, sale_price, sale_price_max, price, price_measured, currency, on_sale, in_stock, unavailable, disabled, out_of_stock_label, selected, image, swatch_image, texture_image, url, tags[]}, price_note, images[]{url, media_type, alt, name, video_url, width, height, format}, image, image_count, video_count, promotions[], subscription_eligible, seller, meta_title, meta_description, reviews{source, page_id, average_rating, rating_count, review_count, native_review_count, syndicated_review_count, recommended_ratio, rating_histogram{1..5}, total_available, returned, most_helpful_positive, most_helpful_negative, attributes[]{key,name,type,values[]{label,count}}, items[]{review_id, rating, headline, comment, nickname, location, created_at, updated_at, helpful_votes, not_helpful_votes, verified_buyer, staff_reviewer, media[]}}}. meta.coverage counts how many of THIS product's variants carry a price and a stock flag, so you can see the completeness of the rows you were given instead of trusting an average. FIELD NOTES: prices come in pairs — `list_price` and `list_price_max` are equal when the product has one price and differ when its shades do (Ulta prints '$38.50 - $55.00' on 46% of catalogue tiles). An ulta.com product page prices ONLY the variant it has selected, so the other variants' prices are fetched separately — see `variant_prices`, `variants[].price_measured` and `product.price_note`. A variant price is always that variant's own and is NEVER copied down from the product. `in_stock` on a variant is a BOOLEAN because Ulta publishes no per-variant quantity anywhere — null means Ulta said nothing, never zero. `rating_count` counts every rating Ulta shows, `native_review_count` how many were written on ulta.com and `syndicated_review_count` how many came from the brand's own site. HONEST LIMITS — WHAT ULTA DOES NOT PUBLISH, so you can plan around it: no barcode, UPC or EAN; no shipping weight and no package dimensions (`size` is the product's own volume, e.g. '1.0 oz', not a shipping weight); no per-store or per-variant stock counts; and no separate specification table — what a spec sheet would hold is in `description`, `ingredients`, `usage`, `size` and `highlights`.

**Example request body:**
```json
{
  "product_id": "pimprod2057355",
  "reviews": 5,
  "variant_prices": "none"
}
```

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