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. The token rotates when a guest cart is adopted at login, so always overwrite the stored token from the response header.
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, CART_LINE_PRICE_UNAVAILABLE, CART_CHECKOUT_IN_PROGRESS
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" | "buy_now";
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;              // effective price now != effective price at add-time
  priceUnavailable: boolean;          // no price determinable; unitPrice reads 0 but the item is NOT free
  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, rating, 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. Read a variant's availability from isOrderable rather than inventoryQuantity > 0 — a backorderable or untracked variant is orderable at zero — and note inventoryQuantity already has safety stock deducted, so it is the ceiling checkout will actually honour.

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.

priceDrifted compares the price the customer would pay now against what the line effectively cost when it was added — a special-price window opening or closing flips it even though variant.price never moved.

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.

CartRemovedCoupon

type CartRemovedCoupon = {
  code: string;
  reason: string;                     // e.g. DISCOUNT_EXPIRED, BELOW_MIN_AMOUNT, USAGE_LIMIT_REACHED
};

PendingGiftCandidate

type PendingGiftOption = {
  variantId: string;
  productId: string;
  title: string;
  thumbnail: string | null;
  price: number | null;               // integer subunits (paise)
  specialPrice: number | null;        // integer subunits (paise)
};

type PendingGiftCandidate = {
  ruleId: string;
  slotCount: number;                  // how many variants the customer must pick
  alreadySelectedVariantIds: string[];
  optionVariantIds: string[];         // pool the customer may pick from
  optionVariants: PendingGiftOption[]; // display data for optionVariantIds (same pool, enriched)
};

type GiftTierLockedReason =
  | "BELOW_MIN_AMOUNT"
  | "ABOVE_MAX_AMOUNT"
  | "REQUIRES_LOGIN"
  | "NO_GIFT_VARIANTS"
  | "GIFTS_OUT_OF_STOCK";

type GiftProgressTier = {
  ruleId: string;
  name: string;
  description: string | null;
  threshold: number;                  // integer subunits (paise)
  maxAmount: number | null;           // integer subunits (paise)
  achieved: boolean;
  amountAway: number;                 // 0 whenever the threshold is already met
  giftQuantity: number;               // gifts this rung grants
  gifts: PendingGiftOption[];         // same display shape as the picker's options
  lockedReason: GiftTierLockedReason | null;
};

type GiftProgressResponse = {
  enabled: boolean;
  currentAmount: number;              // integer subunits (paise)
  tiers: GiftProgressTier[];          // sorted by threshold ascending
  nextTier: GiftProgressTier | null;  // first rung with amountAway > 0
  amountToNextTier: number | null;    // nextTier.amountAway; always > 0 when set
  highestAchievedTier: GiftProgressTier | null;
};

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[];
  removedCoupons: CartRemovedCoupon[];       // auto-dropped during this build; [] when none
  appliedRedemption: CartAppliedRedemption | null;          // rewards plugin; null when no points apply
  redemptionRejection: RewardRedemptionRejectReason | null; // why they did not apply
  pendingGifts: PendingGiftCandidate[];
  deliveryAddressId: string | null;
  deliveryAddress: AddressResponse | null;   // fetched fresh on every read (not cached)
  serviceability: CartServiceability | null; // delivery verdict for that address
  lastActivityAt: string;             // ISO
  createdAt: string;                  // ISO
};

type CartServiceability = {
  pincode: string;
  status: "allowed" | "inform" | "block";
  deliverable: boolean;               // true for allowed and inform
  message: string | null;             // operator's copy; null when allowed
};

removedCoupons reports the coupons this build dropped because they stopped validating (expired, cart fell below the minimum, usage limit reached). They are already gone from appliedCoupons and from the totals — surface them to the shopper so the discount does not just silently vanish.

appliedRedemption and redemptionRejection come from the rewards plugin and are documented with the redemption surface. The pair behaves like removedCoupons: when points that were applying stop applying — the cart fell under the redemption minimum, the operator tightened a cap — appliedRedemption goes null and redemptionRejection says why, so the discount does not just silently vanish.

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

serviceability is derived from that address on every read (never cached) and is null when no address is picked or when the serviceability plugin is not installed. It is advisory — a cart read never fails on an undeliverable pincode, so the shopper can still see and edit the bag. block means place-order will reject with PINCODE_NOT_SERVICEABLE; inform warns but places fine. For the pre-address pincode box, use GET /store/serviceability/check.


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, active and not yet bound to a customer. 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.

Adoption rotates the cart token: the pre-login token is a bearer credential handed to an anonymous visitor, so once the cart carries a customer id (with their coupons, redemption intent and delivery address) it stops resolving. The adopting response carries the new token in x-cart-token. A request without a session that presents a customer-bound token gets a fresh empty guest cart, never the bound one.

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,
        "requireCustomerLogin": false,
        "platform": "BOTH",
        "criteriaScope": "CART_SUBTOTAL",
        "minAmount": null,
        "maxAmount": null,
        "minQuantity": null,
        "minProductCount": null,
        "startsAt": null,
        "endsAt": null,
        "showOnCart": true,
        "estimatedDiscountAmount": 12500,
        "applied": false
      }
    ],
    "ineligible": [
      {
        "code": "FESTIVE25",
        "name": "Q4 Festive",
        "discountId": "01J9...",
        "discountType": "FIXED",
        "value": 25000,
        "freeShipping": true,
        "individualUse": false,
        "requireCustomerLogin": false,
        "platform": "BOTH",
        "criteriaScope": "CART_SUBTOTAL",
        "minAmount": 150000,
        "maxAmount": null,
        "minQuantity": null,
        "minProductCount": null,
        "startsAt": null,
        "endsAt": "2026-09-30T00:00:00.000Z",
        "showOnCart": true,
        "estimatedDiscountAmount": 0,
        "applied": false,
        "reason": "BELOW_MIN_AMOUNT",
        "amountToQualify": 12000
      }
    ]
  }
}

Every entry carries the full coupon card — the offer plus its qualifying thresholds and validity window — so a tile can render "₹250 off on orders above ₹1,500, ends 30 Sep" without a second round trip. The same card shape is returned by GET /store/discounts/applicable, so one component renders both surfaces. All amounts are integer subunits.

Orderingeligible is sorted by estimatedDiscountAmount descending (ties broken on code), so the biggest saving leads. ineligible is sorted by amountToQualify ascending with unknown shortfalls last, so the coupon closest to unlocking leads. Both orders are stable across reads.

applied marks a coupon that is already on the cart — render it as applied rather than as a fresh offer.

reason is a stable code (e.g. BELOW_MIN_AMOUNT, CUSTOMER_NOT_ELIGIBLE, DISCOUNT_PLATFORM_MISMATCH) so the UI can localize.

Coupons outside their validity window are excluded from both lists rather than reported as DISCOUNT_EXPIRED — an expired coupon is noise the shopper can never act on, unlike a missed minimum.

amountToQualify is the subunits still needed to clear minAmount, for an "add ₹120 more" nudge. It is non-null only when reason is BELOW_MIN_AMOUNT and criteriaScope is CART_SUBTOTAL; every other scope measures the threshold against a total this endpoint does not compute, so it reports null rather than a number that would not actually unlock the coupon.

Stacking is pre-applied. The same individual-use rules that POST /store/cart/coupons enforces run here, so a coupon blocked by what is already on the cart lands in ineligible with reason: "COUPON_INDIVIDUAL_USE_CONFLICT" instead of sitting behind an Apply button that throws 409. An individual-use coupon that is itself applied stays in eligible (flagged applied: true).

discountType is PERCENTAGE, FIXED, or FREE_GIFT. A FREE_GIFT coupon always reports value: 0 and estimatedDiscountAmount: 0 — it takes nothing off the cart and instead unlocks a COUPON_BASED free-gift rule, so render it as a gift rather than a saving.


Coupon auto-apply

When the operator turns on the product_cart.auto_apply_coupon store setting, adding, re-quantifying, or removing a cart line applies the highest-saving eligible showOnCart coupon on the customer's behalf. The applied coupon is already reflected in the CartResponse those endpoints return — there is no separate call to make.

The guarantees the storefront can rely on:

  • Once per cart. Success stamps autoCouponApplied into cart metadata and no later line change re-runs it, so a customer who removes the coupon is not fought with. A cart that never qualified stays unstamped, so a later line change can still earn one.
  • Never overrides a choice. A cart that already carries any coupon is skipped entirely.
  • Only real savings. A coupon whose estimatedDiscountAmount is 0 (a FREE_GIFT code, or one whose eligible lines net out to nothing) is not worth the cart's single shot and is passed over.
  • Never fails the mutation. An apply that throws is logged and swallowed; the add-to-cart still succeeds.

Buy-now carts are excluded — a one-shot single-item checkout is not the surface for a courtesy coupon.


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..."],
        "optionVariants": [
          {
            "variantId": "01J9...",
            "productId": "01J8...",
            "title": "Travel Pouch",
            "thumbnail": "https://cdn.example.com/pouch.jpg",
            "price": 49900,
            "specialPrice": null
          }
        ]
      }
    ]
  }
}

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

GET /store/cart/gifts/progress — Spend-tier gift ladder

Returns the ordered ladder of spend milestones the storefront renders as a progress bar — "Shop for ₹999 / 1 Free gift" — including rungs the cart has not reached, with the exact gap left to close. This is the free-gift mirror of GET /store/cart/coupons/eligible; it is a pure read and never attaches or detaches a GIFT line.

A rung is one free-gift rule with showOnCart: true, type: AUTOMATIC, criteriaScope in {CART_SUBTOTAL, ORDER_TOTAL} and a minAmount — the only rules a linear bar can honestly position. BUYXGETY and COUPON_BASED rules never appear here; they surface through pendingGifts instead.

All amounts are integer subunits (paise)₹999 is 99900. Divide by 100 before formatting.

Response 200

{
  "data": {
    "enabled": true,
    "currentAmount": 120000,
    "tiers": [
      {
        "ruleId": "01J9...",
        "name": "Gift 999",
        "description": null,
        "threshold": 99900,
        "maxAmount": null,
        "achieved": true,
        "amountAway": 0,
        "giftQuantity": 1,
        "gifts": [
          {
            "variantId": "01J9...",
            "productId": "01J8...",
            "title": "Travel Pouch",
            "thumbnail": "https://cdn.example.com/pouch.jpg",
            "price": 49900,
            "specialPrice": null
          }
        ],
        "lockedReason": null
      },
      {
        "ruleId": "01JA...",
        "name": "Gift 2499",
        "description": null,
        "threshold": 249900,
        "maxAmount": null,
        "achieved": false,
        "amountAway": 129900,
        "giftQuantity": 1,
        "gifts": [],
        "lockedReason": "BELOW_MIN_AMOUNT"
      }
    ],
    "nextTier": { "ruleId": "01JA...", "threshold": 249900, "amountAway": 129900 },
    "amountToNextTier": 129900,
    "highestAchievedTier": { "ruleId": "01J9...", "threshold": 99900 }
  }
}

tiers is sorted by threshold ascending, and is the full ladder — render the bar from it.

nextTier is the next rung the shopper can unlock by adding more to the cart: the first rung with amountAway > 0. A rung that is locked for some other reason (REQUIRES_LOGIN, NO_GIFT_VARIANTS, GIFTS_OUT_OF_STOCK, ABOVE_MAX_AMOUNT) is never nextTier, because telling the shopper to spend 0 more would be wrong — it still appears in tiers with its lockedReason so you can prompt for the real blocker. amountToNextTier mirrors nextTier.amountAway and is therefore always > 0 when present; both are null when no rung is reachable by spending more.

highestAchievedTier is the last cleared rung, or null when none is cleared. gifts carries the same option shape as pendingGifts[].optionVariants — a variant whose product was deleted is omitted.

enabled is false when the operator turns off the product_cart.gift_progress_bar_enabled store setting. tiers is then always empty — but the underlying rules keep firing, so gifts still attach as GIFT lines and pendingGifts still populates. Hide the bar, not the gifts.

enabled: true with an empty tiers is the ordinary "nothing to show" case: no rule currently qualifies as a rung.

lockedReason codes — stable, so the UI can localize.

CodeMeaning
BELOW_MIN_AMOUNTCart has not reached threshold yet — amountAway is the gap
ABOVE_MAX_AMOUNTCart overshot the rule's maxAmount window
REQUIRES_LOGINRule sets requireCustomerLogin and the caller is a guest — prompt sign-in
NO_GIFT_VARIANTSMisconfigured rule — no gift variants are configured on it at all
GIFTS_OUT_OF_STOCKRule has a gift pool, but every variant in it is currently unavailable

null means the rung is unlocked (achieved: true).

Rungs stack: a ₹2,499 cart clears both the ₹999 and ₹2,499 rules and receives both gifts. Operators wanting mutually exclusive rungs set maxAmount on the lower ones — there is no "best tier wins" logic. Rules scoped to other customers (customerScope) are omitted entirely rather than shown locked.

The same ladder is available for a Buy Now cart — see GET /store/cart/buy-now/gifts/progress.


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 handles. Wrapped in an advisory lock so parallel attempts on the same cart serialize.

The re-price here always bypasses the priced-cart cache. That cache is keyed on cart.version, which does not move when an operator edits a price or a coupon passes its end date, so a cart read can serve a price up to 30 minutes stale — checkout cannot, because this snapshot is what the order freezes onto. Expect the totals returned here to differ from the last GET /store/cart when a price changed in the meantime, and expect removedCoupons to be populated when a coupon expired between the two calls.

Reservation batches are per-vendor, so a cart spanning multiple vendors produces one batch per vendor; reservationBatchIds lists them and all share a single reservationExpiresAt. The reservation idempotency key is cart:<id>:v<version>:<vendorId>, or cart:<id>:v<version>:g<digest>:<vendorId> when the cart holds free gifts (digest is a short hash of the gift set): a retry after a network blip resolves to the same per-vendor key and returns the existing reservation rather than double-holding stock. cart.version is deliberately not bumped inside this endpoint — except when the hold has already lapsed on an unchanged cart: the key would only return the expired batch, so the endpoint releases any still-active sibling batches, bumps the version once, and reserves again under the new key.

The hold lasts admin.payment.checkout_hold_minutes (default 15, range 5–120) from this call, and reservationExpiresAt reports when it ends. Placing the order extends it to admin.payment.reservation_hold_hours.

Free gifts are reserved too. A GIFT line consumes inventory exactly like a purchased one, so a vendor contributing only a gift gets its own batch, and a variant that is both bought and gifted is reserved as a single summed item. If a gift's stock cannot be reserved it is dropped from the cart and reported in removedGifts rather than failing the checkout — a free item never blocks a paid basket, and since gifts are zero-priced the quoted totals do not change. removedGifts is advisory; it blocks nothing.

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": [ /* ... */ ],
    "removedCoupons": [{ "code": "SUMMER20", "reason": "DISCOUNT_EXPIRED" }],
    "pendingGifts": [],
    "deliveryAddressId": "01J9...",
    "deliveryAddress": { /* AddressResponse */ },
    "serviceability": { "pincode": "110001", "status": "allowed", "deliverable": true, "message": null },
    "lastActivityAt": "2026-05-07T10:30:00.000Z",
    "createdAt":      "2026-05-07T10:00:00.000Z",
    "reservationBatchIds": ["01J9...", "01JA..."],
    "reservationExpiresAt": "2026-05-07T10:45:00.000Z",
    "removedGifts": [
      { "ruleId": "01J9...", "variantId": "01JB...", "reason": "OUT_OF_STOCK" }
    ]
  }
}

Side effects — emits cart.checkout.prepared.

Errors

StatusCodeWhen
409CART_EMPTYCart has no bags
409CART_NO_PRODUCT_LINESCart has only GIFT lines (nothing to reserve)
409CART_LINE_PRICE_UNAVAILABLEA line has no determinable price; variantIds lists the offenders
409INSUFFICIENT_INVENTORYInventory rejects the reservation

Buy Now

An isolated, single-item checkout that never touches the customer's real cart — no cart badge change, invisible to GET /store/cart. Every /store/cart/buy-now/** endpoint resolves its cart strictly by the x-cart-token it's given; a buy-now token must never be mixed with the regular cart's token, and vice versa. Each POST /store/cart/buy-now call mints a brand-new cart (status: "buy_now") — no reuse across clicks.

POST /store/cart/buy-now — Start a Buy Now checkout

Mints a fresh buy-now cart, adds the line, and reserves inventory in one call. Any incoming x-cart-token is ignored — a new cart is always created.

Body

{ "variantId": "01J9...", "quantity": 1 }

Response 201PrepareCheckoutResponse, same shape as POST /store/cart/prepare-checkout. x-cart-token in the response header is this buy-now cart's handle — store it separately from the regular cart's token.

Errors

StatusCodeWhen
404NOT_FOUNDvariantId doesn't resolve to a product variant
409INSUFFICIENT_INVENTORYInventory rejects the reservation

GET /store/cart/buy-now — Fetch the current Buy Now cart

Response 200CartResponse. 404s (NOT_FOUND) if the token doesn't resolve to a buy_now cart owned by the caller.

PATCH /store/cart/buy-now/address — Set or clear the delivery address

Same body/behavior as PATCH /store/cart/address, scoped to the buy-now cart.

POST /store/cart/buy-now/coupons — Apply a coupon

Same body/behavior as POST /store/cart/coupons, scoped to the buy-now cart.

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

Same behavior as DELETE /store/cart/coupons/:code, scoped to the buy-now cart.

GET /store/cart/buy-now/coupons/eligible — Browse showable coupons

Same behavior as GET /store/cart/coupons/eligible, scoped to the buy-now cart.

GET /store/cart/buy-now/gifts/progress — Buy-now gift ladder

Same GiftProgressResponse as GET /store/cart/gifts/progress, scoped to the buy-now cart. Unlike the persistent cart, the buy-now token is required — there is no singleton to fall back on.

StatusCodeWhen
400BAD_REQUESTx-cart-token header missing
404NOT_FOUNDBuy-now cart not found

POST /store/checkout/buy-now/place-order — Place the order

Documented in order.md. Same body as the regular place-order; resolves the cart strictly by the buy-now x-cart-token.


  • 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

ConventionsAuthenticationHeadersResponse envelopeError envelopeCurrencyDomain typesEnumsCartLineResponseCartVendorBagCartAppliedCouponSnapshotCartRemovedCouponPendingGiftCandidateCartResponseEndpointsGET /store/cart — Resolve current cartPOST /store/cart/lines — Add a variantPATCH /store/cart/lines/:lineId — Update line quantityDELETE /store/cart/lines/:lineId — Remove a lineDELETE /store/cart — Clear all PRODUCT linesPATCH /store/cart/address — Set or clear the delivery addressPOST /store/cart/coupons — Apply a couponDELETE /store/cart/coupons/:code — Remove an applied couponGET /store/cart/coupons/eligible — Browse showable couponsCoupon auto-applyGET /store/cart/gifts/pending — Free-gift picker candidatesPOST /store/cart/gifts/select — Pick a variant for a picker ruleDELETE /store/cart/gifts/select/:ruleId/:variantId — Revoke a selectionGET /store/cart/gifts/progress — Spend-tier gift ladderPOST /store/cart/sync — Merge a guest cart into the customer cartPOST /store/cart/prepare-checkout — Reserve inventory and snapshotBuy NowPOST /store/cart/buy-now — Start a Buy Now checkoutGET /store/cart/buy-now — Fetch the current Buy Now cartPATCH /store/cart/buy-now/address — Set or clear the delivery addressPOST /store/cart/buy-now/coupons — Apply a couponDELETE /store/cart/buy-now/coupons/:code — Remove an applied couponGET /store/cart/buy-now/coupons/eligible — Browse showable couponsGET /store/cart/buy-now/gifts/progress — Buy-now gift ladderPOST /store/checkout/buy-now/place-order — Place the orderRelated modules