Cart Errors
Goal
Turn the errors a cart response carries into messages a customer can act on. The important part is that these are not failed requests: a cart write returns 200, the recalculated cart, and a list of things that did not go the way the customer asked.
Shopware Flow
Cart errors have no operation of their own. They arrive as the errors field on the response of readCart get /checkout/cart and every line item write. A stock limit, a blocked shipping method, an invalid promotion code — all of them come back with a success status.
useCart therefore treats them as a second output. Every write merges the errors map into a shared swCartErrors value separate from the cart itself. That separation is what lets a notification layer consume them once, somewhere else on the page, without the component that issued the request having to know about them.
Step 1
Store API: Succeed and complain
A cart write returns 200 with the recalculated cart and an errors field. The status code says the request worked, not that the customer got what they asked for.
- Code
const cart = await addProduct({ id, quantity: 99 })- State
- sw-context-token
- Types
- addLineItem response
Read the diagram from left to right:
- A cart write returns
200with the recalculated cart and anerrorsfield. useCartmerges that field intoswCartErrorswithObject.assign.getErrorsCodes()orcodeErrorsNotification()consumes the map, clearing it in the process.resolveCartError(error)produces amessageKeyand aparamsobject for one entry.- Your i18n layer translates
errors.<messageKey>with those params. - The UI reads the notifications from
useNotificationsinstead of keeping its own copy.
You do not need to check the HTTP status to find these. A rejected request is a different thing entirely: an HTTP error throws an ApiClientError, while timeouts and other transport failures can throw other error types; none reaches swCartErrors.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Read the cart | refreshCart() | GET /checkout/cart | readCart response |
| Add and collect errors | addProduct({ id, quantity }) | POST /checkout/cart/line-item | addLineItem response |
| Apply a promotion code | addPromotionCode(code) | POST /checkout/cart/line-item | addLineItem response |
| Update and collect errors | changeProductQuantity(params) | PATCH /checkout/cart/line-item | updateLineItem response |
| Remove and collect errors | removeItemById(id) | POST /checkout/cart/line-item/delete | removeLineItem response |
| Read the errors field | cart.errors | any of the above | Cart |
| Consume the collected map | getErrorsCodes() | none | CartError |
Every row in the Store API column is a cart operation you already call for another reason. There is no request in this recipe that exists only for errors. A promotion code is not a special endpoint either — addPromotionCode posts to the line item route with type: "promotion", which is why promotion feedback arrives as a cart error rather than as a response of its own.
Composables
Pick by what you are holding — the collected map, one error, or the notification list:
| Composable | Scope | Reach for it when |
|---|---|---|
useCart | the whole cart | writing line items, and reading the cart the resolver looks into |
useCartNotification | the collected errors | consuming everything one write complained about |
useCartErrorParamsResolver | one CartError | turning that error into a translatable key and params |
useNotifications | the toast list | rendering the result, or pushing anything else the customer sees |
useCartNotification is the one this recipe is really about. It has exactly two members, and they are alternatives, not a pipeline:
- Consume and render —
codeErrorsNotification()consumes the map and pushes every entry itself, usingpushSuccessforpromotion-discount-addedandpushErrorfor everything else. - Consume and return —
getErrorsCodes()consumes the map and returnsSchemas["CartError"][]with those same success codes dropped, leaving the rendering to you.
It borrows exactly three members of its own: consumeCartErrors from useCart, and pushError and pushSuccess from useNotifications. The wider surface this recipe touches lives elsewhere — addProduct, addPromotionCode, changeProductQuantity, removeItemById, refreshCart, cart, cartItems, count and appliedPromotionCodes on useCart; resolveCartError on useCartErrorParamsResolver; the remaining push* helpers and the notifications list on useNotifications.
Six things the generated reference will not tell you:
codeErrorsNotification()pusheserror.message— the raw, untranslated string from the backend. It never touchesresolveCartErroror your i18n layer. If your storefront is localised, this is the wrong consumer: usegetErrorsCodes()and translate themessageKeyyourself.codeErrorsNotification()also ignoreslevel. A level0notice and a level20error both becomepushError, withpromotion-discount-addedas the single exception.- Both methods call
consumeCartErrors(), so the first one you call clears the map for the other. Pick one per response. setCartErrors— the function that fills the map — is internal touseCartand not part of its public return. You cannot call it, and you cannot reset the map except by consuming it.setCartErrorsalso merges only when the response actually has errors. An error-free response leaves whatever was already collected in place, so the map is not a view of the last write.resolveCartError(error)callsuseCart()inside the resolver function rather than at composable setup, so it needs a cart context to be available at call time, not just at setup time.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type the errors field, one error, or lower-level API client calls:
import type { Schemas } from "#shopware";
type Cart = Schemas["Cart"];
type CartErrors = Cart["errors"];
type CartError = Schemas["CartError"];CartErrors is the type to read carefully. The schema declares it as anyOf a CartError[] and a map of error key to error object, and the two shapes differ: the map form requires a numeric code that the array form does not have. Everything in Shopware Frontends handles the map form only.
Schemas["CartError"]["level"] is an enum of 0 (notice), 10 (warning) and 20 (error), but that union is only on the array-form type. The map form declares level as a plain number, and the composables reach map values through a cast — so do not lean on the union for exhaustiveness. None of the three values means success either: a positive outcome is signalled by the messageKey, not by the level.
Minimal Vue Example
<script setup lang="ts">
import { ApiClientError } from "@shopware/api-client";
const { addProduct, appliedPromotionCodes, cartItems, count } = useCart();
const { getErrorsCodes } = useCartNotification();
const { resolveCartError } = useCartErrorParamsResolver();
const { pushError } = useNotifications();
const { t, te } = useI18n();
const isAdding = ref(false);
const writeError = ref("");
const consumeAndPushCartErrors = () => {
for (const error of getErrorsCodes()) {
const { messageKey, params } = resolveCartError(error);
const snippet = `errors.${messageKey}`;
pushError(
te(snippet) ? t(snippet, params ?? {}) : t("errors.message-default"),
);
}
};
const addToCart = async (productId: string, quantity: number) => {
if (isAdding.value) return;
writeError.value = "";
isAdding.value = true;
try {
await addProduct({ id: productId, quantity });
} catch (error) {
console.error(error);
writeError.value =
error instanceof ApiClientError
? t("errors.addToCartError")
: t("errors.message-default");
} finally {
consumeAndPushCartErrors();
isAdding.value = false;
}
};
</script>
<template>
<p v-if="writeError" role="alert">{{ writeError }}</p>
<p role="status">{{ count }} items in your cart</p>
<h2>Cart</h2>
<ul>
<li v-for="item in cartItems" :key="item.id">
{{ item.label }} — quantity {{ item.quantity }}
</li>
</ul>
<template v-if="appliedPromotionCodes.length">
<h2>Applied promotions</h2>
<ul>
<li v-for="promotion in appliedPromotionCodes" :key="promotion.id">
{{ promotion.label }}
</li>
</ul>
</template>
<button
type="button"
:aria-disabled="isAdding"
:aria-busy="isAdding"
@click="addToCart('a-product-id', 99)"
>
{{ isAdding ? "Adding…" : "Add 99 to the cart" }}
</button>
</template>The example consumes the shared map and never reads cart.errors off the write response. Both are views of the same data, so pick one: the response is per-request, the shared map is cumulative across writes and is what useCartNotification reads.
getErrorsCodes() consumes and clears that map, so exactly one place in your app may call it per write — a second consumer for the same response gets nothing. And because the map is cumulative, what it hands back is not necessarily this write's doing: an entry collected by the startup refreshCart(), or by an earlier write nobody consumed, arrives here too. If your app collects errors before the customer reaches this component, consume them there as well rather than letting them surface against an unrelated add.
The consume sits in finally, not in the try, for two reasons. A rejected write contributes no errors of its own — it never reaches setCartErrors — but it must not strand entries an earlier write left behind. And keeping it out of the try means a failure while rendering a message is never reported to the customer as a failed add.
The catch binds the error, logs it, and narrows on ApiClientError, because a rejected request and a bug in your own code are not the same event and should not reach the customer as the same message. A bare catch {} makes that distinction impossible and loses the stack with it. te() guards the snippet lookup: resolveCartError forwards every backend messageKey unchanged through its catch-all branch, and without the guard an unmapped key renders the literal string errors.<messageKey> into the customer's notification.
aria-disabled rather than disabled on the button: a disabled control cannot hold focus, so a keyboard customer pressing it would be thrown back to the top of the document mid-interaction. aria-disabled does not block activation, which is why the if (isAdding.value) return; guard at the top of the handler is load-bearing rather than decorative.
Finally, the example confirms an applied promotion from appliedPromotionCodes rather than from the errors map, because getErrorsCodes() drops promotion-discount-added on purpose. The line item is the durable fact; the error entry is a one-off notice you may never see.
State And Session
swCartErrors is a shared context value alongside swCart, provided through useContext's provide/inject. createSharedComposable dedupes useCart on top of that, but only on the client — on the server it is a passthrough, so SSR sharing rests on the provide chain alone. Either way the value lives in the Vue app instance, not on the server session, so a full page reload starts from an empty map even though the cart behind the sw-context-token is unchanged.
Within one page life it accumulates. Every write merges that response's errors into whatever is already there with Object.assign, keyed by error key, so two writes in a row produce one combined map. The merge is skipped when a response has no errors, which means a clean write does not clear a stale entry from an earlier one — only consuming does.
consumeCartErrors() is destructive: it deep-clones the value through JSON.parse(JSON.stringify(...)), sets the shared value to null, and returns the clone. Both codeErrorsNotification() and getErrorsCodes() call it, so they cannot be used together for the same response — the second one gets nothing.
Errors are app state, not component state. A mini cart, a cart page and a checkout step all read the same map — and whichever one consumes first wins.
Edge Cases
- A cart error arrives with a
2xxstatus. Checkingresponse.okfinds none of them. - A timed out write has an unknown outcome.
isTimeoutErrorfrom@shopware/api-clientidentifies it, and the request may already have reached the API, so the line item may exist even though the customer saw an error. CallrefreshCart()before letting them retry, rather than repeating the write blind. consumeCartErrors()clears the map.codeErrorsNotification()andgetErrorsCodes()both consume, so calling both after one write shows the errors once and silently drops them for the second caller.- A response without errors does not reset the map. An entry collected by an earlier write survives until something consumes it, so a stale stock warning can surface after an unrelated successful write.
refreshCart(newCart)returns early when you pass a cart in, skipping error collection entirely. Only the argument-lessrefreshCart()issues the request and collects.codeErrorsNotification()ignoreslevelentirely, and pushes the backend'smessageverbatim.- The success list has exactly one entry:
promotion-discount-addedis pushed withpushSuccess. Every other key is an error. getErrorsCodes()filters that key out, so a successfully applied promotion is invisible to it. ReadappliedPromotionCodesinstead.- Both consumers bail out when
errorsis an array —codeErrorsNotification()returns nothing,getErrorsCodes()returns[]. That guard only fires on a raw response you read yourself: throughuseCarttheObject.assignmerge turns[error]into{ "0": error }first, so the map form is the only shape the composables ever see. resolveCartErrorhandles two keys specially and falls through toparams = { ...errorObject }for everything else, which yields onlymessageKey,key,messageandlevelon the type you are holding, pluscodeat runtime —getErrorsCodes()is declared to returnSchemas["CartError"], the array-form element, which has nocode. The snippeterrors.promotion-not-foundexpects a{promotionCode}placeholder that nothing in that object supplies, so it renders unfilled — the same trap applies to any snippet whose placeholder is not one of those names.- For
product-stock-reachedthe resolver strips the key prefix to get a product id, then looks that line item up in the cart. When the name orquantityInformation.maxPurchaseis falsy it switches the key toproduct-stock-reached-emptyand returnsnullparams — so the snippet you render is not always the one you expected. - For
shipping-method-blockedthe resolver readserrorObject.message, notkey, and strips ashipping-method-blocked-prefix from it. Whatever remains becomes the{name}param. pushErroronly writes intouseNotificationsstate; it renders nothing.useNotificationsuses plainprovide/inject, so a component whose ancestors never called it gets its own detached list and the notification reaches no one. Mount a notification outlet above the component, asvue-starter-templatedoes with<LayoutNotifications />in its layouts.- The
messageon an error is a backend string, not a customer-facing one. Translateerrors.<messageKey>and treatmessageas a last resort.
Common Mistakes
- Do not treat a
2xxcart response as an unqualified success. - Do not call both
codeErrorsNotification()andgetErrorsCodes()for the same write. - Do not reach for
codeErrorsNotification()in a localised storefront — it renders the untranslated backendmessage. - Do not render the raw
messagefrom a cart error. - Do not assume the map describes the last write. It also holds anything an earlier write left behind.
- Do not read
levelto decide whether something is good news.0is a notice, not a success. - Do not detect an applied promotion through the errors map. Use
appliedPromotionCodes. - Do not handle the array form of
errorsbehinduseCart. TheObject.assignmerge normalises it to a map first, so nothing the composables hand you is ever an array. - Do not translate a
messageKeywithout checking the snippet exists. An unmapped key renders as the literal stringerrors.<messageKey>in the customer's notification. - Do not expect
{name}and{quantity}placeholders to be filled for keys the resolver does not special-case. - Do not confuse a rejected request with a cart error. Catch
ApiClientErrorseparately, and do not assume it is the only thing a cart write can throw — a timeout is not anApiClientError. - Do not consume the errors in a component that may not be mounted. The map is cleared by whoever reads it first.
Testing Checklist
- Adding more than the available stock returns
2xxand produces aproduct-stock-reachedentry. - The stock error resolves to a message containing the product name and its maximum quantity.
- A stock error for a product that is not in the cart resolves to
product-stock-reached-emptywith no params. - Applying a valid promotion code with
addPromotionCodeadds a line item toappliedPromotionCodes, andgetErrorsCodes()returns nothing for it. - Applying an unknown promotion code produces
promotion-not-foundand renders as an error. - A second consumer after
getErrorsCodes()receives no errors for the same write. - Two writes in a row before any consumption produce one combined map.
- An error collected by one write is still present after a later error-free write, until it is consumed.
refreshCart(someCart)collects no errors, whilerefreshCart()does.- A rejected request shows a request-level error and adds nothing to the shared map.
- A timed out write is reported as a generic failure, not as
errors.addToCartError, and a followingrefreshCart()shows whether the line item was added anyway. - An
errorspayload in array form, handed to a consumer directly, is skipped without throwing — throughuseCartit is normalised to a map first, so that guard cannot be reached from a cart write. - An unmapped
messageKeyfalls back to a generic message instead of rendering the rawerrors.<messageKey>string.