## Add to Cart / Create Cart

```
POST /api/v1/carts
```

Creates a new cart or updates an existing one. To create a new cart, omit `cartId`. To add to an existing cart, include it.

The endpoint supports two mutually exclusive modes: **variant add** (`variantId` + `quantity`) and **bundle add** (`bundleId` + `selections`).

### Variant Add

Add a single product variant to the cart. Setting `quantity` to `0` removes the item.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `variantId` | `string` | Yes | Product variant UUID |
| `quantity` | `number` | Yes | Quantity to apply (0 removes the item) |
| `mode` | `string` | No | `"add"` (default) increments the existing line quantity; `"set"` replaces it atomically |
| `cartId` | `string` | No | Existing cart UUID (omit to create new) |
| `subscriptionPlanId` | `string` | No | Subscription plan ID for recurring items — see [Subscription items](#subscription-items) |
| `currency` | `string` | No | Currency code for this cart (e.g. `EUR`). Validated against store's enabled currencies |

```bash
# Create a new cart
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "variantId": "0191abc0-0000-7000-8000-000000000100",
    "quantity": 1
  }' \
  https://your-store.yns.store/api/v1/carts
```

```bash
# Add to existing cart
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "cartId": "0191abc0-1234-7def-8000-000000000001",
    "variantId": "0191abc0-0000-7000-8000-000000000200",
    "quantity": 2
  }' \
  https://your-store.yns.store/api/v1/carts
```

### Bundle Add

Add a configured bundle as one line item. Get the available groups and choices from the `bundle` field on `GET /api/v1/products/:idOrSlug` for bundle-type products.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `bundleId` | `string` | Yes | Bundle product UUID |
| `selections` | `object[]` | Yes | Customer choices (see below) |
| `cartId` | `string` | No | Existing cart UUID (omit to create new) |
| `currency` | `string` | No | Currency code for this cart |

Each entry in `selections`:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `variantId` | `string` | Yes | Chosen variant UUID |
| `groupId` | `string` | Yes | The bundle group the choice belongs to |
| `quantity` | `number` | Yes | Units chosen of this variant (positive integer) |

Always-included (forced) items may be omitted from `selections` – the server fills them in automatically. Choices are validated server-side against the bundle's groups (per-group count, allow-duplicates, stock).

```bash
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "bundleId": "0191abc0-1234-7def-8000-000000000001",
    "selections": [
      {
        "variantId": "0191abc0-0000-7000-8000-000000000100",
        "groupId": "0191abc0-0000-7000-8000-000000000300",
        "quantity": 1
      }
    ]
  }' \
  https://your-store.yns.store/api/v1/carts
```

A `400` response with `details` is returned when the selections are invalid (wrong group count, disallowed duplicates, etc.).

**Re-adding a bundle:** If the same bundle is already in the cart with identical selections, the line quantity increments by one (capped by constituent stock). If the same bundle is already in the cart with *different* selections, the API returns `409 Conflict` with a `bundle_conflict` code – remove the existing bundle from the cart first to change its options.

```json
{
  "error": "Bundle already in cart with a different configuration",
  "details": ["This bundle is already in the cart with a different configuration. Remove it from the cart to change its options."]
}
```

### Stock Overflow (409)

When the requested quantity exceeds the variant's available stock, the API returns `409 Conflict`:

```json
{
  "error": "Insufficient stock",
  "available": 5,
  "requested": 10
}
```

Available stock accounts for units reserved by other active carts when stock hold is enabled.

### Draft products (409)

A variant whose product is still a `draft` cannot be added, whatever route its id arrived by — a draft is not on sale. `published` and `hidden` both stay addable: hidden means "not listed", not "not for sale", which is what direct and QR links depend on.

```json
{
  "error": "[POST /api/v1/carts] ApiError: This product isn't available right now.",
  "hint": "The product is a draft, so it cannot be purchased."
}
```

### Subscription items

Pass `subscriptionPlanId` to buy a line on a recurring plan. The plan must be one of the plans listed on that product — read them from `subscriptionPlanProducts[].subscriptionPlan` on the [product](/docs/api-reference/products) — or the add is rejected with `400`.

The product's `subscriptionMode` decides whether a plan is optional:

| `subscriptionMode` | Plan-less add |
|--------------------|---------------|
| `optional` | Allowed — an ordinary one-time purchase |
| `only` | Refused with `409` |
| `rolling` | Refused with `409` |

Refusals carry a machine-readable code at the start of the `hint`, so a client can recover without parsing prose:

| Code | Status | Meaning | Recovery |
|------|--------|---------|----------|
| `subscription_plan_required` | `409` | The product can only be bought as a subscription | Re-send with one of the product's plan ids |
| `subscription_cycle_unavailable` | `409` | A `rolling` programme has no published cycle covering today, so there is no box to ship | Retry once the next box is announced — read `subscriptionCycles.upcoming` on the product |
| `rolling_quantity_fixed` | `409` | A `rolling` line holds exactly one box; the add would leave it above 1 (a second `add` of a line already in the cart counts) | Remove the line instead of adding to it. Removals are never refused. |
| `subscription_already_in_cart` | `409` | The cart already holds a different subscription `(variant, plan)` pair | Remove the current subscription first |

A cart may hold **one** subscription next to as many one-time lines as it likes. Re-adding the *same* `(variant, plan)` pair is not a second subscription — it just increments that line (subject to `rolling_quantity_fixed` for rolling programmes).

```json
{
  "error": "[POST /api/v1/carts] ApiError: This product is subscription-only",
  "hint": "subscription_plan_required: pass one of the product's subscription plan ids as `subscriptionPlanId`."
}
```

### Response

Returns the complete cart with all line items and its [totals](#cart-totals):

```json
{
  "id": "0191abc0-1234-7def-8000-000000000001",
  "lineItems": [
    {
      "variantId": "0191abc0-0000-7000-8000-000000000100",
      "quantity": 1,
      "price": "2500",
      "product": {
        "name": "Classic Tee",
        "slug": "classic-tee"
      }
    }
  ],
  "subtotal": 3075,
  "subtotalNet": 2500,
  "subtotalGross": 3075,
  "totalTax": 575,
  "taxBreakdown": {
    "0191abc0-1234-7def-8000-000000000001": {
      "taxRate": {
        "id": "0191abc0-1234-7def-8000-000000000001",
        "name": "Standard VAT",
        "rate": "23000"
      },
      "ratePercent": 23,
      "tax": 575
    }
  }
}
```

---

## Get Cart

```
GET /api/v1/carts/:id
```

Returns the full cart with all line items, product details, and computed totals.

---

## Cart totals

Carts are priced before checkout, so the tax fields are populated as soon as the cart has line items — they are no longer `null` until an order is created. `POST /api/v1/carts`, `GET /api/v1/carts/:id`, and both coupon endpoints return the same shape.

| Field | Type | Description |
|-------|------|-------------|
| `subtotalNet` | `number` | Line items excluding tax, in minor units |
| `subtotalGross` | `number` | Line items including tax, in minor units |
| `totalTax` | `number` | Tax across line items and shipping (once selected), in minor units |
| `taxBreakdown` | `object` | Tax per tax-rate id: `{ "<taxRateId>": { taxRate, ratePercent, tax } }`. `ratePercent` is the percentage number (`23` = 23%) — use it for display. `taxRate` is the raw tax-rate row, whose own `rate` is the internal representation (percent × 1000, e.g. `"23000"`). `tax` is in minor units. |
| `subtotal` | `number` | Follows the store's tax behavior — gross when `inclusive`, net when `exclusive` |
| `shippingGross` | `number \| null` | Shipping including tax — populated once a shipping method is selected |
| `totalNet` | `number \| null` | Items plus shipping, excluding tax — populated once a shipping method is selected |
| `totalGross` | `number \| null` | Items plus shipping, including tax — populated once a shipping method is selected |
| `total` | `number \| null` | The amount charged — populated once a shipping method is selected |

The store's tax behavior comes from `taxBehavior` on [`GET /api/v1/me`](/docs/api-reference/store) and [`GET /api/v1/settings`](/docs/api-reference/settings). Line items' `productVariant` carries the same net/gross price twins as products — see [Prices and tax](/docs/api-reference/products#prices-and-tax).

**Stripe Tax:** when the Stripe Tax module is enabled, tax is computed by Stripe at checkout, so `subtotalNet`, `subtotalGross`, `totalTax`, and `taxBreakdown` stay `null` on the cart. Read the tax from the checkout session or the resulting order instead.

---

## Delete Cart

```
DELETE /api/v1/carts/:id
```

Permanently deletes a cart and all its line items.

---

## Checkout Redirect

```
GET /api/v1/carts/:id/checkout
```

Redirects (HTTP 308) to the store's hosted checkout page for the given cart. Useful for headless integrations that need to hand off to the YNS checkout flow.

```
308 → https://your-store.yns.store/checkout/r/{cartId}
```

---

## Apply Coupon to Cart

```
POST /api/v1/carts/:id/coupon
```

Applies a discount code to a cart. The coupon is validated against its date range, usage cap, product count requirements, and product/collection/category/brand scope before being applied.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `code` | `string` | Yes | Coupon code to apply (case-sensitive, max 64 chars) |

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

### Response (200)

Returns the full updated cart object with the coupon applied.

### Validation Error (422)

When the coupon cannot be applied, a `422` is returned with a machine-readable `reason`:

```json
{
  "error": "Coupon cannot be applied",
  "reason": "couponHasExpired"
}
```

| Reason | Description |
|--------|-------------|
| `couponNotFound` | No coupon with this code exists |
| `couponIsNotValidYet` | The coupon's start date is in the future |
| `couponHasExpired` | The coupon's end date has passed |
| `couponMaxUsesReached` | The coupon has been redeemed the maximum number of times |
| `couponMinProductCountNotMet` | The cart has fewer products than the coupon requires |
| `couponMaxProductCountExceeded` | The cart has more products than the coupon allows |
| `couponIsNotApplicable` | No cart items match the coupon's product/collection/category/brand scope |

---

## Remove Coupon from Cart

```
DELETE /api/v1/carts/:id/coupon
```

Removes the applied coupon from a cart.

```bash
curl -X DELETE \
  -H "Authorization: Bearer your_api_key" \
  https://your-store.yns.store/api/v1/carts/0191abc0-1234-7def-8000-000000000001/coupon
```

### Response (200)

```json
{
  "ok": true
}
```

---

## Remove Line Item

```
DELETE /api/v1/carts/:id/line-items/:variantId
```

Removes a specific variant from the cart. Returns the updated cart.

### Query Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `subscriptionPlanId` | `string` | Match subscription plan when removing |

For subscription items, include the `subscriptionPlanId` to identify the correct line item.