Skip to content

Cart AJAX API

Themes interact with the cart over a JSON API on the storefront’s own domain. The endpoints are Shopify-Ajax compatible, so cart code written for Shopify themes generally ports across. All requests are same-origin, use Content-Type: application/json, and return the updated cart object. Responses carry no-store cache headers, so the browser never serves a stale cart.

EndpointMethodPurpose
/cart.js (or /cart.json)GETRead the current cart
/cart/add.jsPOSTAdd one or more items
/cart/change.jsPOSTSet the quantity of a single line
/cart/update.jsPOSTBulk-update line quantities and the note
/cart/clear.jsPOSTEmpty the cart
/api/cart/buyNowCheckoutPOSTExpress single-product checkout

Each /cart/* endpoint also works without the .js suffix (/cart/add, /cart/change, and so on). The current cart is always resolved from a signed cart cookie — never send a cartId from the browser, or the write is rejected with a cartId forbidden validation error.

CSRF: required on every write

Every POST to a cart endpoint is protected against cross-site request forgery. A write that omits the token is rejected with HTTP 403 before it reaches the cart, so a theme that ignores this will see writes silently fail.

On any storefront HTML page the platform sets a readable csrf_token cookie and exposes the same value in a <meta name="csrf-token"> tag in the document head. Read it and send it back on every POST as the x-csrf-token header:

function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content
// fallback: the cookie is not HttpOnly, so it is also readable directly
|| document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)?.[1]
|| '';
}
async function cartFetch(url, body) {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken(),
},
body: JSON.stringify(body),
});
}

If you ever need the token without a meta tag (for example in a script loaded before the head renders), GET /api/csrf returns it in the x-csrf-token response header.

Get the cart

const cart = await (await fetch('/cart.js')).json();
console.log(cart.item_count, cart.total_price_cents);

/cart.js and /cart.json are the same handler. An empty or never-created cart returns a valid empty cart object rather than an error.

Add to cart

id is the variant id (variantId and variant_id are accepted aliases):

  1. Build the body. Send a single id/quantity, or an items array to add several variants in one call.

  2. POST it with the CSRF header (see the cartFetch helper above).

  3. Re-render your cart UI from the returned cart object — it is the post-mutation state.

await cartFetch('/cart/add.js', {
id: variantId,
quantity: 1,
});

Multiple items at once:

await cartFetch('/cart/add.js', {
items: [
{ id: variantA, quantity: 2 },
{ id: variantB, quantity: 1 },
],
});

If you send neither id nor items, the call returns HTTP 400 with a validation error (see Error shape). Out-of-stock or unavailable-inventory additions also fail with a descriptive message — surface it near the add-to-cart button rather than in a generic alert.

Change one line

/cart/change.js targets a single line and sets its quantity. Quantity 0 removes the line. You can identify the line several ways:

// By 1-based position in cart.items
await cartFetch('/cart/change.js', { line: 1, quantity: 3 });
// By line id — accepts cartLineId, cart_line_id, or key
await cartFetch('/cart/change.js', { key: lineId, quantity: 0 });

Accepted line targets are cartLineId, cart_line_id, key, or line (the 1-based index). If none of them resolves to a real line, the call returns a cartLineId|cart_line_id|key|line required validation error.

Bulk update

/cart/update.js processes updates (a map or array of quantities) and an optional note. A quantity of 0 removes that line.

await cartFetch('/cart/update.js', {
// keyed by line id, or an array positionally matching cart.items
updates: { [lineId]: 2 },
note: 'Please gift wrap',
});

Clear the cart

/cart/clear.js removes every line and returns the emptied cart object:

const cart = await (await cartFetch('/cart/clear.js', {})).json();
console.log(cart.item_count); // 0

Buy Now (express checkout)

POST /api/cart/buyNowCheckout runs a single-product express checkout entirely server-side. It builds (or reuses) a one-line ephemeral cart in a separate signed buy-now cookie — your shopper’s main cart is never read or changed — then returns the checkout URL:

const res = await cartFetch('/api/cart/buyNowCheckout', {
item: { product: { variantId, quantity: 1 } },
// discountCode is optional
});
const { checkout_url } = await res.json(); // e.g. "/checkout/<id>"
window.location.assign(checkout_url);

The body requires item; CSRF is enforced exactly like the other POST routes. This is the flow behind a theme’s “Buy it now” button.

Bundled section rendering

To update the cart drawer, cart count, or any other section without a full page reload, ask the cart endpoint to render Liquid sections alongside the mutation. Pass section ids on /cart/add, /cart/change, /cart/update, /cart/clear, or /cart.js:

  • sections — a comma-separated list of section ids, or
  • section_ids — an array of section ids, or
  • section_id — a single id, and optionally
  • sections_url — a same-store path (beginning with /) whose template context the sections render in; defaults to the referring page.
await cartFetch('/cart/add.js?sections=cart-drawer,cart-icon-bubble', {
id: variantId,
quantity: 1,
});

The response adds a sections map of id → rendered HTML. Each section is wrapped in <div id="shopify-section-...">, so you can swap it straight into the DOM:

const data = await res.json();
for (const [id, html] of Object.entries(data.sections ?? {})) {
if (html == null) continue; // a section that failed to render comes back null
document.getElementById(`shopify-section-${id}`)?.replaceWith(
new DOMParser().parseFromString(html, 'text/html').body.firstElementChild,
);
}

A maximum of 5 sections render per request; extra ids are ignored. An invalid sections_url (not a same-store path) returns HTTP 400 with error: "invalid_sections_url".

Response object

A successful read or mutation returns the cart object. Money fields are integers in paise (1/100 of a rupee), matching the platform’s cents-everywhere money model. The key fields:

FieldMeaning
idThe cart id (server-side only; never echo it back on writes)
item_countTotal quantity across all lines
line_countNumber of distinct lines
itemsThe line items array (also available as lines)
noteThe order note, or absent if unset
attributesAlways {} — cart attributes are not persisted
requires_shippingWhether any line needs shipping
currencyThe store currency as an object — read currency.iso_code (e.g. INR)
items_subtotal_price_centsLine subtotal before cart discounts, in paise
total_discount_centsTotal cart discount, in paise
total_price_centsCart total, in paise
amount_due_centsAmount payable after gift cards / store credit

Mutation responses also include the cart again under a cart key and an issues array of platform-specific warnings (for example inventory caps), plus the optional sections map.

Error shape

Validation failures return HTTP 400 with a structured error instead of a cart. The fields member is an array of objects (field, rule, message):

{
"error": "validation_failed",
"fields": [
{ "field": "id|item|items", "rule": "required",
"message": "A variant id or CartItemInput is required." }
]
}

Other error classes you should handle:

StatuserrorWhen
400validation_failedMissing/invalid body (see fields)
400invalid_sections_urlsections_url is not a same-store path
401customer_requiredA logged-in customer is required for the operation
403csrf_missing / csrf_mismatch / csrf_invalid / csrf_origin_blockedCSRF token, cookie signature, or origin failed
502cart_service_unavailableThe cart backend could not be reached

Inventory problems — such as adding more than is in stock — come back as a descriptive message. Show it inline near the control the shopper used, not as a page-level alert.

Next steps