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. - Request —
POST, body is.strict()(unknown keys → 400validation_failed).customer_idis 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"
}- Response —
201 {cart}(decorated shape below). - Errors — 400
validation_failed|invalid_region|region_required(noregion_idand 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); 404variant_not_found. - SDK —
carts.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. - Request —
GET, no query. - Response —
200 {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. - SDK —
carts.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" = 404POST /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
}- Response —
200 {cart}. - Errors — 404
cart_not_found; 409cart_completed; 400validation_failed|invalid_region. - SDK —
carts.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+emailfrom 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. - Response —
200 {cart}withcustomer_idset. - Errors — 401
unauthenticated(no/invalid JWT); 404cart_not_found; 409cart_completed; 400validation_failed(any body key). - SDK —
carts.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" = 401POST /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(fromGET /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 withselling_plan_idset. - Response —
200 {cart}. - Errors — 404
cart_not_found|variant_not_found; 409cart_completed; 400insufficient_inventory(kit-aware — every linked inventory component is checked;detailscarries{variant_id, inventory_item_id, available, requested}) |price_not_found|invalid_selling_plan(unknown, disabled, or not attached to this product) |validation_failed. - SDK —
carts.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 alsorequires_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;
0deletes the line. - Auth — anon
x-client-id. - Request —
{quantity}(integer ≥ 0, REQUIRED).Contract note (code wins over store-api.md):
metadatais not accepted on update — only on add. - Response —
200 {cart}. - Errors — 404
cart_not_found|line_item_not_found; 409cart_completed; 400insufficient_inventory|validation_failed. - SDK —
carts.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. - Request —
DELETE, no body. - Response —
200 {cart}. - Errors — 404
cart_not_found(bad cart id). - SDK —
carts.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). - Response —
200 {cart}(decorated;cart.promotionsis the raw pivot embed[{promotion: {...}}]). Unknown codes on REMOVE silently no-op (Medusa parity); on ADD they error. - Errors — 404
cart_not_found; 404promotion_not_found(unknown code on add), 400promotion_inactive(draft/expired code on add), 400 zod (empty array / non-string entries). - SDK —
carts.applyPromotions(client, cartId, codes)/carts.removePromotions(client, cartId, codes). - Components — checkout
DiscountSection(apply + error copy viatranslatePromotionError); 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:
- Response —
200 {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(storeaccounts_mode='required'+ guest cart); 402 payment family (see checkout.md); 409cart_locked. - SDK —
carts.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).