# Cart lines

Cart lines hold the products in a cart. Each line snapshots the product name and SKU at add time, stores the resolved unit price in minor units, and, for configurable products, a full configuration snapshot. All three endpoints require the `commerce:write` scope, an `active` cart, and pass the cart ownership check (see the Carts page). Every endpoint returns the full cart envelope, the same shape as `GET /v1/api/commerce/carts/{id}`.

## Product type rules

How a product is added depends on its type (`simple`, `variable`, `configurable`, `digital`, `license`, `subscription`):

- **Products with active variants** (typically `variable`): `variant_id` is required and must be an active variant of the product.
- **Products without variants** (`simple`, `digital`, `license`, `subscription`): `variant_id` must be omitted.
- **Configurable products**: `configuration` is required and `variant_id` is forbidden. The configuration is an object `{"dimensions": {...}, "choices": ["choice_id", ...]}` priced by the configurator engine. The line stores a `config_hash` plus a full snapshot under `attributes.configuration` (dimensions, choices, price breakdown, component list, matrix cell). Component stock is reserved per configurator component.
- For all other products, `configuration` is forbidden.

Lines deduplicate on the combination of product, variant, and configuration hash: adding the same combination again merges into the existing line, summing the quantity and re-resolving the unit price at the new quantity tier.

Unit prices resolve against the request's pricing context: the customer identified by `X-Customer-Token` if present, else the cart's bound customer, else guest pricing.

## POST /v1/api/commerce/carts/{id}/lines

Adds a product to the cart, or merges into an existing identical line. Returns the full cart with status 201.

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. Must be an `active` cart. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `product_id` | string (UUID) | yes | The product to add. Must be an active product of the store. |
| `variant_id` | string (UUID) or null | conditional | Required when the product has active variants; forbidden otherwise, and always forbidden for configurable products. |
| `quantity` | integer | yes | Minimum 1. The merged line quantity may not exceed the policy's `max_quantity_per_line`. |
| `configuration` | object | conditional | Required for configurable products, forbidden otherwise. Shape: `{"dimensions": {...}, "choices": [choice_ids]}`. Validated by the configurator engine. |

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | no | Selects the pricing context; required if the cart is bound to a non-guest customer. |

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4/lines \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{
    "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "variant_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
    "quantity": 2
  }'
```

```javascript title="Request (JS)"
const res = await fetch(
  `https://api.borkol.com/v1/api/commerce/carts/${cartId}/lines`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      product_id: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      variant_id: "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
      quantity: 2,
    }),
  }
);
const { data: cart } = await res.json();
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "status": "active",
    "lines": [
      {
        "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
        "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "variant_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
        "name": "Garden Chair",
        "sku": "CHAIR-GRN-M",
        "quantity": 2,
        "unit_price_amount": 2250,
        "unit_price": { "amount": 2250, "currency": "EUR" },
        "line_total": { "amount": 4500, "currency": "EUR" },
        "attributes": null
      }
    ],
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  }
}
```

For a configurable product, the created line carries the configuration snapshot:

```json title="Response (configurable line excerpt)"
{
  "attributes": {
    "configuration": {
      "dimensions": { "width": 300, "depth": 250 },
      "choices": ["c1d2e3f4-..."],
      "breakdown": [],
      "components": [{ "product_id": "...", "qty": 2 }],
      "matrix": {}
    }
  }
}
```

### Errors

Domain failures are standard 422 validation errors with the message attached to the offending field in `errors`:

| Status | Body | When |
| --- | --- | --- |
| 422 | `errors.product_id: ["Product is not available."]` | Product missing, inactive, or (for configurable products) the configurator engine is absent. |
| 422 | `errors.variant_id: ["Choose a variant for this product."]` | Product has active variants and `variant_id` was omitted. |
| 422 | `errors.variant_id: ["Variant is not available."]` | Variant id does not resolve to an active variant of the product. |
| 422 | `errors.variant_id: ["This product has no variants."]` | `variant_id` sent for a product without variants. |
| 422 | `errors.variant_id: ["Configurable products have no variants."]` | `variant_id` sent for a configurable product. |
| 422 | `errors.configuration: ["Configure this product before adding it."]` | Configurable product without a `configuration`. |
| 422 | `errors.configuration: ["This product is not configurable."]` | `configuration` sent for a non-configurable product. |
| 422 | `errors.choices`, `errors.choices.{choiceId}`, `errors.dimensions.{key}`, `errors.configuration` | Configurator engine errors, attached to their own field keys exactly as documented in the [quote error catalog](/configurator/quote). |
| 422 | `{"message": "Quantity exceeds the per-line maximum.", "code": "cart_constraint"}` | Merged quantity would exceed `max_quantity_per_line`. |
| 422 | `{"message": "Cart line limit reached.", "code": "cart_constraint"}` | Adding a new distinct line would exceed `max_lines`. |
| 404 | `{"message": "..."}` | Cart unknown, expired, or not `active`. |
| 403 | ownership, scope, module, or suspension body | See the Carts page. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |

## PATCH /v1/api/commerce/carts/{id}/lines/{lineId}

Changes a line's quantity. Non-configured product lines are re-priced at the new quantity (tier pricing) using the request's pricing context. Configured lines, and lines whose variant has since been deactivated, keep their stored unit price; checkout is where unavailability surfaces. Returns the full cart with status 200.

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. Must be an `active` cart. |
| `lineId` | string (UUID) | yes | Line id. Must belong to this cart. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `quantity` | integer | yes | Minimum 1, maximum the policy's `max_quantity_per_line`. |

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | no | Selects the pricing context; required if the cart is bound to a non-guest customer. |

```bash title="Request"
curl -X PATCH https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4/lines/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9 \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{"quantity": 3}'
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "lines": [{ "id": "5e6f7a8b-...", "quantity": 3, "...": "..." }],
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  }
}
```

### Errors

| Status | Body | When |
| --- | --- | --- |
| 422 | `{"message": "...", "errors": {"quantity": ["..."]}}` | Missing or non-integer quantity, or below 1. |
| 422 | `{"message": "Quantity exceeds the per-line maximum.", "code": "cart_constraint"}` | Above `max_quantity_per_line`. |
| 404 | `{"message": "..."}` | Cart unknown or not `active`, or the line does not belong to this cart. |
| 403 | ownership, scope, module, or suspension body | See the Carts page. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |

## DELETE /v1/api/commerce/carts/{id}/lines/{lineId}

Removes a line from the cart and releases any stock hold held for the cart. Returns the full cart, without the removed line, with status 200.

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. Must be an `active` cart. |
| `lineId` | string (UUID) | yes | Line id. Must belong to this cart. |

No body.

```bash title="Request"
curl -X DELETE https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4/lines/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9 \
  -H "Authorization: Bearer {api_key}"
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "lines": [],
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  }
}
```

### Errors

| Status | Body | When |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Cart unknown or not `active`, or the line does not belong to this cart. |
| 403 | ownership, scope, module, or suspension body | See the Carts page. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |
