Carts — lifecycle + line items

The cart is the storefront's working document: created anonymously, mutated through line-item and update calls, completed into an order (see checkout.md for the Buy-click sequence and gift-cards.md for gift-card tender). Every mutation returns the full decorated cart — totals are server truth, the storefront never does money math. All amounts are EUR decimal major units.

Auth for every endpoint on this page: anon x-client-id header. A customer JWT (authorization: Bearer <jwt>) is optional and only changes behavior where noted. Sending x-publishable-api-key additionally applies B2B channel scope (see Settings notes).

SDK module: @barter/storefront/api/carts.


POST /api/store/carts — create

  • Purpose — create a cart; first call of every storefront session that adds to cart.
  • Auth — anon x-client-id. With a Bearer JWT the customer is attached (customer_id + email) and initial items are priced with the customer's B2B groups. With a publishable key, the cart defaults to the key's sales channel.
  • RequestPOST, body is .strict() (unknown keys → 400 validation_failed). customer_id is deliberately NOT a field — it only ever derives from the JWT (forgery guard).
{
  "region_id": "reg_…",            // optional — falls back to the store's default region
  "email": "jane@example.com",     // optional here; REQUIRED before complete
  "currency_code": "eur",          // optional — unsupported values silently fall back to the region currency
  "items": [{ "variant_id": "variant_…", "quantity": 1,
              "selling_plan_id": "splan_…" }], // selling_plan_id optional —
                                    // same semantics as add-line below

  "sales_channel_id": "sc_…",      // optional; must be inside the publishable key's scope if one is sent
  "promo_codes": ["WELCOME10"],
  "shipping_address": { "first_name": "Jane", "country_code": "bg" }, // all fields optional here
  "billing_address": null,
  "metadata": { "utm": "…" },
  "locale": "bg"
}
  • Response201 {cart} (decorated shape below).
  • Errors — 400 validation_failed | invalid_region | region_required (no region_id and no store default) | invalid_sales_channel (channel outside the publishable key's scope) | price_not_found (an initial item has no price in the cart currency); 404 variant_not_found.
  • SDKcarts.createCart(client, input).
  • Components — cart drawer / add-to-cart buttons.
  • Settings — store default region (stores.default_region_id); enabled store currencies; publishable-key channel scope (b2b-v1); automatic promotions are applied on create.
# Create a cart on the seeded dev tenant (store default region), with the
# seeded Linen Shirt (M) variant. Capture ids for the blocks below.
CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"carts-doc-'"$RUN"'@example.test","currency_code":"eur",
       "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
echo "$CART_JSON" | grep -q '"total"'            # decorated totals present
echo "$CART_JSON" | grep -q '"gift_card_total"'  # gift-card tender decoration present
CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
REGION_ID=$(echo "$CART_JSON" | grep -o '"region_id":"[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$CART_ID" && test -n "$REGION_ID"

GET /api/store/carts/:id — retrieve

  • Purpose — read the decorated cart (page load, cart drawer refresh). Every read re-runs tax recalculation, totals decoration and gift-card tender resolution — amounts are always current.
  • Auth — anon x-client-id.
  • RequestGET, no query.
  • Response200 {cart}:
{
  "cart": {
    "id": "cart_…",
    "region_id": "reg_…",
    "currency_code": "eur",
    "email": "jane@example.com",
    "customer_id": null,             // set only via JWT paths
    "sales_channel_id": null,
    "completed_at": null,            // ISO timestamp once completed
    "metadata": {},
    "region": { "…": "regions row" },
    "shipping_address": null,        // cart_addresses row or null
    "billing_address": null,
    "items": [{
      "id": "li_…", "variant_id": "variant_…", "product_id": "prod_…",
      "title": "M", "product_title": "Linen Shirt", "product_handle": "linen-shirt",
      "thumbnail": "https://…", "variant_sku": "LIN-SHIRT-M",
      "quantity": 1, "unit_price": 45, "is_tax_inclusive": false,
      "is_discountable": true, "is_giftcard": false, "requires_shipping": true,
      "adjustments": [], "tax_lines": [{ "rate": 20 }],
      // per-line decoration:
      "subtotal": 45, "total": 54, "tax_total": 9, "original_total": 54,
      "discount_total": 0, "discount_subtotal": 0
    }],
    "shipping_methods": [],          // same per-line decoration shape
    "credit_lines": [],
    "promotions": [],
    "payment_collection": [],        // pivot → payment_collections (+ payment_sessions)
    // cart totals (ALL server-computed, EUR major units):
    "total": 54, "subtotal": 45, "tax_total": 9,
    "discount_total": 0, "discount_subtotal": 0, "discount_tax_total": 0,
    "shipping_total": 0, "shipping_subtotal": 0, "shipping_tax_total": 0,
    "item_total": 54, "item_subtotal": 45, "item_tax_total": 9,
    "original_total": 54, "original_subtotal": 45, "original_tax_total": 9,
    "credit_line_total": 0,
    "cod_fee_total": 0,              // non-zero only with a live pp_cod session + enabled COD integration
    "cod_fee_label": null,
    // gift-card tender decoration (totals above NEVER move):
    "gift_cards": [],                // [{id, last4, amount}] in apply order
    "gift_card_total": 0,            // Σ applied-card coverage
    "gift_card_remainder": 54        // max(total − gift_card_total, 0)
  }
}
  • Errors — 404 cart_not_found.
  • SDKcarts.retrieveCart(client, cartId).
  • Components — cart page/drawer, checkout summary.
  • Settings — region automatic_taxes (tax lines appear once a shipping address exists), COD integration (fee), gift cards.
curl -sf "$BASE/api/store/carts/$CART_ID" -H "x-client-id: $CLIENT_ID" \
  | grep -q '"gift_card_remainder"'

# Error contract: unknown cart → 404 {error, code: "cart_not_found"}
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/carts/cart_does_not_exist" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404

POST /api/store/carts/:id — update

  • Purpose — partial update: email, region/currency switch, addresses, metadata, locale.
  • Auth — anon x-client-id.
  • Request.strict() body; every key optional. Address semantics: object = set/replace in place, null = clear, absent = untouched. A region change re-resolves the currency, clears the shipping address (unless a new one is sent in the same call), deletes custom-priced items and re-prices the rest with the cart customer's groups.
{
  "region_id": "reg_…",
  "email": "new@example.com",
  "currency_code": "eur",
  "sales_channel_id": "sc_…",
  "metadata": {},
  "locale": "bg",
  "shipping_address": { "first_name": "Jane", "city": "Sofia", "country_code": "bg" },
  "billing_address": null
}
  • Response200 {cart}.
  • Errors — 404 cart_not_found; 409 cart_completed; 400 validation_failed | invalid_region.
  • SDKcarts.updateCart(client, cartId, input).
  • Components — region/locale switcher, checkout contact step.
  • Settings — store currencies; B2B price lists (region-change reprice); promotions re-applied after a region change.
curl -sf -X POST "$BASE/api/store/carts/$CART_ID" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"email":"carts-doc-'"$RUN"'-updated@example.test"}' \
  | grep -q 'carts-doc-'"$RUN"'-updated@example.test'

POST /api/store/carts/:id/customer — attach the authenticated customer

  • Purpose — after login, claim the guest cart for the signed-in customer (sets customer_id + email from the JWT).
  • Auth — anon x-client-id + REQUIRED Bearer JWT.
  • Request — body MUST be {} (z.object({}).strict()). The customer is never accepted from the body — that was a forgery vector.
  • Response200 {cart} with customer_id set.
  • Errors — 401 unauthenticated (no/invalid JWT); 404 cart_not_found; 409 cart_completed; 400 validation_failed (any body key).
  • SDKcarts.setCartCustomer(client, cartId).
  • Components — login/callback flow, account drawer.
  • Settings — B2B: attaching a grouped customer changes subsequent line pricing (price lists).
# Error contract (executable without a session): guest call → 401 unauthenticated.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  "$BASE/api/store/carts/$CART_ID/customer" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
test "$STATUS" = 401

POST /api/store/carts/:id/line-items — add item

  • Purpose — add a variant (adding a variant already in the cart under the SAME plan — or both one-time — bumps its quantity instead of duplicating the line; a one-time line and a subscription line of the same variant stay separate).
  • Auth — anon x-client-id.
  • Request{variant_id, quantity, selling_plan_id?, metadata?} (quantity: positive integer). selling_plan_id (from GET /products/:id/selling-plans) subscribes THIS line: the server validates the plan is enabled + attached to the variant's product and applies the plan price (percent off / fixed / catalog for cadence-only) — the storefront never computes it. The line comes back with selling_plan_id set.
  • Response200 {cart}.
  • Errors — 404 cart_not_found | variant_not_found; 409 cart_completed; 400 insufficient_inventory (kit-aware — every linked inventory component is checked; details carries {variant_id, inventory_item_id, available, requested}) | price_not_found | invalid_selling_plan (unknown, disabled, or not attached to this product) | validation_failed.
  • SDKcarts.addLineItem(client, cartId, input).
  • Components — PDP add-to-cart, cart drawer upsell.
  • Settings — B2B price lists (cart customer's groups); gift-card products ride the line as is_giftcard: true / is_discountable: false (digital cards also requires_shipping: false); promotions re-applied.
# Add the seeded Wool Beanie (Black); capture its line id from the response.
ADD_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/line-items" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"variant_id":"variant_01tst000000000000000003","quantity":1}')
echo "$ADD_JSON" | grep -q '"variant_sku":"WOOL-BNE-BLK"'
# Anchor the line id to the beanie's SKU (item order in the array is not guaranteed).
LINE_ID=$(echo "$ADD_JSON" \
  | grep -o '"id":"li_[^"]*"[^}]*"variant_sku":"WOOL-BNE-BLK"' \
  | head -1 | cut -d'"' -f4)
test -n "$LINE_ID"

# Error contract: an unknown subscription plan is refused before any write.
SP_RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/line-items" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"variant_id":"variant_01tst000000000000000003","quantity":1,"selling_plan_id":"splan_nope"}')
echo "$SP_RES" | grep -q '"code":"invalid_selling_plan"'

POST /api/store/carts/:id/line-items/:lineId — set quantity

  • Purpose — set a line's quantity; 0 deletes the line.
  • Auth — anon x-client-id.
  • Request{quantity} (integer ≥ 0, REQUIRED).

    Contract note (code wins over store-api.md): metadata is not accepted on update — only on add.

  • Response200 {cart}.
  • Errors — 404 cart_not_found | line_item_not_found; 409 cart_completed; 400 insufficient_inventory | validation_failed.
  • SDKcarts.updateLineItem(client, cartId, lineId, {quantity}).
  • Components — cart drawer quantity stepper.
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/line-items/$LINE_ID" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"quantity":2}' | grep -q '"cart"'

DELETE /api/store/carts/:id/line-items/:lineId — remove item

  • Purpose — remove a line (idempotent — an already-gone line still returns 200 {cart}).
  • Auth — anon x-client-id.
  • RequestDELETE, no body.
  • Response200 {cart}.
  • Errors — 404 cart_not_found (bad cart id).
  • SDKcarts.deleteLineItem(client, cartId, lineId).
  • Components — cart drawer remove button.
curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/line-items/$LINE_ID" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"cart"'

POST + DELETE /api/store/carts/:id/promotions — promo codes

  • Purpose — apply / remove discount codes; the server re-applies every cart promotion and recomputes totals (automatic promotions layer in on their own — only coded ones travel through here).
  • Auth — anon x-client-id.
  • Request{promo_codes: string[]} on BOTH verbs (Medusa reads the DELETE body, not query).
  • Response200 {cart} (decorated; cart.promotions is the raw pivot embed [{promotion: {...}}]). Unknown codes on REMOVE silently no-op (Medusa parity); on ADD they error.
  • Errors — 404 cart_not_found; 404 promotion_not_found (unknown code on add), 400 promotion_inactive (draft/expired code on add), 400 zod (empty array / non-string entries).
  • SDKcarts.applyPromotions(client, cartId, codes) / carts.removePromotions(client, cartId, codes).
  • Components — checkout DiscountSection (apply + error copy via translatePromotionError); cart-drawer promo banner is display-only.
  • Settings — Promotions admin (codes, status, rules); the promotions engine decides eligibility server-side.
# Error contract is executable without fixtures: unknown code on ADD.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
  "$BASE/api/store/carts/$CART_ID/promotions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"promo_codes":["NO-SUCH-CODE-'"$RUN"'"]}')
test "$STATUS" = 404

# REMOVE of an unknown code silently no-ops and returns the cart.
curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/promotions" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
  -d '{"promo_codes":["NO-SUCH-CODE-'"$RUN"'"]}' | grep -q '"cart"'

POST /api/store/carts/:id/complete — place the order

Documented in full in checkout.md (the Buy-click sequence, completion guard, payment authorization, idempotency). Summary of the validation gate, demonstrated executably below:

  • Response200 {type: "order", order}.

    Contract note (code wins over store-api.md): the documented {type: "cart", cart, error} failure union is never returned — failures throw the standard error envelope ({error, code, details?}) with a 4xx/5xx status and the cart stays open and retryable.

  • Validation errors (400)cart_email_required, cart_empty, shipping_address_required, shipping_method_required (only when a line requires shipping — digital-only carts skip it), payment_collection_required, payment_session_required, checkout_method_hidden (checkout rules), insufficient_inventory.
  • Other — 403 account_required (store accounts_mode='required' + guest cart); 402 payment family (see checkout.md); 409 cart_locked.
  • SDKcarts.completeCart(client, cartId).
# Validation gate, executable: a bare cart (no email) refuses to complete.
BARE_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
BARE_ID=$(echo "$BARE_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
COMPLETE_RES=$(curl -s -X POST "$BASE/api/store/carts/$BARE_ID/complete" \
  -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
echo "$COMPLETE_RES" | grep -q '"code":"cart_email_required"'

Cleanup / accretion note

Carts have no store-facing delete endpoint (retention is a server concern). The carts this page creates are inert rows on the shared dev tenant — the same accretion the test suite's own cart tests produce; they are never re-read by other suites (every suite creates its own carts).