Ajax
CartThrob form actions can return JSON instead of redirecting. Use that to save customer info, apply a coupon, or update the cart, then redraw totals without reloading the page.
This is for cart and customer-info forms
(save_customer_info_form, update_cart_form, add_coupon_form).
Checkout payment — quotes, checkout_quote_id, Stripe or PayPal
prepare, custom_js="yes" — is a different contract. See
Custom checkout JavaScript.
The current pattern is the CartThrob 10 live coupon checkout: native fetch,
one named form, FormData, and
X-Requested-With: XMLHttpRequest. No jQuery.form.js. Bind only the
form you mean to submit.
How CartThrob returns JSON
Send the form as an AJAX POST. ExpressionEngine treats a request as
AJAX when it includes the X-Requested-With: XMLHttpRequest header.
On that path CartThrob does not redirect: it prints the flashdata as
JSON, including formatted cart amounts when the action saved the cart.
Typical success keys:
successcart_subtotalcart_discountcart_shippingcart_taxcart_totaltotal_itemstotal_unique_items
Those are cart amounts. They are not {checkout_total}. If this
checkout uses price=, no_tax=, or other checkout-level overrides,
print {checkout_*} in the template and let checkout finalize the
quote before payment. See
Checkout Amounts and Payment Review.
add_coupon_form also accepts json="yes". That is the parameter used
on the CartThrob 10 coupon demo. save_customer_info_form and
update_cart_form do not take that parameter; the AJAX header is
enough.
CSRF: leave secure forms on
Do not turn off ExpressionEngine’s “Process form data in Secure Mode” setting to make AJAX work.
CartThrob forms already include a csrf_token field. FormData posts
it. Front-end AJAX posts to CartThrob actions are not run through
ExpressionEngine’s CSRF check (EE skips that check for non-CP AJAX
unless the action class opts into strict mode). CartThrob does not.
A later full-page submit — checkout_form Place order — still
needs csrf_token on the form.
If the JSON includes CSRF_TOKEN, copying it onto csrf_token fields
is safe. It is not what makes the AJAX post succeed. The CartThrob 10 live
coupon template does not copy it.
Example: save location and refresh cart totals
Bind this form only. Do not attach a change handler to every input
on the page; that would also submit checkout_form.
<p>Subtotal: <span class="js-cart-subtotal">{exp:cartthrob:cart_subtotal}</span></p>
<p>Shipping: <span class="js-cart-shipping">{exp:cartthrob:cart_shipping}</span></p>
<p>Tax: <span class="js-cart-tax">{exp:cartthrob:cart_tax}</span></p>
<p>Total: <strong class="js-cart-total">{exp:cartthrob:cart_total}</strong></p>
{exp:cartthrob:save_customer_info_form
id="save-customer-form"
return=""
error_handling="inline"
}
<label for="zip">Zip</label>
<input id="zip" type="text" name="zip" value="{customer_zip}">
<label for="state">State</label>
<input id="state" type="text" name="state" value="{customer_state}">
<button type="submit">Update totals</button>
<p id="save-customer-message" role="status" aria-live="polite"></p>
{/exp:cartthrob:save_customer_info_form}
<script>
(() => {
const form = document.querySelector('#save-customer-form');
const message = document.querySelector('#save-customer-message');
if (!form || !message || !window.fetch || !window.FormData) {
return;
}
const submitButton = form.querySelector('[type="submit"]');
const updateText = (selector, value) => {
if (typeof value === 'undefined' || value === null) {
return;
}
document.querySelectorAll(selector).forEach((element) => {
element.textContent = value;
});
};
const flattenErrors = (value) => {
if (Array.isArray(value)) {
return value.flatMap(flattenErrors);
}
if (value && typeof value === 'object') {
return Object.values(value).flatMap(flattenErrors);
}
return value ? [String(value)] : [];
};
form.addEventListener('submit', async (event) => {
event.preventDefault();
message.textContent = 'Updating…';
submitButton.disabled = true;
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
if (!response.ok || !data.success) {
const errors = flattenErrors(data.errors);
throw new Error(errors.join(' ') || 'The cart could not be updated.');
}
updateText('.js-cart-subtotal', data.cart_subtotal);
updateText('.js-cart-discount', data.cart_discount);
updateText('.js-cart-shipping', data.cart_shipping);
updateText('.js-cart-tax', data.cart_tax);
updateText('.js-cart-total', data.cart_total);
message.textContent = 'Totals updated.';
} catch (error) {
message.textContent = error.message || 'The cart could not be updated.';
} finally {
submitButton.disabled = false;
}
});
})();
</script>
Coupons
Applying a coupon without reloading checkout uses the same fetch
pattern on a separate add_coupon_form (json="yes"). Do not nest
that form inside checkout_form. Keep id="checkout_form" on the
payment form.
The full example — including the CartThrob 10 live coupon checkout that redraws the summary from JSON, then still runs the checkout quote on Place order — is on Discounts.
Same-page gateway field replacement is a different job (HTML, not cart JSON). See Allow the customer to select a gateway and gateway_fields_url.
Live-rate shipping forms can use this same fetch pattern to refresh
cart_shipping / cart_total. See
get_live_rates_form.
What not to do
- Do not submit
checkout_formwith this AJAX pattern. - Do not bind every
inputandselecton the page. - Do not disable ExpressionEngine secure forms.
- Do not treat
data.cart_totalas the gateway charge when the checkout form has amount overrides.