The Shopify Storefront API cart is a server-held basket: you create it with cartCreate, change it with cartLinesAdd, cartLinesUpdate and cartLinesRemove, and hand the buyer to Shopify’s hosted checkout through the cart’s checkoutUrl field. Keep the cart ID the API returns, send every mutation to a versioned GraphQL endpoint with an access token, and read userErrors on every response.
That is the whole model. The old Checkout API (checkoutCreate and its siblings) was deprecated in version 2024-04 and switched off in 2025-04, so any tutorial still using checkout.webUrl is describing something that no longer runs. Everything below targets API version 2026-07, the current stable release as of September 2026.
Which endpoint and token does the cart need?
Every request is a POST to https://{store}.myshopify.com/api/2026-07/graphql.json. The version sits in the path, Shopify ships a new one each quarter, and each is supported for at least twelve months. Ask for a version that has been retired and Shopify “falls forward” to the oldest one it still serves, then tells you in the X-Shopify-API-Version response header. Assert on that header in a smoke test. A silent fall-forward is how a storefront ends up on a schema nobody chose.
Authentication comes in three shapes. A public access token travels in the X-Shopify-Storefront-Access-Token header and is designed to ship in browser code. A private token travels in Shopify-Storefront-Private-Token, stays on your server, and should be paired with a Shopify-Storefront-Buyer-IP header carrying the real buyer’s address so Shopify’s bot protection can tell your users apart. Leave that header out and the Storefront API reference warns you may be throttled. Both tokens come from the Headless channel in the Shopify admin. There is also tokenless access, which covers products, collections, search and cart reads and writes with no token at all, capped at a query complexity of 1,000; overshoot and you get MAX_COMPLEXITY_EXCEEDED.
Real buyer traffic has no fixed requests-per-minute quota. Automated traffic does, and requests Shopify judges abusive get a 430 Shopify Security Rejection.
How do you create a cart and add lines?
cartCreate takes a CartInput and returns the cart, a userErrors array and a warnings array. The first lines can go in the same call, and buyerIdentity.countryCode should go in too, because it drives which market’s prices the cart uses.
const endpoint = `https://${process.env.SHOP}.myshopify.com/api/2026-07/graphql.json`;
type UserError = { code: string | null; field: string[] | null; message: string };
type Cart = { id: string; checkoutUrl: string; totalQuantity: number };
async function storefront<T>(
query: string,
variables: Record<string, unknown>,
buyerIp: string,
): Promise<T> {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Shopify-Storefront-Private-Token': process.env.STOREFRONT_PRIVATE_TOKEN!,
'Shopify-Storefront-Buyer-IP': buyerIp,
},
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors) throw new Error(JSON.stringify(json.errors));
return json.data as T;
}
const CART_FIELDS = `
id
checkoutUrl
totalQuantity
cost { subtotalAmount { amount currencyCode } totalAmount { amount currencyCode } }
lines(first: 50) {
nodes {
id
quantity
merchandise { ... on ProductVariant { id title product { title } } }
cost { totalAmount { amount currencyCode } }
}
}
`;
const CART_CREATE = `
mutation CartCreate($input: CartInput!) {
cartCreate(input: $input) {
cart { ${CART_FIELDS} }
userErrors { code field message }
warnings { code message target }
}
}
`;
export async function createCart(variantId: string, quantity: number, buyerIp: string) {
const data = await storefront<{ cartCreate: { cart: Cart | null; userErrors: UserError[] } }>(
CART_CREATE,
{ input: { lines: [{ merchandiseId: variantId, quantity }], buyerIdentity: { countryCode: 'GB' } } },
buyerIp,
);
if (data.cartCreate.userErrors.length > 0) {
throw new Error(data.cartCreate.userErrors.map((e) => `${e.code}: ${e.message}`).join('; '));
}
return data.cartCreate.cart!;
}
merchandiseId is a product variant GID (gid://shopify/ProductVariant/...), not a product. quantity defaults to 1. A CartLineInput also accepts attributes for line-level key-value pairs, sellingPlanId for subscriptions, and parent for bundle components.
The ID you get back deserves respect. It has the form gid://shopify/Cart/<token>?key=<secret>, and the cart management guide is blunt about the second half: never expose the secret, treat it like a password. Always send the full ID back, and keep it in an HttpOnly cookie or a server session rather than in a query string or a log line.
Adding to an existing cart is cartLinesAdd(cartId, lines), up to 250 lines per call. Changing a quantity is cartLinesUpdate with lines: [{ id, quantity }], where id is the cart line ID from the previous response, not the variant. Removing is cartLinesRemove(cartId, lineIds). All three return the same cart, userErrors, warnings shape, so one response handler covers the lot.
What should you do with userErrors and warnings?
There are two error channels and they mean different things. Top-level GraphQL errors means the request itself was rejected: a malformed query, a complexity limit, a bad token. userErrors inside the payload means Shopify understood you and declined. Each CartUserError carries a code from the CartErrorCode enum, the field path that caused it, and a message.
The codes you will actually meet: INVALID_MERCHANDISE_LINE when a line ID is no longer in the cart, usually because a stale tab sent an old ID; MINIMUM_NOT_MET, MAXIMUM_EXCEEDED and INVALID_INCREMENT for quantity rules set on the product; VARIANT_REQUIRES_SELLING_PLAN when a subscription-only variant arrives without one; CART_TOO_LARGE; and SERVICE_UNAVAILABLE, which is the one worth retrying.
Stock is not an error. Add more units than exist and the mutation succeeds, then reports MERCHANDISE_NOT_ENOUGH_STOCK or MERCHANDISE_OUT_OF_STOCK in warnings with a target pointing at the line. If your UI only checks userErrors, the basket will quietly hold a smaller quantity than the buyer asked for. Render warnings.
How do buyer identity and discount codes attach?
cartBuyerIdentityUpdate takes a CartBuyerIdentityInput with email, phone, countryCode, customerAccessToken, companyLocationId for B2B, and preferences. Preferences prefill checkout fields (delivery method, pickup location) and the docs note they are not synced back to the cart if the buyer overwrites them at checkout, so do not treat them as state.
The customerAccessToken is the one that matters for logged-in shops. Set it before you read checkoutUrl and the buyer lands in an authenticated checkout with their saved details.
Discount codes go through cartDiscountCodesUpdate(cartId, discountCodes). It replaces the whole list, so removing one code means resending the others, and an empty array clears them all. A code that does not apply is not a userError; the cart’s discountCodes field returns each one with applicable: false, and a warnings entry such as DISCOUNT_NOT_FOUND or DISCOUNT_PURCHASE_NOT_IN_RANGE says why. If the shop runs custom discount logic, our post on migrating to the Discount Function API covers the other side of that relationship.
When do you send the buyer to checkoutUrl?
Read checkoutUrl from the cart and redirect to it. Never assemble the URL from the cart ID yourself. The URL carries the cart’s key parameter, Shopify validates and strips it as the buyer enters checkout, and a checkout opened without a valid key gets a cloned cart with the buyer’s information removed. If you append your own query parameters for analytics, make sure you are not clobbering the one Shopify put there.
Two lifecycle rules shape the cookie logic. Carts expire within 30 days of creation, and Shopify deletes the cart when the buyer completes checkout. So a stored cart ID can point at nothing, and that is normal. On page load, query cart(id: $id); if it comes back null, drop the cookie and create a fresh cart on the next add.
What would I actually do?
For anything with a server, which is most Astro, Next.js, Remix and SvelteKit storefronts, keep the private token in route handlers, forward the buyer’s IP, and store the cart ID in an HttpOnly cookie. Re-fetch the cart on every page that shows it; a null cart means start again, not error. Pin the API version in one constant, and fail the deploy if X-Shopify-API-Version disagrees with it.
Use a public token in the browser only when there is no server at all, such as a static site with client-side add-to-basket. It is safe to expose by design, but you lose the buyer IP header and the bot protection that comes with it.
I would not hand-roll any of this on Hydrogen. Its cart handler already wraps every mutation above; the trade-offs between that and a Next.js build are in our Hydrogen versus Next.js comparison. And if the shop needs a buy button on a brochure site rather than a full headless storefront, a Liquid theme is still less code than a cart integration, which is usually the first thing we say in a Shopify development scoping call.