08 Oct 2026 · 5 min read
Cart and checkout mutations end-to-end
Create a guest cart, resolve its masked quote id, and drive add-to-cart through place-order mutations without breaking checkout state
Part 5 of the API Platform series — from empty cart to placed order in mutations you can replay.
Why the cart flow deserves its own post
GraphQL reads are forgiving. Cart mutations are not. Every step mutates a quote, and the quote carries state: items, addresses, shipping method, payment method, totals. Call the mutations out of order and Magento answers with errors that look cryptic until you see the state machine underneath. This post walks the full guest flow end to end, in the order the quote expects.
The guest flow is the harder one, so we start there. Customer carts reuse the same mutations but skip the masked-id dance because the customer token already identifies the quote.
Step 1: create the empty cart
Everything starts with createEmptyCart, which returns a cart ID. For guests that ID is a masked quote id, a 32-character hash like abc123maskedquoteid456xyz789abcd that stands in for the real quote_id in the database:
mutation {
createEmptyCart
}
Store that value. Every subsequent guest cart mutation takes it as cart_id. If you lose it, you cannot recover the cart through the API. There is no lookup by email or session. Treat the masked id as the cart handle for the whole session and persist it in the frontend store or app state.
A common mistake is calling createEmptyCart twice, once per page load, and ending up with orphaned quotes. Create once, reuse until the order is placed or the cart is explicitly abandoned.
Step 2: add products to the cart
Adding a simple product uses addSimpleProductsToCart:
mutation AddToCart($cartId: String!) {
addSimpleProductsToCart(
input: {
cart_id: $cartId
cart_items: [{ data: { quantity: 2, sku: "MH01-M-Blue" } }]
}
) {
cart {
total_quantity
items {
quantity
product {
sku
name
}
}
}
}
}
Configurable, bundle, and downloadable products each have their own mutation (addConfigurableProductsToCart, addBundleProductsToCart, and so on). The split exists because each type needs different input shapes: a configurable needs the parent SKU plus the variant selection, a bundle needs option quantities. Check the schema for the exact input type before guessing.
After each add, read back cart.items and cart.prices. Do not trust the request payload as the source of truth. Inventory reservations, minimum quantities, and out-of-stock rules can change what actually landed in the quote.
To update quantities or remove lines, use updateCartItems and removeItemFromCart. Both take the cart item uid, not the SKU. That uid comes from the cart.items { uid } field, so query it explicitly.
Step 3: set the email and addresses
A guest cart has no customer, so Magento needs an email before it can estimate shipping or place the order. Use setGuestEmailOnCart:
mutation SetEmail($cartId: String!) {
setGuestEmailOnCart(input: { cart_id: $cartId, email: "[email protected]" }) {
cart {
email
}
}
}
Then set shipping and billing addresses with setShippingAddressesOnCart and setBillingAddressOnCart. The address input requires firstname, lastname, street, city, postcode, country_code, and telephone. Missing telephone is the most frequent validation failure in this step, because many storefront forms treat phone as optional while the API does not.
Setting the shipping address also selects the shipping method in the same call. Pass the carrier_code and method_code you got from available_shipping_methods. A typical value is flatrate plus flatrate. If you skip the method, later totals calls will show zero shipping and placeOrder will fail.
Step 4: select payment and review totals
Set the payment method with setPaymentMethodOnCart:
mutation SetPayment($cartId: String!) {
setPaymentMethodOnCart(
input: { cart_id: $cartId, payment_method: { code: "checkmo" } }
) {
cart {
selected_payment_method {
code
title
}
}
}
}
Payment methods with extra fields, such as Braintree or Adyen nonces, take additional additional_data input. Keep that payload construction in one helper so every payment integration follows the same path.
Before placing the order, query cart { prices { grand_total { value currency } } applied_coupons { code } } and render exactly that. The quote is the pricing authority. Anything computed in the frontend is a preview. If a promotion, coupon via applyCouponToCart, or shipping change altered the total, this is where the shopper sees it.
Step 5: place the order
The finale is one call:
mutation PlaceOrder($cartId: String!) {
placeOrder(input: { cart_id: $cartId }) {
order {
order_number
}
}
}
On success the masked cart id is consumed. Reusing it returns an error because the quote has converted into an order. Create a fresh cart for the next purchase.
Handle the failure modes deliberately. Payment declines surface as placeOrder errors with the gateway message inside. Inventory races surface when the reservation fails at placement time. In both cases the cart survives, so let the shopper fix payment or quantities and retry with the same cart_id. Do not create a new cart on failure or you will strand the corrected addresses and coupons.
Making the flow robust in production
Three practices separate demos from production integrations. First, make the sequence idempotent at the UI layer: disable the place-order button while the mutation is in flight, because double clicks produce double placeOrder calls and confusing duplicate-order scares. Second, log the masked quote id with every mutation on the backend-for-frontend layer so support can trace a shopper session to one quote. Third, test the unhappy paths with a script, not clicks: expired masked id, out-of-stock mid-checkout, coupon removed before placement, and payment decline each map to a distinct error shape your frontend must render.
For customer carts, replace the cart_id argument handling with the customer token in the Authorization header and use customerCart { id } to fetch the active quote. The mutation names stay the same, which means one checkout service can serve both flows with the cart handle as the only branch.
Pitfalls
- Reusing a consumed masked id. After
placeOrdersucceeds the cart is gone. Create a new one for the next order. - Forgetting the shipping method. An address without a method passes validation but fails at placement. Always set both together.
- Addressing items by SKU on update. Updates and removals need the cart item
uid. Query it after every add. - Trusting frontend totals. Promotions and shipping recalculation change the quote. Render the
pricesresponse, not local math. - Creating a cart per page view. One cart per session until order or abandonment. Extra carts become orphaned quotes that pollute the
quotetable.
Next in this series: magento2-rest-custom-endpoints