Shopify Storefront API Shipping Rates: Before Checkout

The Storefront API will give you Shopify shipping rates on the cart, before the buyer ever reaches checkout. Add a delivery address to the cart with cartDeliveryAddressesAdd, mark it selected, then query cart.deliveryGroups: each group lists deliveryOptions with a handle, a title and an estimatedCost. Pick one with cartSelectedDeliveryOptionsUpdate. Everything below was written against Storefront API version 2026-07.

Two arguments on the address input trip people up, and carrier-calculated rates need a differently shaped query or they will hold up the whole cart response. Those are the parts worth reading slowly.

Why does deliveryGroups come back empty?

Because the cart has no destination. A delivery group is Shopify’s bundle of “these lines, going to this address, by one of these methods”, and with no address there is nothing to rate. A freshly created cart, or one that only has buyerIdentity.countryCode set, returns an empty deliveryGroups connection and people assume the field is broken.

The older way to supply a destination was buyerIdentity.deliveryAddressPreferences. In the 2026-07 CartBuyerIdentity reference that field is marked deprecated. The current model is cart.delivery.addresses, a list of up to 20 CartSelectableAddress entries, exactly one of which can be selected. You write to it with cartDeliveryAddressesAdd, cartDeliveryAddressesUpdate, cartDeliveryAddressesRemove or cartDeliveryAddressesReplace, or you pass delivery.addresses straight into cartCreate.

Keep buyerIdentity.countryCode anyway. The cost object uses buyer identity for international pricing, so the subtotal and the delivery address should agree on which country the buyer is in. Our post on @inContext and local prices covers that side.

How do I add the address to the cart?

One mutation. For a guest typing a postcode into an “estimate shipping” box, this is all of it:

mutation AddEstimateAddress($cartId: ID!, $address: CartDeliveryAddressInput!) {
  cartDeliveryAddressesAdd(
    cartId: $cartId
    addresses: [
      {
        address: { deliveryAddress: $address }
        selected: true
        oneTimeUse: true
        validationStrategy: COUNTRY_CODE_ONLY
      }
    ]
  ) {
    cart {
      id
      delivery {
        addresses {
          id
          selected
          oneTimeUse
        }
      }
    }
    userErrors {
      code
      field
      message
    }
    warnings {
      code
      message
    }
  }
}

With variables like { "address": { "countryCode": "GB", "zip": "ST5 1AA" } }.

Three of those input fields matter more than they look. selected: true is what makes Shopify rate this address; an unselected entry is a saved option for the buyer to choose later, and it produces no groups. oneTimeUse: true means the address is not attached to the buyer after checkout, which is what you want for a postcode someone typed into an estimator and may have got wrong. And validationStrategy defaults to COUNTRY_CODE_ONLY, so a partial address is accepted and rated. STRICT validates every field against Shopify’s checkout rules and rejects the address through userErrors instead. Use STRICT when the buyer is entering a real delivery address you intend to carry into checkout; use the default for an estimate.

CartAddressInput takes exactly one of deliveryAddress or copyFromCustomerAddressId. For a logged-in customer with saved addresses, pass the customer address ID and skip the form.

The cartDeliveryAddressesAdd reference and the CartSelectableAddressInput page have the full field list.

What does each delivery group contain?

Once an address is selected, the groups fill in. Query them like this:

query CartDeliveryOptions($cartId: ID!) {
  cart(id: $cartId) {
    deliveryGroups(first: 10) {
      nodes {
        id
        groupType
        deliveryAddress {
          countryCodeV2
          zip
        }
        deliveryOptions {
          handle
          title
          code
          description
          deliveryMethodType
          estimatedCost {
            amount
            currencyCode
          }
        }
        selectedDeliveryOption {
          handle
        }
      }
    }
    cost {
      subtotalAmount {
        amount
        currencyCode
      }
      totalAmount {
        amount
        currencyCode
      }
    }
  }
}

A CartDeliveryGroup has an id, the deliveryAddress it is going to, its cartLines, a groupType (the merchandise type, which is how subscription lines end up in a separate group from one-off purchases), the list of deliveryOptions, and selectedDeliveryOption, which is null until something picks one.

Each CartDeliveryOption is one rate. handle is the identifier you will select by. title and description are the merchant’s strings from their shipping settings. estimatedCost is a MoneyV2. deliveryMethodType is an enum: SHIPPING, LOCAL, PICK_UP, PICKUP_POINT, RETAIL or NONE. If your drawer says “Shipping from GBP 3.95”, filter to SHIPPING first, because a free PICK_UP option will otherwise win the Math.min and the drawer will quote a price for a method the buyer did not ask about.

Expect more than one group when the cart mixes one-time and subscription lines, or when more than one address is selected across the lines. Render per group. A single “shipping” line under the subtotal is only accurate when there is a single group.

How do I get carrier-calculated rates without slowing the cart?

If the shop uses carrier-calculated shipping (live rates from Royal Mail, DHL, UPS and so on), the options above will not include those rates unless you ask. The deliveryGroups connection takes a withCarrierRates: Boolean argument, false by default, and Shopify’s @defer guide says plainly that setting it to true requires the @defer directive. Carrier lookups are slow, and the directive is how the rest of the cart response is kept off that critical path.

query CartWithCarrierRates($cartId: ID!) {
  cart(id: $cartId) {
    cost {
      subtotalAmount {
        amount
        currencyCode
      }
    }
    ... DeliveryGroups @defer
  }
}

fragment DeliveryGroups on Cart {
  deliveryGroups(first: 10, withCarrierRates: true) {
    nodes {
      id
      deliveryOptions {
        handle
        title
        deliveryMethodType
        estimatedCost {
          amount
          currencyCode
        }
      }
    }
  }
}

The response is no longer one JSON document. It comes back as multipart/mixed with a graphql boundary: a first part with the cart cost and "hasNext": true, then a second part whose incremental array carries the deferred fragment. A plain fetch(...).then(r => r.json()) will choke on it. Check that your GraphQL client handles incremental delivery before you ship this, and render the drawer with a “calculating rates” state so the first part has somewhere to land. withCarrierRates has been available since 2024-07, so this is not a preview feature, but it only earns its complexity when the merchant has carrier rates configured. For flat and weight-based rates the default query already returns everything.

How do I let the buyer pick a rate?

Each option has a handle and each group has an id. Send the pair:

mutation SelectDeliveryOption($cartId: ID!, $groupId: ID!, $handle: String!) {
  cartSelectedDeliveryOptionsUpdate(
    cartId: $cartId
    selectedDeliveryOptions: [
      { deliveryGroupId: $groupId, deliveryOptionHandle: $handle }
    ]
  ) {
    cart {
      deliveryGroups(first: 10) {
        nodes {
          id
          selectedDeliveryOption {
            handle
            title
            estimatedCost {
              amount
              currencyCode
            }
          }
        }
      }
    }
    userErrors {
      code
      field
      message
    }
  }
}

The cartSelectedDeliveryOptionsUpdate reference takes an array because a multi-group cart needs one selection per group. After it runs, selectedDeliveryOption on the group reflects the choice. Test on a development store that your checkout opens with that method preselected. That is the behaviour the mutation exists for, but see it with the merchant’s own shipping profiles before you promise it in a design review.

What changes the rates between the cart and checkout?

The field is called estimatedCost for a reason. The rate in the drawer can differ from the rate in checkout in four ordinary ways. Delivery customization Functions run in checkout and can hide, rename or reorder options; we wrote up how those Functions work. A free-shipping discount code changes what the buyer pays for the option they selected. The merchant’s shipping profiles decide which rates apply to which lines, and a fuller address can change that answer. And a COUNTRY_CODE_ONLY address with a mistyped postcode can be rated on the cart and then rejected at checkout.

None of those make the cart estimate useless. They do mean the UI copy should say “estimated”, and the number should refresh whenever the buyer edits the address or the lines.

What I would actually do

For a guest estimator in a cart drawer: a country select and a postcode field, cartDeliveryAddressesAdd with selected: true, oneTimeUse: true and the default validation, then the deliveryGroups query filtered to SHIPPING. Two requests, and no change to the cart create and mutate flow you already have. Switch to STRICT and drop oneTimeUse only when the address is going to be the real delivery address.

For logged-in customers with saved addresses, copyFromCustomerAddressId and skip the form.

Add withCarrierRates and @defer only if the shop has carrier-calculated shipping enabled and your client parses multipart responses. Otherwise it is complexity that returns the same rates as the simple query.

And if the merchant’s rates are flat with a free-shipping threshold, do not build any of this. A sentence that says “Free UK delivery over GBP 50” next to the subtotal is a better estimator than two round trips, and it cannot disagree with checkout.

We build headless Shopify storefronts and the cart logic that goes with them, including the delivery and pricing edges that only show up once a shop has real shipping profiles and real customers. If your cart drawer needs to tell buyers what delivery will cost before checkout, our Shopify development work covers it.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch