← Back to blog

Magento 2 GraphQL Checkout: Cart to Order Mutations

Rodrigo Biazzi

Every headless Magento storefront and every mobile app built on Magento goes through the same sequence of GraphQL mutations to turn a cart into an order. The sequence is not long, but the order of the calls, the IDs each one needs, and the way errors come back are easy to get wrong. This article walks through the full guest and customer flow in Magento 2.4.8, with the queries I use as a starting point and the mistakes I see most often.

The flow at a glance

  1. Get a cart ID: create a guest cart or read the customer's cart.
  2. Add products.
  3. Set the guest email (guests only).
  4. Set the shipping address and read the available shipping methods.
  5. Set the billing address.
  6. Set the shipping method.
  7. Read the available payment methods and set one.
  8. Place the order.

Each step after the first takes the cart ID. For a logged in customer every request also carries the customer token in an Authorization: Bearer header. How to get that token is covered in GraphQL customer authentication.

Step 1: get a cart

For guests, Magento 2.4.7 introduced createGuestCart, which returns a cart object. The older createEmptyCart still exists but is deprecated in favor of it.

mutation {
  createGuestCart {
    cart { id }
  }
}

For a logged in customer, do not create a cart. Read the active one, which Magento creates if needed.

query {
  customerCart { id total_quantity }
}

Store the cart ID on the client. If a guest logs in with items in a guest cart, call mergeCarts with the guest cart as the source and the customer cart as the destination, then use the customer cart ID from then on.

mutation Merge($guest: String!, $customer: String!) {
  mergeCarts(source_cart_id: $guest, destination_cart_id: $customer) {
    id
    total_quantity
  }
}

Step 2: add products

addProductsToCart handles all product types with one input shape. A simple product needs only the SKU and quantity. Configurable products use the parent SKU plus the uid values of the selected options, which you get from configurable_options on the product query.

mutation Add($cartId: String!) {
  addProductsToCart(
    cartId: $cartId
    cartItems: [
      { sku: "24-MB01", quantity: 1 }
      { sku: "MH01", quantity: 1, selected_options: ["Y29uZmlndXJhYmxlLzkzLzUz", "Y29uZmlndXJhYmxlLzE0NC8xNzE="] }
    ]
  ) {
    cart {
      total_quantity
      itemsV2 { items { uid quantity product { sku name } } }
    }
    user_errors { code message }
  }
}

The option uid values above are only examples. Read the real ones from your catalog.

Notice user_errors. This mutation does not throw a GraphQL error when one product cannot be added (out of stock, invalid option, quantity not allowed). It adds what it can and reports the rest in user_errors. If your frontend only checks the top level errors array, customers will think an item was added when it was not.

To change quantities use updateCartItems with the item uid. To remove an item use removeItemFromCart. Coupons go through applyCouponToCart and removeCouponFromCart.

Step 3: guest email

Guests must have an email on the cart before the order can be placed. Set it early, ideally as soon as the customer types it. That also gives you an address for abandoned cart flows.

mutation Email($cartId: String!, $email: String!) {
  setGuestEmailOnCart(input: { cart_id: $cartId, email: $email }) {
    cart { email }
  }
}

Step 4: shipping address and methods

mutation Ship($cartId: String!) {
  setShippingAddressesOnCart(
    input: {
      cart_id: $cartId
      shipping_addresses: [
        {
          address: {
            firstname: "Ana"
            lastname: "Rossi"
            street: ["Via Roma 1"]
            city: "Milano"
            region: "MI"
            postcode: "20121"
            country_code: "IT"
            telephone: "0200000000"
            save_in_address_book: false
          }
        }
      ]
    }
  ) {
    cart {
      shipping_addresses {
        available_shipping_methods {
          carrier_code
          method_code
          carrier_title
          method_title
          amount { value currency }
          available
          error_message
        }
      }
    }
  }
}

For a logged in customer you can pass customer_address_id instead of a full address. Region handling depends on the country. Countries with a region list in Magento accept a region_id or a region code, others accept free text. Validation rules come from the store configuration, so a postcode or telephone that is required in the admin is required here too.

The response lists the shipping methods available for that address. Show only the ones with available: true, and show error_message when a carrier returns one, because carriers that fail silently are a common cause of "no shipping methods" support tickets.

Step 5: billing address

Most checkouts default to billing equal to shipping. Use same_as_shipping instead of sending the address twice.

mutation Bill($cartId: String!) {
  setBillingAddressOnCart(
    input: { cart_id: $cartId, billing_address: { same_as_shipping: true } }
  ) {
    cart { billing_address { firstname lastname city } }
  }
}

Step 6: shipping method

mutation Method($cartId: String!) {
  setShippingMethodsOnCart(
    input: {
      cart_id: $cartId
      shipping_methods: [{ carrier_code: "flatrate", method_code: "flatrate" }]
    }
  ) {
    cart {
      prices {
        grand_total { value currency }
      }
      available_payment_methods { code title }
    }
  }
}

Totals now include shipping. Requesting available_payment_methods in the same response saves a round trip, because payment availability can depend on the address, shipping method and total.

Step 7: payment method

mutation Pay($cartId: String!) {
  setPaymentMethodOnCart(
    input: { cart_id: $cartId, payment_method: { code: "checkmo" } }
  ) {
    cart { selected_payment_method { code title } }
  }
}

Offline methods need only the code. Online methods usually need extra data in the payment_method input, such as a token created by the provider's frontend SDK. Each payment module defines its own input fields in its schema.graphqls. Read that file, not the module's REST documentation, to see what GraphQL expects. If a payment module has no GraphQL support, you cannot use it in a headless checkout without writing that support. A custom method needs it from the start, as covered in building a custom payment method.

Step 8: place the order

mutation Place($cartId: String!) {
  placeOrder(input: { cart_id: $cartId }) {
    orderV2 { number }
    errors { code message }
  }
}

In 2.4.7 and later, placeOrder returns an errors list with error codes for known failures, and orderV2 replaced the older order field. Handle both the structured errors and top level GraphQL errors. After a successful order, the cart is no longer active. Clear the stored cart ID on the client, and for customers read customerCart again to get a fresh one.

Common mistakes

Reusing a cart after the order

The frontend keeps the old cart ID and the next add to cart fails with a cart not found or not active error. Always reset the ID after placeOrder succeeds, and handle the error by creating a new cart if it happens anyway.

Mixing guest and customer carts

Sending a customer token with a guest cart ID, or calling guest mutations for a customer cart, fails with authorization errors. Keep track of which kind of cart you hold, and merge on login.

Trusting totals on the client

Never compute totals in the frontend. Read prices from the cart after every change. Cart price rules, taxes and shipping all live on the server. If you add your own fee, implement it as a totals collector and expose it in GraphQL, as described in custom fee totals collector.

Caching cart queries

Cart queries and mutations must never be cached by the frontend or CDN. Send mutations as POST and mark cart reads as no store in your client.

Adding custom data to the cart

Custom checkout fields need a place in GraphQL. The usual pattern is a custom mutation that writes the value to the quote, plus a field on the Cart type that reads it back. Declare both in your module's schema.graphqls and implement resolvers that load the cart through Magento\QuoteGraphQl\Model\Cart\GetCartForUser, which checks that the cart belongs to the current user. Copy the value from quote to order on placement as you would for any checkout field.

FAQ

Should I use createEmptyCart or createGuestCart?

On 2.4.7 and later use createGuestCart. createEmptyCart still works but is deprecated.

Why does addProductsToCart succeed but the item is missing?

Item level problems are returned in user_errors, not in the top level errors array. Check user_errors after every add.

How do I pay with a card through GraphQL?

The payment provider's frontend SDK creates a token, and you pass it in the payment_method input of setPaymentMethodOnCart using fields the payment module defines in its schema.graphqls.

What happens to the cart after placeOrder?

It becomes inactive. Clear the cart ID on the client, and for logged in customers read customerCart again to get a new active cart.

I am available for freelance Magento and Adobe Commerce work, including headless checkouts. If you need help with this, get in touch.

Related articles