## List Products

```
GET /api/v1/products
```

Returns a paginated list of products with variants, categories, and translations.

### Query Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | `number` | 10 | Products per page (1-100) |
| `offset` | `number` | 0 | Number of products to skip |
| `cursor` | `string` | — | Product UUID for keyset pagination |
| `category` | `string` | — | Filter by category slug |
| `brand` | `string` | — | Filter by brand slug |
| `query` | `string` | — | Search term for product name |
| `active` | `boolean` | — | Filter by published status |
| `excludeBundles` | `boolean` | false | Exclude bundle products |
| `includeEvents` | `boolean` | false | Include event-products (excluded by default — see the [Events API](/docs/api-reference/events)) |
| `orderBy` | `string` | — | Sort field: `price`, `name`, or `createdAt` |
| `orderDirection` | `string` | `desc` | Sort direction: `asc` or `desc` |
| `currency` | `string` | — | Currency code for price display (e.g. `EUR`) |
| `lang` | `string` | — | Locale code for translations (e.g. `pl-PL`) |

### Response

```json
{
  "data": [
    {
      "id": "0191abc0-1234-7def-8000-000000000001",
      "name": "Classic Tee",
      "slug": "classic-tee",
      "summary": "A comfortable everyday tee",
      "images": ["https://cdn.example.com/tee.jpg"],
      "status": "published",
      "categoryId": "0191abc0-0000-7000-8000-000000000010",
      "taxRate": {
        "id": "0191abc0-1234-7def-8000-000000000001",
        "name": "Standard VAT",
        "rate": 23,
        "label": null
      },
      "variants": [
        {
          "id": "0191abc0-0000-7000-8000-000000000100",
          "sku": "CT-SM-BLK",
          "price": "2500",
          "priceGross": "3075",
          "stock": 42
        }
      ],
      "translations": []
    }
  ],
  "meta": {
    "count": 1
  }
}
```

---

## Prices and tax

Prices are stored **net** — excluding tax — as strings in minor units. Each store also has a **tax behavior** setting (admin: Settings → Taxes), returned as `taxBehavior` by [`GET /api/v1/me`](/docs/api-reference/store) and [`GET /api/v1/settings`](/docs/api-reference/settings):

| Tax behavior | What shoppers see | At checkout |
|--------------|-------------------|-------------|
| `inclusive` | Gross prices — tax already included | Nothing is added |
| `exclusive` | Net prices — tax excluded | Tax is added on top |

So that a storefront never has to redo tax math, every variant carries a **gross twin** next to each net price field. Gross is net × (1 + rate), rounded by the platform; with no tax rate assigned, gross equals net. Both are strings in minor units.

| Net field | Gross twin | Where |
|-----------|------------|-------|
| `price` | `priceGross` | every variant |
| `originalPrice` | `originalPriceGross` | every variant |
| `calculatedPrice` | `calculatedPriceGross` | every variant |
| `prePromotionPrice` | `prePromotionPriceGross` | every variant |
| `omnibusPrice` | `omnibusPriceGross` | product detail only |
| `prices[].price` | `prices[].priceGross` | multi-currency entries |
| `prices[].calculatedPrice` | `prices[].calculatedPriceGross` | multi-currency entries |
| `volumePricingTiers[].price` | `volumePricingTiers[].priceGross` | product detail only |
| `bundle.groups[].items[].variant.price` | `…variant.priceGross` | bundle products, from the constituent's own rate |
| `bundleFixedPriceAmount` | `bundleFixedPriceAmountGross` | bundle products, product detail only |
| `bundleAmountOffAmount` | `bundleAmountOffAmountGross` | bundle products, product detail only |

The product itself carries its assigned rate as `taxRate` — `null` when the product has none:

```json
{
  "taxRate": {
    "id": "0191abc0-1234-7def-8000-000000000001",
    "name": "Standard VAT",
    "rate": 23,
    "label": null
  }
}
```

`rate` is a percentage number (`23` = 23%), the same shape as [`GET /api/v1/tax-rates`](/docs/api-reference/tax-rates). The older `productTaxRate` object is still returned for compatibility; its nested `taxRate.rate` is the internal representation — percent × 1000, e.g. `"23000"` — and should not be used for display.

Both `taxRate` and the gross twins are returned wherever products appear: `GET /products`, `GET /products/:idOrSlug`, and the product lists on collections and search. Cart line items carry the same twins on their `productVariant` — see the [Carts API](/docs/api-reference/carts) — and so do order line items, priced from the rate frozen into the order snapshot rather than the product's current one; see the [Orders API](/docs/api-reference/orders#prices-and-tax).

### Displaying a price

One rule covers every storefront:

```ts
const shown = store.taxBehavior === "inclusive" ? variant.priceGross : variant.price;
```

A Polish store with 23% VAT sells a variant at `price: "7236"`, `priceGross: "8900"`. On `inclusive` the storefront shows 89,00 zł. Switched to `exclusive`, the same store shows 72,36 zł and adds 16,64 zł of tax at checkout.

---

## Get Product

```
GET /api/v1/products/:idOrSlug
```

Returns a single product by UUID or URL slug, with full variant details, category, collections, and translations.

| Parameter | Type | Description |
|-----------|------|-------------|
| `currency` | `string` | Currency code for variant price resolution |
| `lang` | `string` | Locale code for translated slug lookup |

Supports `?lang=` query parameter for translated slug lookup when translations are enabled.

**Draft visibility by slug:** When looking up by slug, the result depends on the authentication method. Operator credentials (OAuth tokens from "Sign in with YNS" apps, or internal tokens used by Elliot) can see draft products by slug — this lets tools read back a just-created draft without publishing it first. Public store API keys (`sk-` prefix) only see published products when looking up by slug. Lookups by UUID return drafts to any authenticated caller regardless of credential type.

### Response

Each variant includes an `omnibusPrice` field — the lowest price recorded in the last 30 days (EU Omnibus Directive). The value is a string in minor units or `null` when the store has not enabled omnibus pricing or no historical price exists for that variant. Its gross twin is `omnibusPriceGross`; see [Prices and tax](#prices-and-tax) for the full set of net/gross fields and the product-level `taxRate`.

**Bundle products** additionally include a `bundle` object describing the configurable structure: always-included items (`forced: true`) plus "pick exactly N" choice groups, each with variant price/stock/images. Use it to render and price the bundle on your storefront, then submit the shopper's picks via `POST /api/v1/carts`.

```json
{
  "id": "0191abc0-1234-7def-8000-000000000001",
  "name": "Classic Tee",
  "slug": "classic-tee",
  "summary": "A comfortable everyday tee",
  "images": ["https://cdn.example.com/tee.jpg"],
  "status": "published",
  "category": { "id": "...", "name": "Shirts", "slug": "shirts" },
  "taxRate": {
    "id": "0191abc0-1234-7def-8000-000000000001",
    "name": "Standard VAT",
    "rate": 23,
    "label": null
  },
  "variants": [
    {
      "id": "0191abc0-0000-7000-8000-000000000100",
      "sku": "CT-SM-BLK",
      "price": "2500",
      "priceGross": "3075",
      "stock": 42,
      "combinations": { "size": "Small", "color": "Black" },
      "omnibusPrice": "2200",
      "omnibusPriceGross": "2706"
    }
  ],
  "collections": [],
  "translations": []
}
```

#### Bundle object (bundle products only)

When the product's `type` is `"bundle"`, the response includes a `bundle` field:

```json
{
  "bundle": {
    "discountPercentage": 15,
    "groups": [
      {
        "id": "0191abc0-0000-7000-8000-000000000300",
        "name": "Pick your size",
        "position": 0,
        "minQuantity": 1,
        "maxQuantity": 1,
        "allowDuplicates": false,
        "items": [
          {
            "variantId": "0191abc0-0000-7000-8000-000000000100",
            "position": 0,
            "forced": false,
            "defaultSelected": true,
            "fixedQuantity": null,
            "maxQuantity": null,
            "variant": {
              "id": "0191abc0-0000-7000-8000-000000000100",
              "price": "2500",
              "priceGross": "3075",
              "sku": "CT-SM-BLK",
              "stock": 42,
              "images": [],
              "productName": "Classic Tee",
              "productSlug": "classic-tee",
              "productImages": ["https://cdn.example.com/tee.jpg"],
              "taxRate": {
                "id": "0191abc0-1234-7def-8000-000000000001",
                "name": "Standard VAT",
                "rate": 23,
                "label": null
              }
            }
          }
        ]
      }
    ]
  }
}
```

Each group item's `variant` carries `priceGross` and the `taxRate` that produced it from the **constituent's own** product — a bundle may mix rates, so pricing a 5% book inside a 23% bundle with the bundle's rate would be 18 points too high. The two bundle-level money fields are grossed on the product itself, with the bundle's own rate: `bundleFixedPriceAmountGross` (for `bundlePriceMode: "fixed"`) and `bundleAmountOffAmountGross` (for `"amount"`). Both are `null` when the bundle has no such amount. See [Prices and tax](#prices-and-tax).

### Rolling subscriptions

Every product carries `subscriptionMode`, which says how it may be bought:

| Value | Meaning |
|-------|---------|
| `optional` | One-time purchase or subscription (the default) |
| `only` | Subscription required — a plan-less add to cart is refused |
| `rolling` | A curated box whose contents change every cycle; subscription required |

A `rolling` product's fixed record says nothing about what a shopper actually receives, so `GET /api/v1/products/:idOrSlug` additionally returns `subscriptionCycles` with the box shipping now and the one announced next. Only published cycles resolve, and an item whose own product has gone back to `draft` is dropped from the box. The field is absent entirely for every other product.

```json
{
  "subscriptionMode": "rolling",
  "subscriptionCycles": {
    "current": {
      "id": "0191abc0-1234-7def-8000-000000000001",
      "productId": "0191abc0-0000-7000-8000-000000000200",
      "title": "October 2026",
      "description": "Two washed lots picked for filter brewing.",
      "image": "https://cdn.example.com/october-box.jpg",
      "startsAt": "2026-10-01T00:00:00.000Z",
      "endsAt": "2026-11-01T00:00:00.000Z",
      "status": "published",
      "items": [
        {
          "productVariantId": "0191abc0-0000-7000-8000-000000000100",
          "productId": "0191abc0-0000-7000-8000-000000000300",
          "name": "Ethiopia Guji",
          "variantLabel": "250g / Whole bean",
          "sku": "ETH-GUJI-250",
          "image": "https://cdn.example.com/eth-guji.jpg",
          "quantity": 1
        }
      ],
      "createdAt": "2026-09-01T10:30:00.000Z",
      "updatedAt": "2026-09-01T10:30:00.000Z"
    },
    "upcoming": null
  }
}
```

Either side is `null` when there is no such box — `current: null` means the programme is between boxes and [`POST /api/v1/carts`](/docs/api-reference/carts#subscription-items) will refuse the subscription until the next one is announced. Manage the calendar with the [Subscription Cycles API](/docs/api-reference/subscription-cycles).

---

## Create Product

```
POST /api/v1/products
```

Creates a product in one call. Image URLs are downloaded and re-uploaded to the store's CDN. Categories and collections are found or created automatically when referenced by name.

By default a single default variant is created at the given `price`. To create a product with **multiple variants** (e.g. Size × Color), pass the `variants` array — see the multi-variant example below.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Product display name |
| `slug` | `string` | Yes | URL-friendly identifier (`^[a-z0-9-]+$`) |
| `description` | `string` | No | Plain text product description |
| `content` | `object` | No | Rich TipTap JSON document (`{ "type": "doc", ... }`) for the product body |
| `price` | `number` | Yes* | **Net** price as a decimal (e.g. `29.99`) |
| `priceGross` | `number` | No* | **Gross** price as a decimal — converted to net using the product's tax rate. Send instead of `price`, never both. |
| `images` | `string[]` | No | Array of image URLs to upload |
| `categoryId` | `string` | No | Category UUID — takes priority over `category` when both are provided |
| `category` | `string` | No | Category name — created automatically if it doesn't exist |
| `brandId` | `string` | No | Brand UUID — must reference an existing brand (pre-create via the admin) |
| `collectionIds` | `string[]` | No | Collection UUIDs to attach the product to (unioned with `collectionNames`) |
| `collectionNames` | `string[]` | No | Collection names — created automatically if missing |
| `taxRateId` | `string` | No | Tax rate UUID to apply to this product (pre-create via `POST /tax-rates`) |
| `stock` | `number` | No | Initial inventory quantity. Omit to leave the product untracked — see [Inventory tracking](#inventory-tracking) below. |
| `quantity` | `number` | No | **Deprecated** — use `stock` instead |
| `prices` | `object` | No | Multi-currency prices (e.g. `{ "EUR": 25.99, "GBP": 22.50 }`) |
| `variants` | `object[]` | No | Structured variants — see [Multi-Variant Products](#multi-variant-products) below |
| `cost` | `number` | No | Unit cost of goods (COGS) as a decimal in the store currency (e.g. `12.50`). Merchant-private — never returned by read endpoints. Applied to the default variant, or as the fallback for structured variants that omit their own `cost`. |
| `seo` | `object` | No | SEO overrides — see [SEO fields](#seo-fields) below |

\* Provide either `price` or `priceGross` — sending both is rejected.

The `price` (and per-variant `price`) must be large enough to represent at least one minor unit in the store's currency. A value like `0.001` for USD passes the schema's positivity check but rounds to 0 cents — the API returns a `400` with `"Price is too small to represent in the store currency"`.

#### Writing net or gross prices

Prices are stored net, so `price` is always the net amount — on `POST /products` and on each entry of the `variants` array. Send `priceGross` instead when you have the shopper-facing gross amount: the platform converts it to net using the product's tax rate — `taxRateId` from the same request when present, otherwise the product's current rate. With no rate, gross and net are identical. `price` and `priceGross` are mutually exclusive in one request.

`PATCH /products/:idOrSlug` has no price field at all: price lives on the variant, so **update prices through the variant endpoints** — [`POST /api/v1/products/:idOrSlug/variants`](#create-variant) and [`PATCH /api/v1/variants/:idOrSku`](/docs/api-reference/variants#update-variant). Those take **cents**, not decimals, so the pair there is `priceCents` / `priceGrossCents` (integer minor units) with the same one-or-the-other rule.

See [Prices and tax](#prices-and-tax) for the read side.

#### Inventory tracking

Omitting `stock` is not the same as sending `0`:

| `stock` | Stored as | Meaning |
|---------|-----------|---------|
| omitted | `null` | **Untracked** — the variant sells freely, with no quantity ceiling |
| `0` | `0` | **Sold out** — `POST /api/v1/carts` rejects add-to-cart with `409 Insufficient stock` |
| `> 0` | the number | Tracked at that quantity |

The same rule applies to the per-variant `stock` in `variants`, to `POST /api/v1/products/:idOrSlug/variants`, and to the deprecated `quantity`. Send an explicit `0` only when you mean the product is unavailable; otherwise leave `stock` out and start tracking later with `PATCH /api/v1/variants/:idOrSku`.

#### Simple product

```bash
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Classic Tee",
    "slug": "classic-tee",
    "price": 25.00,
    "images": ["https://example.com/tee.jpg"],
    "category": "Shirts",
    "stock": 100,
    "prices": { "EUR": 23.00 }
  }' \
  https://your-store.yns.store/api/v1/products
```

### Response (201)

```json
{
  "message": "Product created successfully",
  "product": {
    "id": "0191abc0-1234-7def-8000-000000000001",
    "name": "Classic Tee",
    "slug": "classic-tee",
    "description": null,
    "category": "Shirts",
    "collectionIds": [],
    "images": ["https://cdn.store.com/tee-abc123.jpg"],
    "variant": {
      "id": "0191abc0-0000-7000-8000-000000000100",
      "price": "2500",
      "stock": 100
    }
  }
}
```

### Multi-Variant Products

Pass the `variants` array to create a product with real variant types, values, and combinations (e.g. Size × Color). Every variant must define the **same set of option keys**. The top-level `price` is used as the default when a variant omits its own price.

#### Variant object fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `options` | `object` | Yes | Option label → value map, e.g. `{ "Size": "L", "Color": "Red" }` |
| `sku` | `string` | No | Variant SKU (required to sync stock and per-variant prices) |
| `barcode` | `string \| null` | No | Variant barcode (EAN, UPC, GTIN, ISBN, or internal) |
| `description` | `string \| null` | No | Per-variant description (markdown, max 4000 chars), shown on the product page |
| `price` | `number` | No | **Net** variant price as a decimal — defaults to the product `price` |
| `priceGross` | `number` | No | **Gross** variant price as a decimal — converted to net using the product's tax rate. Send instead of `price`, never both. |
| `stock` | `number` | No | Variant inventory quantity. Omit to leave this variant untracked — see [Inventory tracking](#inventory-tracking). |
| `images` | `string[]` | No | Variant-specific image URLs |
| `shippable` | `boolean` | No | Whether the variant ships physically (default: `true`) |
| `weight` | `number` | No | Weight for shipping calculation |
| `width` | `number` | No | Width dimension |
| `height` | `number` | No | Height dimension |
| `depth` | `number` | No | Depth dimension |
| `cost` | `number` | No | Per-variant unit cost of goods (COGS) as a decimal. Falls back to the product-level `cost` when omitted. Merchant-private — never returned by read endpoints. |
| `prices` | `object` | No | Per-variant multi-currency prices (e.g. `{ "EUR": 28.00 }`) |
| `attributes` | `object[]` | No | Per-variant key/value rows shown on the product page, e.g. `[{ "key": "Nuty zapachowe", "value": "bergamotka, cedr" }]`. Each entry is `{ key, value, checked? }`. |

#### Example

```bash
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Classic Tee",
    "slug": "classic-tee",
    "price": 25.00,
    "category": "Shirts",
    "images": ["https://example.com/tee.jpg"],
    "variants": [
      { "options": { "Size": "S", "Color": "Black" }, "sku": "CT-S-BLK", "stock": 20, "description": "Heavyweight charcoal colourway.", "attributes": [{ "key": "Material", "value": "220 gsm cotton" }] },
      { "options": { "Size": "S", "Color": "White" }, "sku": "CT-S-WHT", "stock": 15 },
      { "options": { "Size": "M", "Color": "Black" }, "sku": "CT-M-BLK", "price": 27.00, "stock": 30 },
      { "options": { "Size": "M", "Color": "White" }, "sku": "CT-M-WHT", "price": 27.00, "stock": 25 }
    ]
  }' \
  https://your-store.yns.store/api/v1/products
```

#### Multi-variant response (201)

```json
{
  "message": "Product created successfully",
  "product": {
    "id": "0191abc0-1234-7def-8000-000000000001",
    "name": "Classic Tee",
    "slug": "classic-tee",
    "description": null,
    "content": null,
    "category": "Shirts",
    "collectionIds": [],
    "images": ["https://cdn.store.com/tee-abc123.jpg"],
    "variantCount": 4
  }
}
```

---

## Update Product

```
PATCH /api/v1/products/:idOrSlug
```

Partially updates a product. Only the provided fields are changed.

### Request Body

| Field | Type | Description |
|-------|------|-------------|
| `title` | `string` | New product name |
| `description` | `string` | New plain text description |
| `content` | `object` | Rich TipTap JSON document (`{ "type": "doc", ... }`) for the product body |
| `images` | `string[]` | Replace the product's images. External URLs are uploaded to the store's CDN; URLs already on the CDN pass through. `[]` removes all images. |
| `status` | `string` | `active` (published) or `draft` |
| `categoryId` | `string \| null` | Category UUID — takes priority over `category`; pass `null` to clear |
| `category` | `string` | Category name — created automatically if it doesn't exist |
| `brandId` | `string \| null` | Brand UUID — must reference an existing brand; pass `null` to clear |
| `taxRateId` | `string \| null` | Tax rate UUID to apply to this product (create via `POST /tax-rates`); pass `null` to clear. Replaces any existing assignment. |
| `collectionIds` | `string[]` | Collection UUIDs. Replaces the product's full membership set (`[]` removes all). Unioned with `collectionNames`. |
| `collectionNames` | `string[]` | Collection names — created automatically if missing. Unioned with `collectionIds`. |
| `event` | `object` | Toggle event/ticketing behaviour — see [Event fields](#event-fields) below |
| `seo` | `object` | SEO overrides — see [SEO fields](#seo-fields) below |

This endpoint does not change prices — price lives on the variant. Use [`PATCH /api/v1/variants/:idOrSku`](/docs/api-reference/variants#update-variant) for an existing variant, or [`POST /api/v1/products/:idOrSlug/variants`](#create-variant) for a new one. See [Writing net or gross prices](#writing-net-or-gross-prices).

#### Event fields

The `event` object merges onto the product's existing event metadata. Setting `enabled: false` disables ticketing while preserving the stored details for later re-enabling. Omitted sub-fields keep their current value.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `enabled` | `boolean` | Yes | Enable or disable event/ticketing behaviour |
| `startsAt` | `string \| null` | No | Event start time as an ISO 8601 string |
| `location` | `string \| null` | No | Event location |
| `capacity` | `number \| null` | No | Maximum attendees (positive integer, metadata only — not wired to inventory) |
| `guestLabel` | `string \| null` | No | Display label for the guest line (max 100 chars, e.g. "Speakers") |
| `guest` | `string \| null` | No | Guest/speaker name(s) as free text (max 500 chars, comma-separated for multiple) |

#### SEO fields

The `seo` object sets meta/OG title and description overrides for the product page. On `PATCH`, it merges onto existing values: omitted fields keep their stored value, `null` clears a field back to the default (derived from the product name/summary).

| Field | Type | Description |
|-------|------|-------------|
| `title` | `string \| null` | SEO meta title (`null` to clear) |
| `description` | `string \| null` | SEO meta description (`null` to clear) |
| `canonical` | `string \| null` | Canonical URL (`null` to clear) |

#### Basic update

```bash
curl -X PATCH \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"title": "Premium Tee", "status": "active"}' \
  https://your-store.yns.store/api/v1/products/classic-tee
```

#### Enable event mode

```bash
curl -X PATCH \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event": {
      "enabled": true,
      "startsAt": "2025-09-20T18:00:00Z",
      "location": "Warsaw, Poland",
      "capacity": 200,
      "guest": "Jane Doe, John Smith"
    }
  }' \
  https://your-store.yns.store/api/v1/products/classic-tee
```

When the `event` field is included, the response contains the merged event metadata:

```json
{
  "id": "0191abc0-1234-7def-8000-000000000001",
  "title": "Classic Tee",
  "status": "active",
  "event": {
    "enabled": true,
    "startsAt": "2025-09-20T18:00:00Z",
    "location": "Warsaw, Poland",
    "capacity": 200,
    "guestLabel": null,
    "guest": "Jane Doe, John Smith"
  }
}
```

#### Set SEO overrides

```bash
curl -X PATCH \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "seo": {
      "title": "Classic Tee — Premium Cotton | Your Store",
      "description": "Heavyweight 220 gsm cotton tee in 4 colourways. Free shipping over $50."
    }
  }' \
  https://your-store.yns.store/api/v1/products/classic-tee
```

When the `seo` field is included, the response contains the merged SEO metadata:

```json
{
  "id": "0191abc0-1234-7def-8000-000000000001",
  "title": "Classic Tee",
  "status": "active",
  "seo": {
    "title": "Classic Tee — Premium Cotton | Your Store",
    "description": "Heavyweight 220 gsm cotton tee in 4 colourways. Free shipping over $50.",
    "canonical": null
  }
}
```

---

## Delete Product

```
DELETE /api/v1/products/:idOrSlug
```

Deletes a product if it has no order history. Products with orders are **archived** (status set to `hidden`) instead of deleted.

```json
{ "message": "Product deleted" }
// or
{ "message": "Product archived (has order history)" }
```

---

## Create Variant

```
POST /api/v1/products/:idOrSlug/variants
```

Adds a new variant to an existing product.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `sku` | `string` | Yes | SKU code |
| `barcode` | `string \| null` | No | Barcode (EAN, UPC, GTIN, ISBN, or internal) |
| `description` | `string \| null` | No | Per-variant description (markdown, max 4000 chars), shown on the product page |
| `title` | `string` | Yes | Variant display name (e.g. `Large / Red`) |
| `priceCents` | `number` | No* | **Net** price in cents (e.g. `2999` = $29.99) |
| `priceGrossCents` | `number` | No* | **Gross** price in cents — converted to net using the product's assigned tax rate. Send instead of `priceCents`, never both. |
| `costCents` | `number` | No | Unit cost of goods in cents (e.g. `1250` = $12.50). Merchant-private. |
| `imageUrl` | `string` | No | Variant image URL |
| `stock` | `number` | No | Inventory quantity. Omit to create the variant untracked (sells freely); an explicit `0` means sold out — see [Inventory tracking](#inventory-tracking). |
| `options` | `object` | No | Option label → value map (e.g. `{ "Zapach": "Black Fig" }`). Builds the variant types/values/combinations that drive the storefront picker. |
| `attributes` | `object[]` | No | Per-variant key/value rows shown on the product page, e.g. `[{ "key": "Nuty zapachowe", "value": "bergamotka, cedr" }]`. Each entry is `{ key, value, checked? }`. |

\* Exactly one of `priceCents` or `priceGrossCents` is required — sending both, or neither, is rejected. Unlike `POST /products`, these are **cents** (integer minor units), not decimals. Gross is converted to net using the tax rate assigned to the product; with no rate, gross and net are identical. See [Prices and tax](#prices-and-tax).

```bash
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"sku": "CT-LG-RED", "title": "Large / Red", "priceCents": 2500, "stock": 50}' \
  https://your-store.yns.store/api/v1/products/classic-tee/variants
```

Or with the shopper-facing gross amount — at 23% VAT, `3075` gross stores `2500` net:

```bash
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"sku": "CT-LG-RED", "title": "Large / Red", "priceGrossCents": 3075, "stock": 50}' \
  https://your-store.yns.store/api/v1/products/classic-tee/variants
```

---

## Batch Create Products

```
POST /api/v1/products/batch
```

Creates up to 100 products in a single request. Each product gets a default variant. Partial failures are reported in the `errors` array.

### Request Body

JSON array of product objects:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Product name |
| `slug` | `string` | No | URL slug (auto-generated from name) |
| `summary` | `string` | No | Short description |
| `price` | `number` | Yes | Price as decimal |
| `images` | `string[]` | No | Image URLs |
| `status` | `string` | No | `draft`, `published`, or `hidden` (default: `draft`) |
| `category_name` | `string` | No | Category name |
| `collection_names` | `string[]` | No | Collection names to add to |
| `sku` | `string` | No | Stock keeping unit |
| `cost` | `number` | No | Unit cost of goods (COGS) as a decimal in the store currency. Merchant-private. |
| `stock` | `number` | No | Inventory quantity |
| `shippable` | `boolean` | No | Requires shipping (default: `true`) |

```bash
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '[
    {"name": "Soap A", "price": 12.99, "sku": "SOAP-A", "stock": 50, "cost": 5.00},
    {"name": "Soap B", "price": 14.99, "status": "published"}
  ]' \
  https://your-store.yns.store/api/v1/products/batch
```

### Response

```json
{
  "message": "Import completed",
  "totalProducts": 2,
  "imported": 2,
  "errors": []
}
```

---

## Import Products (CSV)

```
POST /api/v1/products/import
Content-Type: text/plain
```

Import products from CSV. Supports multi-variant products (multiple rows with the same slug) and updates via `product_id`. Categories and collections are created or matched automatically.

```bash
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: text/plain" \
  --data-binary @products.csv \
  https://your-store.yns.store/api/v1/products/import
```

### Response

```json
{
  "message": "Import completed",
  "totalProducts": 10,
  "imported": 10,
  "errors": []
}
```

---

## Product Reviews

### List Reviews

```
GET /api/v1/products/:idOrSlug/reviews
```

Returns approved reviews with summary statistics.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | `number` | 10 | Reviews per page (1-100) |
| `offset` | `number` | 0 | Reviews to skip |
| `lang` | `string` | — | Locale code for translated slug lookup |

```json
{
  "data": [
    {
      "id": "0191abc0-0000-7000-8000-000000000200",
      "author": "Jane",
      "content": "Love this product!",
      "rating": 5,
      "createdAt": "2024-06-15T10:30:00.000Z"
    }
  ],
  "meta": { "count": 1, "offset": 0, "limit": 10 },
  "summary": { "averageRating": 5, "reviewCount": 1 }
}
```

### Create Review

```
POST /api/v1/products/:idOrSlug/reviews
```

Submits a review (pending approval by store owner).

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `author` | `string` | Yes | Reviewer name (1-100 chars) |
| `email` | `string` | Yes | Reviewer email (not returned in responses) |
| `content` | `string` | Yes | Review text (1-5000 chars) |
| `rating` | `number` | Yes | Star rating (1-5) |

---

## Product Filters

```
GET /api/v1/products/filters
```

Returns the facets available for building a storefront filter UI: the store's price range, its variant option types with their values, and the active categories, collections, and brands. Scoped store-wide rather than to a result set, so you can render the filter sidebar before running a search.

Only active categories, collections, and brands are returned, and variant types with no values are omitted.

```bash
curl \
  -H "Authorization: Bearer your_api_key" \
  https://your-store.yns.store/api/v1/products/filters
```

### Response

```json
{
  "priceBounds": {
    "min": 0,
    "max": 24900
  },
  "variantTypes": [
    {
      "label": "Size",
      "values": ["S", "M", "L"]
    },
    {
      "label": "Color",
      "values": ["Black", "White"]
    }
  ],
  "categories": [
    { "name": "Candles", "slug": "candles" }
  ],
  "collections": [
    { "name": "Summer", "slug": "summer" }
  ],
  "brands": [
    { "name": "Acme", "slug": "acme" }
  ]
}
```

Prices are in cents. Feed the selected values back into [List Products](#list-products) using `category`, `collection`, `brand`, `vts`, and the price range parameters.

---

## Translations

```
PUT /api/v1/products/:idOrSlug/translations/:locale
```

Upsert or delete a single locale's translated fields for a product. See the [Localization page](/docs/api-reference/localization#writing-translations) for the full field reference, behaviour, and examples.

---

## Reset Catalog

```
POST /api/v1/admin/reset-catalog
```

Deletes the store's entire catalog and blog in a single transaction. Intended for reseeding a demo or test store, not for routine cleanup.

**This is irreversible.** Products cascade to their variants, prices, combinations, memberships, favorites, and translations.

### Body Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `confirm` | `string` | Yes | Must exactly match the authenticated store's UUID |

The `confirm` field is a safety belt: if it doesn't equal the store id behind the API key, the request is refused with `400` and nothing is deleted. Unknown fields are rejected.

```bash
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"confirm":"0191abc0-1234-7def-8000-000000000001"}' \
  https://your-store.yns.store/api/v1/admin/reset-catalog
```

### Response

```json
{
  "ok": true,
  "deleted": {
    "products": 128,
    "collections": 6,
    "categories": 12,
    "posts": 9,
    "blog_categories": 3,
    "page_section_data": 4
  }
}
```

Orders, customers, and settings are left untouched.

> Get your store's UUID from [`GET /api/v1/me`](/docs/api-reference/store).