Cart Service
For validation semantics (errors/warnings, the atomic upsert contract, preview/convert correlation) see the Cart Validation & Checkout — Frontend Integration Guide. This page covers the endpoints and session/merge mechanics only.
Overview
The Cart Service manages shopping carts and their line items. Carts support anonymous guest flows, authenticated user sessions, and seamless merge on login.
Each cart is identified by a client-generated GUID and can be associated with a user after login.
Cart types are seeded as follows:
| Id | Title |
|---|---|
| 1 | Guest |
| 2 | Wishlist |
| 3 | Saved For Later |
| 4 | Quote |
All cart endpoints are [AllowAnonymous] with rate limiting applied per IP (AnonCart policy), except cart deletion which requires FullManage.
All operations are tenant-aware.
Database tables
sales.Cartsales.CartItemref.CartType
Endpoints
GET
/api/v1/Cart
Returns a cart by its GUID or by user GUID.
Query parameters:
guid(string, optional) — cart GUIDuserGuid(Guid, optional) — looks up the cart associated with the user
Behavior:
- Returns
404if not found - Only one of
guidoruserGuidshould be supplied
Response:
CartModel(includesItemslist)
Authorization:
[AllowAnonymous]— no token required
POST
/api/v1/Cart
Unified cart session endpoint — creates, claims, or merges a cart depending on the fields supplied.
Request body (CartSessionDTO):
UserGuid(Guid?, optional) — authenticated user's GUID. If present, the request must be authenticated.CartTypeId(int?, optional) — cart type to assign on creationMergeFromGuid(string?, optional) — GUID of a guest cart to merge into the user's cart
Behavior modes:
| Scenario | Fields supplied | Result |
|---|---|---|
| Create (guest) | {} |
Creates a new anonymous cart |
| Create (logged-in) | { "userGuid": "..." } |
Creates a cart owned by the user |
| Claim | { "userGuid": "...", "mergeFromGuid": "..." } |
Assigns the guest cart to the user if they have no existing cart |
| Merge | { "userGuid": "...", "mergeFromGuid": "..." } |
Merges the guest cart into the user's existing cart |
Response:
200 OK—CartModel
Authorization:
[AllowAnonymous]— but ifUserGuidis provided the request must include a valid Bearer token
POST
/api/v1/Cart/items
Adds, updates, or removes cart items by cart GUID.
Request body (UpsertCartItemsDTO):
Guid(string, required) — cart GUIDItems(List, required) — items to upsert: Sku(string, required) — product SKUQuantity(int, required) — new quantity. Set to0or negative to remove the item.UserId(long?, optional) — entity-user placing this line (customisation scoping)Notes(string?, optional) — customisation notes
Behavior:
- An empty
Itemslist deletes the entire cart - Quantity
<= 0removes that item from the cart - New additions are validated atomically — if any new SKU is unavailable/not allowed, the
entire batch is rejected (nothing applied) and the response is
422with a fullCartModelbody (operationErrorset,validation.rejectedAdditionspopulated). Updates/ removals of lines already in the cart are never blocked this way. See the frontend guide for the full contract.
Response:
200 OK—CartModel(success)422 Unprocessable Entity—CartModel(batch rejected; still a full cart body)
Authorization:
[AllowAnonymous]— no token required
POST
/api/v1/Cart/guid/{guid}/invalid-items/remove
Removes exactly the error-severity items excluded for the supplied EntityId, PriceListId,
and DocumentTypeGuid context. Send all their CartItemId values as CartItemIds. The API
revalidates before removal and returns 409 CartConcurrencyConflict if the set changed.
Success returns the updated CartModel; valid items remain untouched.
POST
/api/v1/Cart/guid/{guid}/preview
Calculates prices, tax, and totals for a cart without creating a document.
Route parameters:
guid(string, required) — cart GUID
Request body (PreviewCartRequest):
EntityId(long, required) — customer entity to price againstUserId(long?, optional) — user placing the orderPriceListId(long?, optional) — price list to applyDocumentTypeGuid(string, required) — the document type the cart is for (e.g."SALES_ORDER","QUOTE")PromoCodes(string[]?, optional) — promo codes to evaluate for this request (see Promo codes)
Behavior:
- Resolves product prices using the given entity and price list
- Applies price tier fallback: uses the closest tier at or below the requested quantity
- Evaluates the promo codes at every discount level and reports each code's outcome
- Every submitted code that is not
Appliedbecomes a cart-level warning inCartValidation.CartWarningsand changesCartValidation.Signature; the merged signature is saved on the cart - Returns a fully calculated document preview without changing cart contents or creating a document; validation metadata on existing cart items may be refreshed
Response:
200 OK—DocumentModel(unsaved,Id = 0), withAppliedDiscountsand, when codes were sent,PromoCodes400 Bad Request—CartPromoCodesInvalid(6053) if more than 10 codes are sent, or any code is longer than 100 characters
Authorization:
[AllowAnonymous]— no token required
POST
/api/v1/Cart/guid/{guid}/convert
Converts a cart into a saved document. The cart is deleted on success.
Route parameters:
guid(string, required) — cart GUID
Request body (ConvertCartRequest — extends PreviewCartRequest):
EntityId(long, required) — customer entityUserId(long?, optional) — user placing the orderPriceListId(long?, optional) — price list to applyDocumentTypeGuid(string, required) — target document type code (e.g."SALES_ORDER","QUOTE"). Determines document type and initial statusPromoCodes(string[]?, optional) — promo codes to apply; send the same set the customer accepted at preview (see Promo codes)DocumentCategoryId(int?, optional) — document category FKBillingAddressSnapshots(DocumentBillingAddressSnapshotModel?, optional) — full billing address to stamp on the documentShippingAddressSnapshots(DocumentShippingAddressSnapshotModel, required) — full shipping address to stamp on the documentNotes(string?, optional) — order notesPurchaseOrderRef(string?, optional) — customer PO referenceAdditionalRef(string?, optional) — secondary referenceTags(string?, optional) — comma-separated tagsDescription(string?, optional) — document descriptionActionNotes(string?, optional) — internal action notesOther1/Other2/Other3(string?, optional) — configurable fieldsOtherNotes(string?, optional) — additional notesIsBillingSameAsShipping(bool) — iftrue, billing snapshot is copied from shippingAcknowledgedValidationSignature(string?, optional) — current warning signature after explicit customer acceptance; a preview alone does not acknowledge warningsRequireAllItemsConvertible(bool, optional) — when true, reject with409 / 6051if any item is excluded by current validation
Behavior:
- Creates
DocumentandDocumentLinerecords from cart contents - Items with validation errors are excluded from the document, not a reason to refuse
conversion — convert only fails if nothing in the cart is convertible
(
422 CartHasNoConvertibleItems). See the frontend guide for the full contract, including the warning-acknowledgement gate. - The cart is priced first, then every submitted promo code that is not
Appliedis added as a cart-level warning and the signature recomputed. If there are active warnings andAcknowledgedValidationSignaturedoes not match the recomputed signature, convert returns409 CartValidationAcknowledgementRequiredand nothing is saved. A code that applied at preview but is invalid at convert therefore blocks convert until the new signature is acknowledged. The document saved is exactly the one that was priced and checked - Every applied discount is recorded in discount usage history with its real amount, inside the
same transaction, and the promo outcomes are written to the audit log
(
Cart {guid} promo codes: SAVE10=Applied, FREESHIP=Invalid) - Address snapshots are saved and linked to the document — they are immutable copies of the address at time of conversion, independent of any future address changes
- Prices are resolved using the same tier logic as preview
- WarehouseId is taken from the stock item associated with each variant
- Document status is determined by the target type's
InitialStatusGuidconfiguration; falls back toNEW_ORDERif not set - Source cart is deleted on success, atomically with document creation
Response:
201 Created—DocumentModel(fully populated, same shape as/api/v1/Documents/{id}), plusAppliedDiscountsand, when codes were sent,PromoCodes400 Bad Request—CartPromoCodesInvalid(6053) for more than 10 codes or a code longer than 100 characters422 Unprocessable Entity—CartHasNoConvertibleItemsif nothing in the cart is convertible409 Conflict—CartContainsExcludedItemsif strict conversion encounters invalid items409 Conflict—CartValidationAcknowledgementRequiredif warnings (including promo code warnings) are active and the signature has not been acknowledged
Authorization:
[AllowAnonymous]— no token required
DELETE
/api/v1/Cart/{id}
Deletes a cart by its internal numeric ID.
Route parameters:
id(long, required) — cart identifier
Response:
204 No Content
Authorization:
- Requires Bearer Token
- Permission:
FullManage
Models
CartModel
Fields:
Guid (type: string?)— client-assigned GUID (used for all cart operations)DateAdded (type: DateTime?)— creation timestampDeviceId (type: string?)— device identifierUserId (type: long?)— associated userCartTypeId (type: int?)— FK toCartTypeDateLastUpdated (type: DateTime?)— last update timestampItems (type: List<CartItemModel>?)— cart line itemsValidation (type: CartValidationModel?)— aggregate + per-item validation state (see frontend guide)OperationError (type: CartOperationError?)— set only on a rejectedPOST /itemscall
Note: Id is present in the DB but omitted from JSON responses.
Each CartItemModel does include its own Id in JSON so preview cartItemId values can be
joined back to cart rows.
CartItemPostDTO
Fields used when upserting items via POST /api/v1/Cart/items:
Sku (type: string)— product SKUQuantity (type: int)— quantity (<= 0removes the item)UserId (type: long?)— entity-user placing this lineNotes (type: string?)— customisation notes
PreviewCartRequest
Fields used for preview and as base for convert:
EntityId (type: long)— customer entityUserId (type: long?)— user placing the orderPriceListId (type: long?)— price list to applyDocumentTypeGuid (type: string)— the document type the cart is for (e.g."SALES_ORDER","QUOTE"). Required by cart validation (422 CartValidationContextRequiredwhen missing or unknown); on convert it also determines the document type and initial statusPromoCodes (type: string[]?)— promo codes for this request only; see Promo codes
ConvertCartRequest
Extends PreviewCartRequest. Additional fields:
DocumentCategoryId (type: int?)— document category FKBillingAddressSnapshots (type: DocumentBillingAddressSnapshotModel?)— billing address snapshotShippingAddressSnapshots (type: DocumentShippingAddressSnapshotModel)— shipping address snapshotNotes (type: string?)— order notesPurchaseOrderRef (type: string?)— customer PO referenceAdditionalRef (type: string?)— secondary referenceTags (type: string?)— comma-separated tagsDescription (type: string?)— document descriptionActionNotes (type: string?)— internal action notesOther1 / Other2 / Other3 (type: string?)— configurable fieldsOtherNotes (type: string?)— additional notesIsBillingSameAsShipping (type: bool)— copy billing from shippingAcknowledgedValidationSignature (type: string?)— the currentcartValidation.signatureafter explicit customer acceptance; required once warning detection shipsRequireAllItemsConvertible (type: bool)— rejects excluded items with409 / 6051
DocumentAddressSnapshotModel
Used in both billing and shipping snapshot fields:
Name (type: string?)— address name / companyLine1 (type: string?)— street address line 1Line2 (type: string?)— street address line 2City (type: string?)— cityStateRegion (type: string?)— state or regionPostalCode (type: string?)— postal/ZIP codeCountryCode (type: string?)— ISO country codePhone (type: string?)— phone numberEmail (type: string?)— email
Promo codes
- The cart stores no codes and there is no apply/remove-code endpoint. Send the full set of codes on every preview and convert; the frontend owns the "applied codes" state.
- Codes are trimmed, blanks are dropped and duplicates are removed case-insensitively (the first
occurrence wins). More than 10 codes, or a code longer than 100 characters, is a
400. - One bucket applies at every level: product, category and brand discounts on lines, and subtotal, shipping and order-total discounts. A code-gated discount becomes eligible when its code is in the bucket and is then validated as usual. One code may unlock several discounts.
- Code-unlocked and automatic discounts compete: the best value wins (the highest single
amount, or the cumulative set when larger). A valid code that loses reports
NotApplied. - Bad codes never fail pricing; they are reported per code as
Applied,NotAppliedorInvalid(DocumentModel.PromoCodes), with no further reason. - When the request has no entity, codes are not evaluated and every submitted code is
Invalid. - Each code that is not
Appliedadds a cart warning:PromoCodeNotApplied(Promo code {CODE} has not been applied.) orPromoCodeInvalid(Promo code {CODE} is not valid.), where{CODE}is the trimmed upper-case code. These are part of the validation signature, so convert needs the signature acknowledged. - Documents derived from a converted cart (quote → order, order → invoice, sub-orders) keep the origin's subtotal and order-total discounts on recompute, and lines added later are priced with the origin's codes, validated live. Purchase orders inherit nothing.
Notes
- All cart endpoints are rate-limited via the
AnonCartpolicy - Cart
Idis hidden from API responses; useGuidfor all client-side operations POST /api/v1/CartwithUserGuidrequires a valid JWT — the controller returns401ifUserGuidis provided but no token is present- Convert and preview use the same price resolution logic — preview is a dry run of convert
- Address snapshots are point-in-time copies; they do not link back to a live address record
- All data is tenant-scoped
- Internal errors are logged but not exposed to API consumers