# Carts

A cart is the mutable pre-order object a storefront builds up before checkout. Every cart belongs to one store, has a UUID id, and is always created in currency `EUR` with a 30-day expiry. Every write to a cart (adding lines, changing email, attaching a discount code) extends the expiry by another 30 days and updates the cart's last-activity timestamp. Expired carts are physically deleted; an expired cart id returns 404.

All cart endpoints require the `Authorization: Bearer {api_key}` header. Read endpoints need the `commerce:read` scope, write endpoints need `commerce:write`, and the commerce module must be enabled for the store. The tenant rate limit of 120 requests per minute applies.

## Cart lifecycle and status

| Status | Meaning |
| --- | --- |
| `active` | The cart accepts writes. This is the only status a cart is ever created in. |
| `converted` | The cart became an order (checkout) or a quote (quote request). Read-only. |
| `merged` | The cart was folded into another cart during a claim. Read-only. |

Write endpoints look the cart up with `status = active`, so a `converted` or `merged` cart id returns 404 on every write. The exceptions are `GET /v1/api/commerce/carts/{id}`, which returns a cart in any status, and `POST /v1/api/commerce/carts/{id}/checkout`, which returns the existing order for an already-converted cart (idempotent replay).

## Ownership: the X-Customer-Token header

Carts start anonymous. The optional `X-Customer-Token` header carries an opaque customer session token (issued by the customers module) and affects cart routes in three ways:

1. **Pricing.** When present and valid, prices are resolved in that customer's pricing context (customer-group pricing, B2B price display). Without it, guest pricing applies.
2. **Ownership.** If a cart's `customer_id` is set and that customer is a real (non-guest) account, every cart-scoped request must carry an `X-Customer-Token` that resolves to that same customer. Otherwise the request fails with `403 {"message": "This cart belongs to a customer."}`. Carts with `customer_id` null stay open, and so do carts bound to a guest customer row created by email capture (guests have no token to present). If the customers module is unavailable, the check fails closed with the same 403.
3. **Binding.** `POST /v1/api/commerce/carts/{id}/claim` requires the header, and at checkout a valid token binds the resulting order to the customer.

A token that is present but does not resolve returns `401 {"message": "Invalid customer token."}` on checkout, quote request, and order history, and a plain 401 on claim. On the other cart routes an unresolvable token simply falls back to guest pricing unless the cart is customer-bound (then the ownership 403 applies).

## The cart response shape

Every cart endpoint (and several cart-line and discount endpoints) returns the same envelope: `{"data": {...cart...}}`. Fields:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | always | Cart id. Store it client-side; it is the visitor's handle. |
| `currency` | string | always | Always `EUR`. |
| `status` | string | always | `active`, `converted`, or `merged`. |
| `email` | string or null | always | Email captured via `PATCH /carts/{id}`, else null. |
| `metadata` | object or null | always | Free-form scalar map set via `PATCH /carts/{id}`, else null. |
| `discount_code` | string or null | always | The attached code, stored verbatim, else null. |
| `discount_code_error` | string or null | always | Null when the code applies (or no code is set). Otherwise one of `invalid`, `expired`, `min_order`, `not_eligible`, `usage_limit`. |
| `discounts` | array | always | Applied discounts, each `{id, name, code, kind, amount}`. `kind` is one of `order`, `products`, `shipping`, `bogo`; `code` is null for automatic discounts; `amount` is an integer in minor units. |
| `policy` | object | always | The store's cart policy, see below. |
| `requires_quote` | boolean | always | True when the cart currently trips a quote trigger and checkout will refuse with `QUOTE_REQUIRED`. |
| `quote_reasons` | array of strings | always | Active trigger reasons: `cart_threshold`, `configurator_choice`, `customer_group`, `product_purchasability`. Empty when `requires_quote` is false. |
| `lines` | array | always | Cart lines, see below. |
| `totals` | object | always | Cart totals, see below. |

### `policy`

The store's cart policy, evaluated live from tenant settings. Storefronts should render against it (for example, hide prices for guests, require login before checkout).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `identity_mode` | string | always | `guest_allowed`, `login_required`, or `guest_only`. |
| `audience` | string | always | `both`, `b2b_only`, or `b2c_only`. |
| `min_order_value_amount` | integer or null | always | Minimum cart total in minor units required to check out, or null for no minimum. |
| `max_lines` | integer | always | Maximum number of distinct lines. Default 100. |
| `max_quantity_per_line` | integer | always | Maximum quantity per line. Default 100. |
| `pricing` | object | always | Price display block for the current pricing context. |
| `pricing.guest_price_visibility` | string | always | `visible` or `hidden`. |
| `pricing.vat_display` | string | always | `inc` or `ex`. Resolves to `ex` automatically for B2B customers. |
| `pricing.show_original_price` | boolean | always | Whether to show the pre-discount price next to a discounted one. |
| `pricing.show_tier_table` | boolean | always | Whether to show quantity tier price tables. |

### `lines[]`

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | always | Line id. |
| `product_id` | string (UUID) | always | The product. |
| `variant_id` | string (UUID) or null | always | The variant, when the product has variants. |
| `name` | string | always | Product name snapshot taken when the line was added. |
| `sku` | string or null | always | SKU snapshot. |
| `quantity` | integer | always | Quantity, at least 1. |
| `unit_price_amount` | integer | always | Unit price in minor units (cents). |
| `unit_price` | object | always | The same unit price as a money object `{"amount": int, "currency": "EUR"}`. |
| `line_total` | object | always | Line total money object, after discount allocation. |
| `attributes` | object or null | always | Null for plain lines. For configurable products this holds the full `configuration` snapshot (dimensions, choices, breakdown, components, matrix). |

### `totals`

All money values are objects `{"amount": int, "currency": "EUR"}` with `amount` in minor units. Whether prices include tax is a store setting (`prices_include_tax`, default true).

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `subtotal` | money | always | Goods total excluding tax. |
| `tax_total` | money | always | Total tax. |
| `total` | money | always | Grand total. |
| `discount_total` | money | always | Total goods discount applied. |
| `tax_breakdown` | array | always | Per-rate breakdown, each `{"rate": float, "base": int, "tax": int}` with `base` and `tax` in minor units. |

## POST /v1/api/commerce/carts

Creates a new empty cart. Requires `commerce:write`. There is no request body and no customer token is needed: carts start anonymous. The cart is created with currency `EUR`, status `active`, and a 30-day `expires_at`. Persist the returned `id` client-side (cookie or local storage); it is the only handle to the cart.

### Parameters

None.

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/commerce/carts \
  -H "Authorization: Bearer {api_key}"
```

```javascript title="Request (JS)"
const res = await fetch("https://api.borkol.com/v1/api/commerce/carts", {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}` },
});
const { data: cart } = await res.json();
// persist cart.id
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "currency": "EUR",
    "status": "active",
    "email": null,
    "metadata": null,
    "discount_code": null,
    "discount_code_error": null,
    "discounts": [],
    "policy": {
      "identity_mode": "guest_allowed",
      "audience": "both",
      "min_order_value_amount": null,
      "max_lines": 100,
      "max_quantity_per_line": 100,
      "pricing": {
        "guest_price_visibility": "visible",
        "vat_display": "inc",
        "show_original_price": false,
        "show_tier_table": true
      }
    },
    "requires_quote": false,
    "quote_reasons": [],
    "lines": [],
    "totals": {
      "subtotal": { "amount": 0, "currency": "EUR" },
      "tax_total": { "amount": 0, "currency": "EUR" },
      "total": { "amount": 0, "currency": "EUR" },
      "discount_total": { "amount": 0, "currency": "EUR" },
      "tax_breakdown": []
    }
  }
}
```

The response status is 201.

### Errors

| Status | Body | When |
| --- | --- | --- |
| 401 | `{"message": "Unauthenticated."}` | Missing or invalid API key. |
| 403 | `{"message": "Invalid ability provided."}` | Key lacks `commerce:write`. |
| 403 | `{"error": "Module not enabled: commerce"}` | Commerce module disabled for the store. |
| 403 | `{"message": "Tenant suspended.", "code": "tenant_suspended"}` | Store suspended. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |

## GET /v1/api/commerce/carts/{id}

Fetches a cart with lines, live-evaluated discounts, totals, the store's cart policy block, and the current quote-trigger state. Requires `commerce:read`. Unlike write endpoints, this works for a cart in any status (`active`, `converted`, `merged`). Discounts and `discount_code_error` are re-evaluated on every read, so this endpoint is the source of truth for rendering the cart. Cart ownership is enforced (see above).

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. |

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | no | Required if the cart is bound to a non-guest customer. Also selects customer-group pricing for displayed prices. |

```bash title="Request"
curl https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4 \
  -H "Authorization: Bearer {api_key}"
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "currency": "EUR",
    "status": "active",
    "email": "jane@example.com",
    "metadata": { "source": "web" },
    "discount_code": "WELCOME10",
    "discount_code_error": null,
    "discounts": [
      {
        "id": "71c9e0aa-2f4b-4d31-9a77-0b1c2d3e4f5a",
        "name": "Welcome 10%",
        "code": "WELCOME10",
        "kind": "order",
        "amount": 450
      }
    ],
    "policy": {
      "identity_mode": "guest_allowed",
      "audience": "both",
      "min_order_value_amount": null,
      "max_lines": 100,
      "max_quantity_per_line": 100,
      "pricing": {
        "guest_price_visibility": "visible",
        "vat_display": "inc",
        "show_original_price": false,
        "show_tier_table": true
      }
    },
    "requires_quote": false,
    "quote_reasons": [],
    "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": 4050, "currency": "EUR" },
        "attributes": null
      }
    ],
    "totals": {
      "subtotal": { "amount": 3347, "currency": "EUR" },
      "tax_total": { "amount": 703, "currency": "EUR" },
      "total": { "amount": 4050, "currency": "EUR" },
      "discount_total": { "amount": 450, "currency": "EUR" },
      "tax_breakdown": [{ "rate": 21, "base": 3347, "tax": 703 }]
    }
  }
}
```

### Errors

| Status | Body | When |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Unknown id, expired (pruned) cart, or a cart belonging to another store. |
| 403 | `{"message": "This cart belongs to a customer."}` | Cart is customer-bound and the token is missing or resolves to a different customer. |
| 403 | scope, module, or suspension body | See POST /carts. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |

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

Updates the cart's email and/or metadata. Requires `commerce:write`; the cart must be `active`; ownership is enforced. Setting `email` lowercases it and, when the customers module is active, finds or creates a guest customer row and binds the cart to it (or binds to the token's account when a valid `X-Customer-Token` proves ownership). This binding is the basis for abandoned-cart recovery; a guest-bound cart remains accessible without a token.

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | no | Valid email address, max 255 characters. Stored lowercased. |
| `metadata` | object or null | no | Free-form map, max 16 keys. Every value must be a scalar (string, number, boolean) or null. Pass null to clear. |

Both fields are `sometimes` rules: omitting a field leaves it unchanged.

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | no | 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 \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane@example.com", "metadata": {"utm_source": "newsletter"}}'
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "email": "jane@example.com",
    "metadata": { "utm_source": "newsletter" },
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  }
}
```

The response is the full cart envelope, identical in shape to `GET /v1/api/commerce/carts/{id}`, with status 200.

### Errors

| Status | Body | When |
| --- | --- | --- |
| 422 | `{"message": "...", "errors": {"email": ["The email field must be a valid email address."]}}` | Validation failure (invalid email, too many metadata keys, non-scalar metadata value). |
| 404 | `{"message": "..."}` | Cart unknown, expired, or not `active`. |
| 403 | `{"message": "This cart belongs to a customer."}` | Ownership check failed. |
| 403 | scope, module, or suspension body | See POST /carts. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |
