# The Beauty API reference

> Everything you need to integrate The Beauty API: 180,000 products, 22,000 ingredient records, and 180,000 hosted product images behind a uniform JSON interface.

Markdown edition of https://thebeautyapi.com/docs, generated from the same source as the HTML page. Data snapshot 2026.08 (updated 17 August 2026).

- Base URL: `https://api.thebeautyapi.com/v1`
- OpenAPI 3.1 spec: https://api.thebeautyapi.com/openapi.json
- Discovery document: https://api.thebeautyapi.com/
- API keys: https://thebeautyapi.com/dashboard
- Index for agents: https://thebeautyapi.com/llms.txt
- Instructions to paste into an agent: https://thebeautyapi.com/agent.md

## Overview

The Beauty API is a REST API for a normalized catalog of beauty products and ingredients: 180,000 products, 22,000 ingredient records, and 4.8 million product–ingredient links, refreshed once every two months. All endpoints accept and return JSON over HTTPS, and responses are compressed (brotli or gzip) when your client sends `Accept-Encoding`.

The API describes itself. A request to the root of the API (or to `/v1`) returns a discovery document listing every endpoint with its method, URL and summary, plus links to this reference and to the [OpenAPI 3.1 spec](https://api.thebeautyapi.com/openapi.json), which is served live at `/openapi.json`. Neither needs an API key, so agents and SDK generators can start from the base URL alone.

```bash
curl https://api.thebeautyapi.com/              # endpoints, docs_url, openapi_url, authentication
curl https://api.thebeautyapi.com/openapi.json  # OpenAPI 3.1 document
```

## Authentication

Every request under `/v1` requires an API key in the `x-api-key` header. Keys are issued from your [dashboard](https://thebeautyapi.com/dashboard), with up to 2 active keys per account and create, reveal, and revoke available at any time. A missing, invalid, or revoked key returns `403`. Only `/health`, the discovery document and `/openapi.json` are open.

```bash
curl "https://api.thebeautyapi.com/v1/products/search?q=retinol&page=1" \
  -H "x-api-key: YOUR_API_KEY"
```

> Treat API keys as secrets. Call the API from your backend and never embed a key in client-side or mobile code: anyone who can read your key can spend your quota.

## Quickstart

Set your key in the environment, then search the catalog from the language of your choice.

curl:

```bash
curl "https://api.thebeautyapi.com/v1/products/search?q=niacinamide&page=1" \
  -H "x-api-key: $BEAUTY_API_KEY"
```

JavaScript:

```js
const res = await fetch(
  "https://api.thebeautyapi.com/v1/products/search?q=niacinamide&page=1",
  { headers: { "x-api-key": process.env.BEAUTY_API_KEY } },
);
const { results, pagination } = await res.json();
```

Python:

```python
import os, requests

res = requests.get(
    "https://api.thebeautyapi.com/v1/products/search",
    params={"q": "niacinamide", "page": 1},
    headers={"x-api-key": os.environ["BEAUTY_API_KEY"]},
)
data = res.json()
```

## Endpoints

### Health: `GET /health`

Liveness check plus the size and build date of the catalogue currently being served. built_at (YYYY-MM-DD, UTC) is the freshness signal: the API is refreshed on its own cadence, independently of the downloadable dataset (see the changelog). Needs no API key, like the discovery document at the API root and the spec at /openapi.json.

No authentication required.

Request:

```bash
curl https://api.thebeautyapi.com/health
```

Response (200 OK):

```json
{
  "status": "ok",
  "products": 179166,
  "ingredients": 22471,
  "built_at": "2026-09-11"
}
```

### Search products: `GET /v1/products/search`

Search by brand and product name. Matching is strict: every word in q must appear in the product's brand or name, in any order. Case, accents and punctuation are ignored (punctuation splits words: Dr.Jart+ is searched as dr jart), and the last word may be partial (cera finds CeraVe), so it suits autocomplete. Returns slim records; follow up with the product endpoint for the full payload.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | Yes | Search text, 1–200 characters. Use the brand plus the distinctive part of the name, not the full retail title. |
| `page` | query | integer | No | Page number, 1–51. Optional; defaults to 1. 20 results per page. |

Request:

```bash
curl "https://api.thebeautyapi.com/v1/products/search?q=niacinamide&page=1" \
  -H "x-api-key: $BEAUTY_API_KEY"
```

Response (200 OK):

```json
{
  "results": [
    {
      "id": "06d9476a-397b-5f61-abc8-640b30a9ae23",
      "brand": "The Ordinary",
      "name": "Niacinamide 10% + Zinc 1%",
      "category": "skincare",
      "image_url": "https://images.thebeautyapi.com/06d9476a-397b-5f61-abc8-640b30a9ae23.jpg"
    }
  ],
  "pagination": { "page": 1, "has_more": true, "total": 1571 }
}
```

Worked examples:

| Query | Outcome | Result |
| --- | --- | --- |
| `q=CeraVe Moisturizing Cream` | matches | CeraVe Moisturizing Cream and its variants (Baby, Intensive, Psoriasis, For Dry To Very Dry Skin…) |
| `q=CeraVe Moisturizing Cream Daily Face and Body Moisturizer for Dry Skin` | no match | No results: "Daily", "Face", "Body" and "Moisturizer" come from the retail listing, not the stored name. The response's no_match.hint names the least specific word to drop, and no_match.terms shows each word's own match count. |
| `q=Laneige sleeping mask` | matches | Every LANEIGE sleeping mask (Water, Lip, Cica, Time Freeze…) |

Notes:

1. An empty `results` array means no product has *all* of those words. It does not mean the brand is missing.
2. When `total` is 0 the response carries `no_match`: `hint` names the word to drop, and `terms` gives each word's own match count. Drop the word that matches nothing (a typo, a receipt line number) or the least specific one (serum, cream, SPF) and retry.
3. Send brand + short name from the start, never a full listing title.
4. Words split on anything that is not a letter or digit: `Oil-Free` searches as `oil` and `free`, `10%` as `10`. Only the first 10 words count.
5. A query with no letters or digits returns `400`.

### Filter products: `GET /v1/products`

Discover products by category, brand, origin, formulation flags, and ingredient composition. All filters combine with AND. Returns the same slim records as search.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `category` | query | string | No | Exact, case-sensitive match on the stored category: skincare, suncare, makeup, haircare, body/bath, fragrance or other. Any other value returns an empty page with no_match, not an error. |
| `brand` | query | string | No | Brand name. Case, accents, punctuation and spaces are ignored: supergoop finds Supergoop!, sk ii finds SK-II. Misspellings are not corrected; use search to find the stored brand string. |
| `origin` | query | string | No | Exact, case-sensitive match on the brand's country of origin as stored, e.g. USA, South Korea, France, United Kingdom. Unknown for ~53% of products, which no origin value matches. |
| `fragrance_free` | query | boolean | No | Formulation flags. Also: alcohol_free, paraben_free, sulfate_free, silicone_free. |
| `has_ingredient` | query | uuid | No | Only products containing this ingredient. Ingredient IDs come from the ingredient endpoints. |
| `excludes_ingredient` | query | uuid list | No | Only products containing none of these ingredients. Comma-separated, up to 10. |
| `page` | query | integer | No | Page number, 1–51. Optional; defaults to 1. 20 results per page. |

Request:

```bash
curl "https://api.thebeautyapi.com/v1/products?category=skincare&fragrance_free=true&has_ingredient=a1526dea-37d1-5904-9106-be583378339b&page=1" \
  -H "x-api-key: $BEAUTY_API_KEY"
```

Response (200 OK):

```json
{
  "results": [
    {
      "id": "06d9476a-397b-5f61-abc8-640b30a9ae23",
      "brand": "The Ordinary",
      "name": "Niacinamide 10% + Zinc 1%",
      "category": "skincare",
      "image_url": "https://images.thebeautyapi.com/06d9476a-397b-5f61-abc8-640b30a9ae23.jpg"
    }
  ],
  "pagination": { "page": 1, "has_more": true, "total": 11501 }
}
```

Notes:

1. An unknown ingredient ID returns an empty page, not an error.
2. When `total` is 0, `no_match.hint` says why: the brand has no stored spelling (with a pointer to search), the other filters exclude every product of that brand, or the ingredient ID is unknown.

### Get a product: `GET /v1/products/{id}`

The complete record: brand, category, origin, formulation flags, hosted image URL, and the full ingredient list in exact on-label order, each ingredient with its normalized name, functional classes, safety ratings, and CAS/EC/IUPAC/Ph. Eur. identifiers.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | Yes | Product ID. |

Request:

```bash
curl https://api.thebeautyapi.com/v1/products/06d9476a-397b-5f61-abc8-640b30a9ae23 \
  -H "x-api-key: $BEAUTY_API_KEY"
```

Response (200 OK (trimmed)):

```json
{
  "id": "06d9476a-397b-5f61-abc8-640b30a9ae23",
  "brand": "The Ordinary",
  "name": "Niacinamide 10% + Zinc 1%",
  "image_url": "https://images.thebeautyapi.com/06d9476a-397b-5f61-abc8-640b30a9ae23.jpg",
  "category": "skincare",
  "origin": "Canada",
  "contains_fragrance": false,
  "contains_drying_alcohol": false,
  "contains_parabens": false,
  "contains_sulfates": false,
  "contains_silicones": false,
  "ingredients": [
    /* … position 0 … */
    {
      "position": 1,
      "label_name": "Niacinamide 10.0%",
      "concentration": 10.0,
      "id": "a1526dea-37d1-5904-9106-be583378339b",
      "name": "Niacinamide",
      "rating": "direct actives",
      "irritancy": null,
      "comedogenicity": null,
      "functions": ["smoothing"],
      "cas_number": "98-92-0",
      "ec_number": "202-713-4",
      "ph_eur_name": "Nicotinamidum",
      "iupac_name": "3-Pyridinecarboxamide",
      "other_names": "vitamin B3, nicotinamide",
      "category": null
    },
    /* … positions 2–10 … */
  ]
}
```

Notes:

1. `concentration` is present only where the manufacturer discloses it publicly. It is never estimated.
2. Barcodes are never in this payload, on any plan. Barcode-enabled plans resolve a GTIN to a product with [Barcode lookup](https://thebeautyapi.com/docs#barcode-lookup).

### Barcode lookup: `GET /v1/products/barcode/{barcode}`

Resolve a scanned or catalog barcode (UPC-A, EAN-13, GTIN-8/14) to its product and return the same complete record as Get a product. Unit codes, multipack codes and sibling-variant codes (shade/scent/size of the same line) all resolve, and any zero-padded GTIN form of the same code (UPC-A, EAN-13, GTIN-14) is treated as identical. Barcode-enabled plans only.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `barcode` | path | string | Yes | 8–14 digits, no spaces or dashes (^[0-9]{8,14}$). |

Request:

```bash
curl https://api.thebeautyapi.com/v1/products/barcode/769915195941 \
  -H "x-api-key: $BEAUTY_API_KEY"
```

Response (200 OK (same shape as Get a product)):

```json
{
  "id": "06d9476a-397b-5f61-abc8-640b30a9ae23",
  "brand": "The Ordinary",
  "name": "Niacinamide 10% + Zinc 1%",
  "image_url": "https://images.thebeautyapi.com/06d9476a-397b-5f61-abc8-640b30a9ae23.jpg",
  "category": "skincare",
  "origin": "Canada",
  "contains_fragrance": false,
  "contains_drying_alcohol": false,
  "contains_parabens": false,
  "contains_sulfates": false,
  "contains_silicones": false,
  "ingredients": [ /* … as in Get a product … */ ]
}
```

Notes:

1. The response never lists the product's barcodes. The API resolves a code to a product; it does not enumerate codes. The full GTIN list ships only with the licensed dataset.
2. `403` when your plan has no barcode lookup (the message reads “Your current plan does not include Barcode Lookup access.”). `404` when nothing matches. `400` for malformed input.
3. Bills 1 item.
4. Roughly 46% of the catalog carries a code today; coverage grows with each refresh.

### Batch products: `POST /v1/products/batch`

Fetch up to 50 complete product records in one request. The response array has the same length and order as the ids you send; unknown IDs come back as null.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `ids` | body | uuid[] | Yes | 1–50 product IDs. |

Request:

```bash
curl -X POST https://api.thebeautyapi.com/v1/products/batch \
  -H "x-api-key: $BEAUTY_API_KEY" \
  -H "content-type: application/json" \
  -d '{"ids": ["06d9476a-397b-5f61-abc8-640b30a9ae23", "00000000-0000-0000-0000-000000000000"]}'
```

Response (200 OK (trimmed)):

```json
[
  {
    "id": "06d9476a-397b-5f61-abc8-640b30a9ae23",
    "brand": "The Ordinary",
    "name": "Niacinamide 10% + Zinc 1%",
    "ingredients": [ … ]
  },
  null
]
```

Notes:

1. Send JSON with `Content-Type: application/json`. A missing or unrecognised content type returns `415`.
2. A body that is not valid JSON, or `ids` missing, empty, over 50 or not UUIDs, returns `400`.
3. Billed per item: a batch of 50 counts as 50 calls. The `X-Item-Count` response header carries the billable count.
4. Rate limits and the 100-request trial allowance count requests, so a batch is one request against those. Trial calls never incur overage.

### Search ingredients: `GET /v1/ingredients/search`

Search the 22,000-record ingredient dictionary by canonical INCI name and known synonyms. Use the returned IDs with the product filters or the ingredient endpoint.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | Yes | Search text, 1–200 characters. |
| `page` | query | integer | No | Page number, 1–51. Optional; defaults to 1. 20 results per page. |

Request:

```bash
curl "https://api.thebeautyapi.com/v1/ingredients/search?q=niacinamide&page=1" \
  -H "x-api-key: $BEAUTY_API_KEY"
```

Response (200 OK):

```json
{
  "results": [
    {
      "id": "a1526dea-37d1-5904-9106-be583378339b",
      "name": "Niacinamide",
      "other_names": "vitamin B3, nicotinamide",
      "rating": "direct actives"
    }
  ],
  "pagination": { "page": 1, "has_more": false, "total": 6 }
}
```

### Get an ingredient: `GET /v1/ingredients/{id}`

An ingredient's full scientific record: normalized name, synonyms, functional classes, irritancy and comedogenicity ratings, and CAS, EC, IUPAC, and Ph. Eur. identifiers.

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | Yes | Ingredient ID. |

Request:

```bash
curl https://api.thebeautyapi.com/v1/ingredients/a1526dea-37d1-5904-9106-be583378339b \
  -H "x-api-key: $BEAUTY_API_KEY"
```

Response (200 OK):

```json
{
  "id": "a1526dea-37d1-5904-9106-be583378339b",
  "name": "Niacinamide",
  "rating": "direct actives",
  "irritancy": null,
  "comedogenicity": null,
  "functions": ["smoothing"],
  "cas_number": "98-92-0",
  "ec_number": "202-713-4",
  "ph_eur_name": "Nicotinamidum",
  "iupac_name": "3-Pyridinecarboxamide",
  "other_names": "vitamin B3, nicotinamide",
  "category": null
}
```

## Pagination

List endpoints return 20 results per page. Pass `page` (1–51) and read the `pagination` object on the response; iterate while `has_more` is true. `total` is the number of matches across all pages, so a script can tell one exact hit from an ambiguous set before it reads the rows. A page beyond 51 is rejected with `400`, so a total above 1,020 means narrow the query with filters instead of paging deeper.

```json
"pagination": { "page": 2, "has_more": true, "total": 47 }
```

## Errors

Errors use conventional HTTP status codes with a uniform JSON body:

```json
{
  "statusCode": 404,
  "error": "Not Found",
  "message": "No product with id 00000000-0000-4000-8000-000000000000"
}
```

Branch on `statusCode`; the `message` is written for a person reading a log and says what to change. A parameter the endpoint does not accept, for example, comes back with the accepted list and the endpoint to use instead:

```json
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "Unknown query parameters 'name', 'limit'. GET /v1/products accepts: category, brand, origin, fragrance_free, alcohol_free, paraben_free, sulfate_free, silicone_free, has_ingredient, excludes_ingredient, page. To find products by name, use GET /v1/products/search?q=<brand and product name>. Pages are fixed at 20 results; use page=2, page=3, … for more."
}
```

| Status | Meaning |
| --- | --- |
| `400` | The request could never match anything: a query parameter the endpoint does not accept, a malformed ID or barcode, a page above 51, empty or over-long search text, or an invalid batch body. The message names the field and, for an unknown parameter, lists the ones the endpoint accepts. Fix the request; do not retry as-is. |
| `402` | Trial expired or payment required. Resolve billing on the dashboard before retrying. |
| `403` | Missing, invalid, or disabled API key, or a plan without barcode lookup calling the barcode endpoint. |
| `404` | Well-formed, but nothing matches: no product or ingredient with that ID, no product with that barcode, or a path the API does not serve. The message echoes the ID or barcode. |
| `413` | Request body over 1 MB. Only the batch endpoint reads a body, and 50 UUIDs are under 3 KB, so a valid batch never reaches this. |
| `415` | Batch request sent without a Content-Type header, or with one the API cannot parse. Send Content-Type: application/json. |
| `429` | Trial request allowance exhausted (use Convert to paid on the dashboard), rate limit exceeded, or the monthly request ceiling reached. Paid plans have an abuse ceiling of several times the included calls (included calls never block; overage is billed). Gateway ceilings reset on the 1st (UTC); paid allowances reset with your billing month. The trial allowance never resets. Successful trial requests include X-Trial-Requests-Remaining. Back off for rate limits; use the dashboard for an exhausted trial. |
| `500` | Internal error on our side. Safe to retry. |
| `503` | Access verification is temporarily unavailable. Retry shortly with backoff. |
| `504` | The origin did not answer in time. Safe to retry with backoff. |

## Rate limits and quotas

New accounts receive a seven-day trial with 100 requests total across all keys. At the cap, requests return 429 until you convert to paid or the trial ends. Trial requests never incur overage. Paid monthly fees are collected upfront; usage above the included allowance is billed at the end of your billing month at $0.02 per call. Paid batch requests meter per item, while each batch consumes one trial request. Paid allowances reset at the start of each billing month.

| Plan | Price | Included calls / month | Rate limit |
| --- | --- | --- | --- |
| Starter | $40/month | 10,000 | 10 req/sec, burst 20 |
| Starter + Barcode | $60/month | 10,000 | 10 req/sec, burst 20 |
| Pro | $200/month | 100,000 | 25 req/sec, burst 50 |
| Pro + Barcode | $300/month | 100,000 | 25 req/sec, burst 50 |

Requests above the rate limit return `429`. No rate-limit headers are sent, so handle 429 with exponential backoff.

## Product images

Every product record carries an `image_url` pointing to standardized, white-background product photography served from `images.thebeautyapi.com`. 180,000 images are hosted for you. Display them directly in your product UI; there is nothing to download or re-host. A small number of products have no photograph, in which case `image_url` is null.

## Barcodes

Barcodes are a *lookup key*, not a field. On barcode-enabled plans, `GET /v1/products/barcode/{barcode}` resolves a UPC-A / EAN-13 / GTIN-8/14 to its product and returns the standard product record. No response on any plan lists a product’s codes: detail, batch, search and filter payloads are identical with or without barcode access. Unit codes, multipack codes and sibling-variant codes (shade, scent or size of the same line) all resolve; a UPC-A and its zero-padded EAN-13 form are the same code. About 46% of the catalog carries at least one code today. If you need the codes themselves, they ship as a `gtin14` list per product in the licensed dataset.

## Versioning

The API is versioned in the path. Fields may be added to responses at any time and are non-breaking: parse what you need and ignore the rest. Breaking changes would ship under a new version path with advance notice to all subscribers.

Data refreshes and API changes are recorded in the [changelog](https://thebeautyapi.com/changelog).

## Try it in Postman

The fastest way to see every endpoint working before you write code: import our ready-made Postman collection, paste your API key into one variable, and send each request above. The collection also includes a folder of requests that are meant to fail, so you can see exactly what a 400, 403 and 404 look like.

[Read the guide: How to Test The Beauty API in Postman](https://thebeautyapi.com/resources/07-how-to-test-the-beauty-api-in-postman)

## Get a key

Start a seven-day trial with 100 requests. Then plans from $40/month with 10,000 calls included. Create a key in the [dashboard](https://thebeautyapi.com/dashboard).
