Supercommerce API Docs
Store API

Cart Module — Storefront

HTTP surface for the storefront shopping cart — guest and logged-in reads/mutates, address attach, coupon apply/remove + browse, free-gift picker, guest→customer merge on login,…

HTTP surface for the storefront shopping cart — guest and logged-in reads/mutates, address attach, coupon apply/remove + browse, free-gift picker, guest→customer merge on login, and the checkout inventory-reservation handoff.

Source: api-modules/cart/src/controllers/store-cart.controller.ts, store-cart-address.controller.ts, store-cart-checkout.controller.ts, store-cart-coupon.controller.ts, store-cart-gift.controller.ts, store-cart-sync.controller.ts.

The cart module is pluggable — it depends on DISCOUNT_PORT and FREE_GIFT_PORT for coupon/free-gift evaluation. Order does not import CartService directly; it consumes the cart in-process via the CART_PORT token. Admin and vendor surfaces live in sibling docs.


Conventions

Authentication

The storefront cart endpoints use BetterAuthGuard with @OptionalAuth() — a session is not required. A guest cart is identified by an opaque x-cart-token header (issued by the server on first request); a logged-in cart is identified by the customer's session and binds automatically. The exception is /store/cart/sync, which requires a customer session.

Endpoint groupAuth
GET/POST/PATCH/DELETE /store/cart/**optional (guest or customer)
POST /store/cart/syncrequired (customer)

Headers

All /store/cart/** endpoints accept and emit two headers:

HeaderDirectionNotes
x-cart-tokenrequest (optional) + response (always)Opaque cart handle. Mint by omitting the header — the server's response sets it. Echo back on subsequent calls. Lost token = lost guest cart.
x-platformrequest (optional)WEB or APP (case-insensitive). Used for platform-scoped coupon and free-gift rules. Defaults to WEB.

For a logged-in customer the cart is resolved by their customer id (the active cart), so x-cart-token mainly matters for offering a guest token for adoption or as the payload of /sync.

Response envelope

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, e.g. pagination */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR, BELOW_MIN_QUANTITY_PER_CART, ABOVE_MAX_QUANTITY_PER_CART
401UNAUTHORIZED (sync without session)
403FORBIDDEN (guest cart cannot attach an address)
404NOT_FOUND, COUPON_NOT_APPLIED, GUEST_CART_NOT_FOUND
409CONFLICT, INSUFFICIENT_INVENTORY, COUPON_INDIVIDUAL_USE_CONFLICT, GIFT_VARIANT_NOT_IN_POOL, GIFT_RULE_NOT_IN_PICKER, GIFT_SLOTS_FULL, GUEST_CART_OWNED_BY_OTHER_CUSTOMER, CART_EMPTY, CART_NO_PRODUCT_LINES
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Currency

All monetary fields (unitPrice, subtotal, discountTotal, total, allocations[].amount, etc.) are integer subunits (paise / cents / eurocents). Single currency per tenant — clients send and receive integers, never decimal strings. Example: "subtotal": 125000 is ₹1,250.00 in an INR tenant.


Domain types

Enums

type Platform     = "APP" | "WEB" | "BOTH";
type CartStatus   = "active" | "abandoned" | "converted" | "discarded";
type CartLineType = "PRODUCT" | "GIFT";

CartLineResponse

type CartLineResponse = {
  id: string;
  vendorId: string;
  productId: string;
  variantId: string;
  quantity: number;
  type: CartLineType;
  unitPrice: number;                  // effective price now (subunits); 0 for GIFT
  unitPriceAtAdd: number | null;      // snapshot at add-time
  specialPriceAtAdd: number | null;
  priceDrifted: boolean;              // current variant.price != unit_price_at_add
  allocatedDiscount: number;          // coupon allocation against THIS line (subunits)
  freeGiftRuleId: string | null;      // populated only for GIFT lines
  sourceLineId: string | null;        // for BuyXGetY: the qualifying buy line
  product: ProductCard | null;        // canonical product card; null if the product was deleted
};

Each line embeds product, the canonical product card — the exact same shape the storefront search endpoint returns for each result (see the Product type in search.md: id, title, subtitle, description, slug, thumbnail, images, priceStart, priceEnd, brand, inStock, hasActiveSpecial, variants[]). This lets the frontend render one product-card component everywhere (search grid and cart alike). It is built from Postgres (authoritative price/stock), reflects all sibling variants, and is null only when the line's product no longer exists. Money fields are integer subunits.

CartVendorBag

type CartVendorBag = {
  vendorId: string;
  vendor: { name: string; slug: string; logo: string | null } | null;
  lines: CartLineResponse[];
  subtotal: number;                   // PRODUCT lines only (subunits)
  discountAllocated: number;          // sum of allocatedDiscount across this bag
  totalBeforeShippingAndTax: number;  // max(0, subtotal - discountAllocated)
};

Bags are sorted by subtotal descending, then by vendorId for stability.

CartAppliedCouponSnapshot

type CartAppliedCouponSnapshot = {
  code: string;
  discountId: string;
  individualUse: boolean;
  freeShipping: boolean;
  allocations: Array<{ vendorId: string; amount: number }>;
};

Allocation is pro-rata by each vendor's eligible-line subtotal. The rounding residual goes to the largest bag so per-line allocatedDiscount always reconciles to the coupon's discount amount.

PendingGiftCandidate

type PendingGiftCandidate = {
  ruleId: string;
  slotCount: number;                  // how many variants the customer must pick
  alreadySelectedVariantIds: string[];
  optionVariantIds: string[];         // pool the customer may pick from
};

CartResponse

type CartResponse = {
  cartId: string;
  cartToken: string;                  // also sent in the x-cart-token response header
  customerId: string | null;
  status: CartStatus;
  platform: Platform;
  version: number;                    // optimistic-version counter (cache key)
  bags: CartVendorBag[];
  cartTotals: {
    subtotal: number;
    discountTotal: number;
    shippingTotal: number;
    total: number;
  };
  appliedCoupons: CartAppliedCouponSnapshot[];
  pendingGifts: PendingGiftCandidate[];
  deliveryAddressId: string | null;
  deliveryAddress: AddressResponse | null;   // fetched fresh on every read (not cached)
  lastActivityAt: string;             // ISO
  createdAt: string;                  // ISO
};

deliveryAddress is re-read on every cart fetch so edits in the customer's address book are reflected immediately.


Endpoints

Base path: /store/cart. Every endpoint emits x-cart-token in the response — clients should overwrite their stored token on each call.

Every successful mutator returns the full CartResponse so the client can re-render after a single round-trip.

GET /store/cart — Resolve current cart

Returns the cart matching the request's x-cart-token if it's valid and active. Otherwise mints a fresh cart and emits its token. For a logged-in customer with an existing active cart, that cart is preferred — but if the request also presents an unbound guest token, the guest cart is adopted (customer bound to it). To merge a guest cart into an existing customer cart, use POST /store/cart/sync.

Response 200CartResponse.

Side effects — emits cart.created when a cart is freshly minted.


POST /store/cart/lines — Add a variant

Idempotently adds quantity for the given variant. If the variant is already in the cart, the new quantity is summed with the existing one (existing.quantity + body.quantity); otherwise a new PRODUCT line is created.

Body

{
  "variantId": "01J9...",   // required, min 1 char
  "quantity": 2             // optional, integer >= 1, default 1
}

Response 201CartResponse.

Cross-field validation

RuleError code
quantity >= variant.minQuantityPerCart (when set)BELOW_MIN_QUANTITY_PER_CART
quantity <= variant.maxQuantityPerCart (when set)ABOVE_MAX_QUANTITY_PER_CART
Inventory soft check passesINSUFFICIENT_INVENTORY (409)

Side effects — emits cart.item.added for new lines or cart.item.quantity.changed when summing.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod or per-cart bounds
404NOT_FOUNDVariant does not exist or is soft-deleted
409INSUFFICIENT_INVENTORYStock cannot cover the requested quantity

PATCH /store/cart/lines/:lineId — Update line quantity

Replaces the line's quantity (does not sum). Only PRODUCT lines are mutable here — GIFT lines are owned by the reconciler.

Body

{ "quantity": 5 }   // integer >= 1

Response 200CartResponse.

Errors

StatusCodeWhen
400VALIDATION_ERROR, per-cart boundsBody fails validation
404NOT_FOUNDLine does not exist or belongs to a different cart
409CONFLICTLine is GIFT (not manually editable)
409INSUFFICIENT_INVENTORYStock cannot cover the new quantity

DELETE /store/cart/lines/:lineId — Remove a line

Soft-deletes the line. Only PRODUCT lines may be removed manually.

Response 200CartResponse.

Errors

StatusCodeWhen
404NOT_FOUNDLine does not exist or belongs to a different cart
409CONFLICTLine is GIFT (not manually removable)

DELETE /store/cart — Clear all PRODUCT lines

Soft-deletes every PRODUCT line on the cart. Coupons remain applied; gifts disappear on the next read since the qualifying lines are gone.

Response 200CartResponse.


PATCH /store/cart/address — Set or clear the delivery address

Pin the customer-side delivery address that drives the shipping rate. Pass deliveryAddressId: <id> to set, or null to clear. Requires the cart to be bound to a customer — guest carts can't attach an address. The address must belong to the same customer; cross-customer ids return 404 (existence is not leaked).

Body

{ "deliveryAddressId": "01J9..." }   // or null

Response 200CartResponse (with deliveryAddress populated).

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod (missing field, empty string)
403FORBIDDENCart is a guest cart — sign in first
404NOT_FOUNDAddress does not exist or belongs to a different customer

POST /store/cart/coupons — Apply a coupon

Validates the coupon against the cart via DISCOUNT_PORT.validateCoupon, enforces individual-use stacking, and persists the application. Idempotent — re-applying an already-applied code returns the existing snapshot.

Body

{ "code": "WELCOME10" }   // 1..64 chars; trimmed; matched case-insensitively, stored uppercase

Response 200CartResponse (with the new coupon under appliedCoupons and recomputed allocations).

Stacking rules — an incoming individualUse coupon cannot stack with anything; an existing individualUse coupon blocks any new coupon. The check reads discount.individualUsageOnly directly, so a currently-ineligible existing individual-use coupon (e.g. cart subtotal dropped below its min) still blocks new applications.

Side effects — emits cart.coupon.applied.

Errors

StatusCodeWhen
404NOT_FOUNDCart does not exist
409DISCOUNT_NOT_VALID (or specific reason from the discount port)Coupon ineligible against the current cart
409COUPON_INDIVIDUAL_USE_CONFLICTStacking blocked (carries couponCode, optionally conflictingCode)

DELETE /store/cart/coupons/:code — Remove an applied coupon

code is matched case-insensitively.

Response 200CartResponse.

Side effects — emits cart.coupon.removed.

Errors

StatusCodeWhen
404COUPON_NOT_APPLIEDCoupon was not applied to this cart

A coupon may also be auto-removed during a cart read when it loses eligibility (e.g. items were removed and the cart subtotal fell below the coupon's minOrderAmount). That fires cart.coupon.auto.removed with a reason, but is invisible to the HTTP request that triggered the recompute.


GET /store/cart/coupons/eligible — Browse showable coupons

Returns all showOnCart discounts split into eligible vs ineligible. Uses a single batched validation pass, so request cost is constant regardless of how many showOnCart rules exist platform-wide.

Response 200

{
  "data": {
    "eligible": [
      {
        "code": "WELCOME10",
        "name": "Welcome 10",
        "discountId": "01J9...",
        "discountType": "PERCENTAGE",
        "value": 10,
        "freeShipping": false,
        "individualUse": false,
        "showOnCart": true,
        "estimatedDiscountAmount": 12500
      }
    ],
    "ineligible": [
      {
        "code": "FESTIVE25",
        "name": "Q4 Festive",
        "discountId": "01J9...",
        "discountType": "FIXED",
        "value": 25000,
        "freeShipping": true,
        "individualUse": false,
        "showOnCart": true,
        "estimatedDiscountAmount": 0,
        "reason": "BELOW_MIN_ORDER"
      }
    ]
  }
}

reason is a stable code (e.g. BELOW_MIN_ORDER, EXCLUDES_CUSTOMER) so the UI can localize.


GET /store/cart/gifts/pending — Free-gift picker candidates

Re-runs the gift reconciler under an advisory lock and returns rules currently in picker mode — i.e., rules whose qualifying conditions are met but require the customer to pick a variant. Auto-attach rules don't appear here; their gifts are already in bags[].lines with type: "GIFT".

Calling this endpoint may attach/detach GIFT lines as a side effect of reconciliation.

Response 200

{
  "data": {
    "pendingGifts": [
      {
        "ruleId": "01J9...",
        "slotCount": 1,
        "alreadySelectedVariantIds": [],
        "optionVariantIds": ["01J9...", "01J9..."]
      }
    ]
  }
}

POST /store/cart/gifts/select — Pick a variant for a picker rule

Records the customer's choice and re-runs reconciliation, which inserts the chosen GIFT line.

Body

{
  "ruleId": "01J9...",
  "variantId": "01J9..."
}

Response 200CartResponse.

Side effects — emits cart.gift.selected; reconciliation may also emit cart.gift.attached.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails validation
404NOT_FOUNDCart does not exist
409GIFT_RULE_NOT_IN_PICKERRule is auto-attach or already satisfied
409GIFT_VARIANT_NOT_IN_POOLVariant is not one of the rule's options
409GIFT_SLOTS_FULLCustomer already picked slotCount variants for this rule

DELETE /store/cart/gifts/select/:ruleId/:variantId — Revoke a selection

Removes the prior pick and re-runs reconciliation (which detaches the corresponding GIFT line).

Response 200CartResponse.

Errors

StatusCodeWhen
404NOT_FOUNDSelection did not exist

POST /store/cart/sync — Merge a guest cart into the customer cart

Authenticated. Atomically claims the guest cart (CAS: active → discarded, only when its customer_id IS NULL) before copying its PRODUCT lines into the customer's active cart, summing overlapping variant quantities (capped at current stock). GIFT lines on the customer side are dropped — the reconciler rebuilds them. Guest coupons are re-applied via the normal apply path; ones that fail eligibility or stacking are silently skipped.

Body

{ "guestCartToken": "ct_01HX..." }

Response 200CartResponse of the customer's now-merged cart.

Concurrency safety — wrapped in an advisory lock keyed on the customer cart plus the CAS gate on the guest cart. Retries and parallel syncs against the same guest token return the post-merge cart without doubling quantities.

Side effects — emits cart.merged with the merged line count and surviving coupon codes.

Errors

StatusCodeWhen
401UNAUTHORIZEDNo customer session
404GUEST_CART_NOT_FOUNDguestCartToken doesn't resolve to a cart
404NOT_FOUNDThe customer-side cart context cannot be resolved
409GUEST_CART_OWNED_BY_OTHER_CUSTOMERGuest cart is already bound to a different customer

POST /store/cart/prepare-checkout — Reserve inventory and snapshot

Re-prices the cart (revalidates coupons, reconciles gifts), then reserves stock via InventoryService.reserve, returning the priced view plus the reservation batch handle. Wrapped in an advisory lock so parallel attempts on the same cart serialize.

The reservation idempotency key is cart:<id>:v<version>: a retry after a network blip resolves to the same key and returns the existing reservation rather than double-holding stock. cart.version is deliberately not bumped inside this endpoint.

Response 200PrepareCheckoutResponse (= CartResponse plus reservation fields).

{
  "data": {
    "cartId": "01J9...",
    "cartToken": "ct_01HX...",
    "customerId": "01J9...",
    "status": "active",
    "platform": "WEB",
    "version": 7,
    "bags": [ /* ... */ ],
    "cartTotals": { "subtotal": 250000, "discountTotal": 25000, "shippingTotal": 0, "total": 225000 },
    "appliedCoupons": [ /* ... */ ],
    "pendingGifts": [],
    "deliveryAddressId": "01J9...",
    "deliveryAddress": { /* AddressResponse */ },
    "lastActivityAt": "2026-05-07T10:30:00.000Z",
    "createdAt":      "2026-05-07T10:00:00.000Z",
    "reservationBatchId": "01J9...",
    "reservationExpiresAt": "2026-05-07T10:45:00.000Z"
  }
}

Side effects — emits cart.checkout.prepared.

Errors

StatusCodeWhen
409CART_EMPTYCart has no bags
409CART_NO_PRODUCT_LINESCart has only GIFT lines (nothing to reserve)
409INSUFFICIENT_INVENTORYInventory rejects the reservation

  • customer — owns the address book; PATCH /store/cart/address references its addressId. See customer.md.
  • catalog — variants referenced by POST /store/cart/lines come from catalog. See catalog.md.
  • discount — backs DISCOUNT_PORT for coupon evaluation.
  • free-gift — backs FREE_GIFT_PORT for picker/auto rules.
  • inventory — backs the reservation and stock checks in prepare-checkout.
  • order — consumes CART_PORT to materialize the order from a prepared cart.

On this page