Products & Ingredients API

Live, filterable access to the BeautyFeeds product catalog, plus self-service credit-metered access for expanding what your organization can pull. See Plans & Features for what's included per plan.

Scope: the Products API (below) covers product data only. Ingredients API access — catalog search by ingredient, or a product's full ingredient breakdown — is a separate plan and subscription, documented in its own section further down this page. Reviews are not available via API at all — they're purchased and downloaded manually from the Global Data page.

Want to try it instead of just reading?

The Playground sends real requests with your real API key and shows the real response — no code required. Sign in to open the Playground →

Authentication

Every request needs your organization's API key in a header:

X-API-KEY: your_api_key_here

Find your key on your dashboard. Requests without a valid key, or from an inactive subscription, return 401 Unauthorized.

GET/api/v1/beauty_products

Returns products your organization currently has access to. Always live at query time — every request reads current data straight from the database, never a stale HTTP-level cache. The underlying catalog itself is refreshed on a monthly cycle by default (weekly or daily on request) — this endpoint just guarantees you always see the latest refresh, not an older cached response.

Query parameters

ParamTypeDescription
brand_namestringFilter by brand (partial match). Comma-separated for an OR match, e.g. Fenty,Sephora
categorystringFilter by category (partial match). Comma-separated for OR
site_namestringFilter by retailer, e.g. sephora.com. Comma-separated for OR
countrystringFilter by country code, e.g. us. Comma-separated for OR
uniq_idsstringComma-separated exact product IDs — selects specific products instead of a whole brand/category
updated_withinstringlast_7_days, last_30_days, or last_1_year — only products re-crawled within that window. Plan-gated; not included on every plan.
pageintegerDefault 1
per_pageintegerCapped at your plan's per-page limit (default 50)

Example response

A real response, from an org with a large assigned catalog and no active filter (so matched_in_catalog/expansion_notice are null — they only populate when a filter is given):

{ "meta": { "total_results": 27294, "total_pages": 546, "current_page": 166, "per_page": 50, "status": "success", "data_source": "live", "assigned_urls_count": 27294, "matched_in_catalog": null, "next_page_url": "https://api.beautyfeeds.io/api/v1/beauty_products?page=167&per_page=50", "previous_page_url": "https://api.beautyfeeds.io/api/v1/beauty_products?page=165&per_page=50", "renewal_notice": "These 27294 assigned products remain free to access until your subscription renews on 2026-09-16. At renewal, continued access is re-charged from your new billing cycle's credits — check /api/v1/beauty_products/stats beforehand to preview the cost.", "expansion_notice": null, "ingredients_data_notice": "Ingredient/INCI fields (ingredients, ingredients_formatted, inci_format, raw_ingredients) are extracted and normalized automatically from retailer pages. They are not manually verified and may not be 100% accurate — confirm against the original source before relying on them for formulation, compliance, or labeling decisions." }, "data": [ { "uniq_id": "4d8d2fa92f8559b1", "site_name": "amazon.com", "country": "us", { ...rest of the row, see "Which fields you get back" below ... } } ] }

next_page_url / previous_page_url are null at either end of the result set, so you can branch on presence instead of computing bounds yourself. matched_in_catalog is the full-catalog count for the given filter (not just what you're entitled to) — null when no filter is given. expansion_notice is also null unless the catalog has more matches than you're currently assigned. If either shows a gap, use assign to unlock the rest.

On ingredient/INCI data: it's automatically parsed and normalized, not manually verified — ingredients_data_notice repeats this on every response as a standing reminder, not just here. Treat it as a strong starting point for formulation research, not a certified source.

Which fields you get back

data rows only include the fields your plan grants — see Plans & Features for the exact list per plan. A Starter-tier response, for example, won't include ingredients or description even if you don't filter for them; an Enterprise plan (or any plan with no field restriction configured) gets every field. This applies to the row shape only — meta is always complete regardless of plan.

List-shaped fields (ingredients, ingredients_formatted, variants, additional_images, breadcrumbs, gtin_list, ean_list, highlights, other_information) come back as real JSON arrays/objects, not JSON-encoded strings — no need to JSON.parse them again on your end.

GET/api/v1/beauty_products/stats

Free preview — see what a filter would grant and what it would cost, without spending anything or changing your access. Requires at least one filter (brand_name, category, site_name, country, or precise uniq_ids — a comma-separated list to select specific products instead of a whole brand/category).

# request GET /api/v1/beauty_products/stats?brand_name=fenty # response { "meta": { "status": "success" }, "matched_total": 10000, "already_assigned": 5000, "new_available": 5000, "credits_required": 5000, "credit_balance": 15000 }

POST/api/v1/beauty_products/assign

Grants access to newly-matched products and charges credits for exactly what's new — products you already have access to are never re-charged. If your balance can't cover everything matched, you're granted as many as you can afford rather than the call failing outright.

# request POST /api/v1/beauty_products/assign?brand_name=fenty # or select specific products instead of a whole brand POST /api/v1/beauty_products/assign?uniq_ids=abc123,def456 # response { "meta": { "status": "success" }, "newly_assigned": 5000, "requested_new": 5000, "total_owned_for_filter": 10000, "credits_used": 5000, "credit_balance": 15000, "notice": null }

Once assigned, those products are immediately included in GET /api/v1/beauty_products — no separate step needed.

notice is null on a clean assign, and explains itself otherwise — two independent reasons it can be non-null: your credit balance couldn't cover everything matched (assigned as many as affordable), or a small number of matched products couldn't be resolved due to a data sync issue and weren't charged for. Either way, credits_used/newly_assigned always reflect what actually happened, not what was requested.

POST/api/v1/beauty_products/reset

Releases access to products matching the given filter (or specific uniq_ids). Credits are not refunded — see how credits work.

# request POST /api/v1/beauty_products/reset?brand_name=fenty # response { "meta": { "status": "success" }, "removed": 10000, "credits_refunded": 0, "credit_balance": 15000 }

How credits & renewal work

  • Credits are spent once per product, the moment it's newly granted — downloading it or querying it via the API afterward is free, no matter how many times, for the rest of the current billing cycle.
  • reset stops future access to a product but never refunds the credit already spent this cycle.
  • Access isn't permanent across billing cycles: the product catalog itself is re-crawled every cycle, so continued access to your currently-assigned products is re-affirmed and re-charged from your new cycle's credit pool at renewal. You'll see this cost ahead of time on your dashboard and by email before it happens.
  • Credits are separate from your plan's request-rate limit — credits govern how many distinct products you can access; the request limit governs how many API calls you can make per cycle.

GET/api/v1/reviews/stats

Note the different host — this endpoint is served by app.beautyfeeds.io (this app), not api.beautyfeeds.io. Reviews live in a separate datastore from products, so they're not part of the Products API above.

Analytics and pricing only — review count and estimated cost for products you already have Products API access to. Does not return the review records themselves; those are still purchased and downloaded from Global Data. Same X-API-KEY auth and filters (brand_name, category, site_name, country, uniq_ids) as the Products API — at least one is required.

# request GET https://app.beautyfeeds.io/api/v1/reviews/stats?brand_name=fenty # response { "meta": { "status": "success" }, "matched_products": 5000, "reviews_count": 128000, "price_per_record_usd": 0.0025, "estimated_cost_usd": 320.0, "purchase_url": "https://app.beautyfeeds.io/client/global_data", "note": "Analytics and pricing only — reviews are purchased and downloaded from the Global Data page, not delivered by this endpoint." }

GET/api/v1/ingredient_analysis

Served by api.beautyfeeds.io (same host as the Products API above), but gated by its own separate plan and subscription — not included with a Products API plan, and vice versa. See Plans for pricing tiers.

Two independent query modes on one endpoint — pass exactly one of ingredient or uniq_id (both or neither returns 400). This endpoint always returns a fixed, minimal set of product identifiers regardless of plan (name, url, brand, source, uniq_id, sku, gtin, upc, position) — full product data is the separate Products API's job.

Mode 1 — search by ingredient

# request GET /api/v1/ingredient_analysis?ingredient=RETINOL,NIACINAMIDE&page=1&per_page=25 # response { "meta": { "status": "success", "total_results": 242, "total_pages": 5, "current_page": 1, "per_page": 50, "unresolved_terms": [], "ingredients": [ { "inci_name": "RETINOL", "matched_products": 220, "frequency_pct": 4.1, "avg_position": 8.3, "restrictions": [], "concerns": [] }, { "inci_name": "TOLUENE", "matched_products": 3, "frequency_pct": 1.0, "avg_position": 22.0, "restrictions": [ { "country": "US", "source": "ca_prop65", "text": "California Prop 65: listed for developmental since 1991-01-01 (NSRL/MADL: 7000c µg/day)." } ], "concerns": ["Developmental (California Prop 65)"] } ], "ingredients_data_notice": "...", "product_fields_notice": "This endpoint returns minimal identifying fields only ...", "notices": { "position_coverage": "Ingredient position data available for 13.7% of matched products. Missing position means it wasn't captured at extraction time, not that the ingredient is absent from the label.", "plan_result_limit": "Your plan returns up to 10 matching products per search. 242 products actually match this search - upgrade for a higher limit." } }, "brand_insights": { "detected_brands": [ { "brand": "Jo Loves", "products_with_threshold_match": 1, "highest_match_pct": 100.0, "retailers": ["cultbeauty.co.uk"] } ], "most_similar_brand": "Jo Loves", "cross_retailer_matches": true }, "data": [ { "uniq_id": "0229de978d9ed08f", "product_name": "Polyglutamic Acid Serum", "product_url": "https://theinkeylist.com/...", "brand_name": "Inkey List", "site_name": "theinkeylist.com", "sku": "IF017KE", "gtin": ["5060879821262"], "upc": null, "match_percentage": 50.0, "matched_ingredients": [ { "inci_name": "RETINOL", "position": null } ] } ] }

Comma-separate multiple ingredients for an OR match — resolved against both canonical INCI names and known synonyms. Terms that don't resolve at all are reported back in unresolved_terms, not silently dropped — but a term that does resolve and simply has zero catalog matches won't show up there either (it did resolve); check meta.ingredients instead, which lists every requested ingredient with its own catalog stats (matched_products, catalog-wide frequency_pct, typical avg_position, and any EU/US restrictions) regardless of whether it matched anything.

A trailing (Common Name) is also tried stripped before giving up — the standard US label format is the INCI name with the common name in parentheses right in the declaration (e.g. Cocos Nucifera (Coconut) Water), so pasting terms straight off a real product label resolves the same as passing the bare INCI name. This is the one structured exception to "name known ingredients directly" — free-text prose (disclaimers, shade-label prefixes, marketing copy) still isn't parsed here; that's what the portal's paste-a-list tool is for.

One row per product in datamatched_ingredients lists every requested ingredient that product contains, each with its own position. match_percentage is what fraction of your requested ingredients this product has.

On restriction text: restrictions is copied verbatim from the EU CosIng database — some entries carry a generic-sounding cross-reference (e.g. "ban substances that deplete the ozone layer") on ingredients with no obvious connection to it. That's a known quirk of the source data itself, not a data error on our end — we don't edit or filter restriction text.

On concerns: short categorical labels derived entirely from our own regulatory sources — California Prop 65 (parsed into the actual listed concern, e.g. "Cancer" or "Developmental"), FDA restricted-use color additives, EU CosIng/Annex restrictions, and the EU's declarable fragrance allergen list. This is not sourced from EWG Skin Deep or any other third-party hazard-scoring database — every label traces back to a specific regulatory restriction already shown in restrictions, or to the concerns array on ?uniq_id= ingredient rows.

Plan-gated fields: meta.ingredients[].function/frequency_pct/avg_position and restrictions only appear on Starter and above — Free gets inci_name/matched_products only. brand_insights is null below Pro. The total number of matching products returned is also capped by plan (Free: 3, Starter: 5, Pro: 10, Enterprise: 50) — data itself (per-product fields) is otherwise identical across every tier. See Plans for the full breakdown.

Mode 2 — full breakdown for a known product

# request GET /api/v1/ingredient_analysis?uniq_id=abc123 # response { "meta": { "status": "success", "ingredient_analysis_available": true, "ingredients_data_notice": "..." }, "data": { "uniq_id": "abc123", "product_name": "...", "product_url": "...", "brand_name": "...", "site_name": "...", "sku": "...", "upc": null, "ingredients": [ { "inci_name": "RETINOL", "position": 4, "is_may_contain": false, "function": "skin conditioning", "restrictions": [] } ] } }

meta.ingredient_analysis_available: false (not a 404) means the product exists but hasn't been through ingredient extraction yet — routine given the catalog's ongoing extraction backlog, not an error.

Shared usage with Ingredient Analysis: this API and the portal's Ingredient Analysis paste-a-list tool draw from the same monthly request budget — one subscription covers both, and using either one counts against the same limit.

Errors

StatusMeaning
400/ingredient_analysis called with both or neither of ingredient/uniq_id
401Missing/invalid X-API-KEY, or an inactive subscription for the API being called — Products and Ingredients are gated by separate subscriptions, see Ingredients API
403Your plan doesn't include API access, or doesn't include a filter you passed (e.g. updated_within on a plan that doesn't have it)
422stats/assign/reset called with no filter
429Plan request limit or rate limit exceeded
404Unmatched path or method
500Something went wrong on our end — every error response is a clean, minimal JSON object; internals are never exposed in the response body

Rate limits

Two independent limits apply: your plan's request quota (per billing cycle, resets on renewal), and a general abuse backstop of 300 requests/minute per IP. Normal usage — including paging through a large result set quickly — stays well under the backstop; it exists to catch scripted abuse, not to slow down real usage.