Order History
Goal
Build a paginated order history for a logged-in customer and a detail view for a single order. The important part is that both screens call the same readOrder post /order operation, and that what an order contains depends entirely on the associations you request, not on the generated Order type.
Shopware Flow
readOrder post /order is a criteria route secured with the access key and the context token. There is no customer id in the request: the Store API returns the orders of whoever the sw-context-token header resolves to.
useCustomerOrders().loadOrders() sends only your criteria plus limit from its own ref and "total-count-mode": "exact". It adds no associations of its own, so anything a row renders beyond the order's own columns — the state, the line items, the deliveries, the transactions — has to be asked for in the criteria you pass.
Hover a type chip to inspect fields generated from the current Store API schema.
Step 1
UI: Open the order history
The page passes a criteria to useCustomerOrders. Everything it wants to render on a row has to be requested here, because the composable adds no associations of its own.
- Code
loadOrders({ page, associations, sort })- State
- local page state
- Types
- readOrder body
Read the diagram from left to right:
- The customer opens the order history and the page calls
loadOrderswith a criteria. useCustomerOrdersoverwrites thelimitin that criteria with its ownlimitref and sets"total-count-mode": "exact".- The Store API returns one page of orders for the customer behind the context token.
useCustomerOrdersadds nocheckPromotionof its own, so unless your criteria passes one nopaymentChangeablemap comes back. - The composable keeps
orders.elements,orders.total, andorders.page, and exposesorders,currentPage, andtotalPages. - The customer opens one order and the detail view calls
useOrderDetails(orderId).loadOrderDetails(). loadOrderDetailscallsreadOrder post /orderagain withids, the default order associations, andcheckPromotion: true.- The UI reads
order,status,total, andpaymentChangeablefrom composables instead of keeping its own copy.
You do not assemble the detail criteria yourself. useOrderDetails calls useDefaultOrderAssociations(), which asks for stateMachineState, lineItems with cover and downloads.media, addresses, deliveries with shippingMethod, shippingOrderAddress and stateMachineState, and transactions with paymentMethod and stateMachineState. The associations you pass as the second argument are merged underneath that, so the defaults win on conflicting keys.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Load a page of orders | loadOrders(criteria) | POST /order | readOrder body |
| Change the page | changeCurrentPage(page) | POST /order | readOrder response |
| Load one order | loadOrderDetails() | POST /order | OrderRouteResponse |
| List selectable payments | getPaymentMethods() | POST /payment-method | readPaymentMethod response |
| Change the payment method | changePaymentMethod(paymentMethodId) | POST /order/payment | orderSetPayment body |
| Start the payment again | handlePayment(finishUrl, errorUrl) | POST /handle-payment | handlePaymentMethod body |
| Cancel the order | cancel() | POST /order/state/cancel | cancelOrder response |
Composables
Pick by scope — how much of the order history the composable is about:
| Composable | Scope | Reach for it when |
|---|---|---|
useUser | the customer session | deciding between the list and the signed-out state |
useCustomerOrders | the paginated order list | building the history page itself |
useOrderDetails | one order | building the detail view, or any component below it |
useDefaultOrderAssociations | the detail criteria | every order view in your project needs a different shape |
useCustomerOrders carries the list page:
- Load —
loadOrders(criteria),changeCurrentPage(page). - Read —
orders,currentPage,totalPages. - Configure —
limit, the writable ref that decides the page size.
useOrderDetails carries the detail view, one order at a time — the Order Details recipe covers that page on its own, down to the associations, the cancellation and the downloads:
- Load —
loadOrderDetails()fills every value below exceptpaymentUrl, which onlyhandlePayment()writes, and returns the rawOrderRouteResponse. - Read —
order,status,statusTechnicalName,total,subtotal,shippingCosts,billingAddress,shippingAddress,personalDetails,shippingMethod,paymentMethod. - Payment —
paymentChangeable,getPaymentMethods,changePaymentMethod,handlePayment,paymentUrl. - Cancel —
cancel(). - Documents —
documents,hasDocuments,getDocumentFile,getMediaFile.
Six things the generated reference will not tell you:
limitonuseCustomerOrdersis a writable ref that starts at15, andloadOrdersoverwrites whateverlimityour criteria carries with it. Page sizes are changed throughlimit.value, never through the criteria.useOrderDetails(orderId, associations?)does not let you replace the default criteria. The second argument is merged underuseDefaultOrderAssociations(), so the defaults win on conflicting keys. To change the shape of every order view, overrideuseDefaultOrderAssociationsin your project instead.paymentDetailsexists only in the declared type ofhandlePayment. The function itself takes justfinishUrlanderrorUrl, so a third argument type-checks and is then ignored — onlyorderId,finishUrl, anderrorUrlreach the Store API.handlePayment()stores the returnedredirectUrlinpaymentUrland does nothing else. No navigation happens until you watchpaymentUrland redirect yourself — and check its scheme first, becausenew URL()parsesjavascript:anddata:without throwing. The Payment recipe owns that guard.- The two composables hold state differently.
useCustomerOrderscreates its refs per call, so two components calling it keep two independent lists.useOrderDetailsinjects and provides one sharedswOrderDetailsref, so everything below the first caller reads the same order object. getMediaFile(downloadId)returns aBlobfromorderDownloadFile get /order/download/{orderId}/{downloadId}.getDocumentFile(documentId, deepLinkCode)returnsBlob | stringfromdownload post /document/download/{documentId}/{deepLinkCode}. The union is the hand-written return type, which covers everyacceptvariant of the operation.getDocumentFilepassesaccept: "application/pdf", but that selects the typed variant rather than setting a request header — as the Order Details recipe explains — so which arm you actually get follows the document's own file type: a PDF is parsed as aBlob, an HTML or XML one as text. Narrow it before you hand it toURL.createObjectURL, and wrap the string arm rather than discarding it. Both are about attachments, not about the order body.documentsis the one field that arrives without being asked for:useDefaultOrderAssociations()never requests it, but the route returns it anyway — which is whyhasDocumentsgets away with readingorder.documents.lengthunguarded, whiledocumentsitself still falls back to an empty array.
useUser contributes only isLoggedIn, and it is narrower than it sounds: it is false for a guest, because it requires an active, non-guest customer — the Customer Profile recipe compares it with isCustomerSession and isGuestSession. It gates the registered customer's history, not every session the order routes will answer for — see the guest flow under State And Session.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you type the list criteria, the response, or the order you render:
import type { Schemas, operations } from "#shopware";
type OrderListCriteria = operations["readOrder post /order"]["body"];
type OrderListResponse = operations["readOrder post /order"]["response"];
type Order = Schemas["Order"];
type OrderLineItem = Schemas["OrderLineItem"];
type OrderState = Schemas["StateMachineState"];OrderListCriteria intersects Schemas["NoneFieldsCriteria"] with checkPromotion and the guest-authentication fields email, zipcode, login, plus a filter restricted to an equals filter on deepLinkCode.
Minimal Vue Example
<script setup lang="ts">
import type { operations } from "#shopware";
const {
orders,
loadOrders,
changeCurrentPage,
currentPage,
totalPages,
limit,
} = useCustomerOrders();
const { isLoggedIn } = useUser();
// browserLocale is the visitor's locale (navigator.language, or accept-language
// during SSR), not the storefront's. usePrice formats in that locale too, but
// takes the currency from the session context. Both beat the host default,
// which differs between the server render and the browser.
const { browserLocale } = useShopwareContext();
const { getFormattedPrice } = usePrice();
const localePath = useLocalePath();
const { formatLink } = useInternationalization(localePath);
// loadOrders replaces the limit in your criteria with this ref, so set it here.
limit.value = 10;
// The list carries no associations by default: without stateMachineState the
// rows have no order state, even though the generated type declares one.
const criteria: operations["readOrder post /order"]["body"] = {
associations: {
stateMachineState: {},
},
sort: [{ field: "createdAt", order: "DESC" }],
};
// Starts false: the immediate watcher below runs synchronously in setup and
// flips it before the first render, so a signed-out visitor is never "busy".
const isLoading = ref(false);
const ordersError = ref("");
const loadFirstPage = async () => {
ordersError.value = "";
isLoading.value = true;
try {
await loadOrders({ ...criteria, page: 1 });
} catch {
ordersError.value = "Your orders could not be loaded.";
} finally {
isLoading.value = false;
}
};
// changeCurrentPage re-sends the criteria of the last loadOrders call with a
// new page, so the associations and the sorting above are kept.
// The in-flight guard is what makes aria-disabled safe: the buttons stay
// mounted and focusable, so the click has to be rejected here instead.
const changePage = async (page: number) => {
if (isLoading.value || page < 1) return;
// totalPages is 0 until a load succeeds, so the upper bound only applies
// once there is data — otherwise the retry button could never fire.
if (totalPages.value && page > totalPages.value) return;
ordersError.value = "";
isLoading.value = true;
try {
await changeCurrentPage(page);
} catch {
ordersError.value = "Your orders could not be loaded.";
} finally {
isLoading.value = false;
}
};
const formatDate = (value: string) =>
new Intl.DateTimeFormat(browserLocale).format(new Date(value));
// One live region for the page position and the loading state, so a page
// change is announced instead of silently swapping the list underneath.
const statusMessage = computed(() => {
if (isLoading.value) return "Loading orders…";
if (!orders.value.length) return "";
return `Page ${currentPage.value} of ${totalPages.value}`;
});
// Immediate watcher instead of onMounted: the orders belong to the customer
// behind the context token, so the list is reloaded when the session changes.
watch(
isLoggedIn,
(loggedIn) => {
if (loggedIn) {
loadFirstPage();
}
},
{ immediate: true },
);
</script>
<template>
<section :aria-busy="isLoading">
<h1>Order history</h1>
<p v-if="!isLoggedIn">Sign in to see your orders.</p>
<template v-else>
<p role="status">{{ statusMessage }}</p>
<!-- role="alert" and a retry, not a replacement for the list: the
previous page is still in `orders` and still worth showing. -->
<div v-if="ordersError" role="alert">
<p>{{ ordersError }}</p>
<button type="button" @click="changePage(currentPage)">
Try again
</button>
</div>
<p v-else-if="!isLoading && !orders.length">
You have not placed an order yet.
</p>
<ul v-if="orders.length">
<li v-for="order in orders" :key="order.id">
<!-- NuxtLink, not <a href>: a plain anchor is a full document
navigation, and formatLink keeps the active locale prefix. -->
<NuxtLink :to="formatLink(`/account/order/details/${order.id}`)">
Order {{ order.orderNumber }}
</NuxtLink>
<time :datetime="order.orderDate">
{{ formatDate(order.orderDate) }}
</time>
<span>{{ getFormattedPrice(order.amountTotal) }}</span>
<span v-if="order.stateMachineState">
{{ order.stateMachineState.translated.name }}
</span>
</li>
</ul>
<!-- Always mounted and aria-disabled rather than removed or disabled:
unmounting the control the customer just activated drops focus to
the document body. changePage() guards the click. -->
<nav aria-label="Order history pages">
<button
type="button"
:aria-disabled="isLoading || currentPage <= 1"
@click="changePage(currentPage - 1)"
>
Previous page
</button>
<button
type="button"
:aria-disabled="isLoading || currentPage >= totalPages"
@click="changePage(currentPage + 1)"
>
Next page
</button>
</nav>
</template>
</section>
</template>vue-starter-template builds the same page without useCustomerOrders: app/pages/account/order/index.vue calls apiClient.invoke("readOrder post /order") through its own paginated-list component, passing useDefaultOrderAssociations() so the rows carry the same shape as the detail view. That is the trade the composable makes visible — useCustomerOrders owns the pagination refs for you, at the cost of the forced limit and the discarded paymentChangeable. Reach for the composable when you want that state managed, and for invoke when you already have a pagination primitive.
State And Session
The Store API resolves the customer of readOrder post /order, orderSetPayment post /order/payment, and cancelOrder post /order/state/cancel from the sw-context-token header. Nothing in the request names the customer, so the order history changes with the session and not with a route parameter.
handlePaymentMethod post /handle-payment names its target explicitly instead: the order comes from orderId in the body, not from the session alone — the route still requires the context token. readPaymentMethod post /payment-method is secured with the access key alone, because payment methods are sales-channel data rather than customer data. Before relying on that for any other route, read the security block of the schema your backend ships rather than assuming it: the declared requirement of a route can change between Shopware releases, and /handle-payment is one route where it has.
Neither useCustomerOrders nor useOrderDetails refreshes the session context or the cart. Unlike login, reading orders does not change the session, so orders simply keeps describing the customer that was authenticated when the request was sent.
Guest orders reach the same route with email, zipcode, and an equals filter on deepLinkCode in the body. With login: true, the response carries an sw-context-token header, and the API client adopts that token as its new default header when the response is not publicly cacheable — the Session Context recipe explains why a Cache-Control: public token is ignored. Neither composable adds those fields for you. useOrderDetails cannot send them at all, and useCustomerOrders would forward them only because loadOrders spreads the whole body — it keeps just orders.elements, total and page in list refs and exposes nothing else of the response. A guest order page therefore calls apiClient.invoke("readOrder post /order") directly.
That route answers a guest session isLoggedIn reports as signed out, so a guest page branches on the rejection rather than on session state. The three codes it has to tell apart are CHECKOUT__CART_ORDER_DEEP_LINK_NOT_FOUND, CHECKOUT__GUEST_NOT_AUTHENTICATED, which is the signal to show the email and postcode form, and CHECKOUT__GUEST_WRONG_CREDENTIALS. The Guest Order Lookup recipe walks through that flow end to end.
Edge Cases
Schemas["Order"]declaresstateMachineStateas required, but it is an association: it isundefinedat runtime until your criteria asks for it. The generated type describes the entity, not the response you received.lineItems,deliveries, andtransactionsare absent for the same reason, but the type does not mislead you about them: all three are declared optional, so the compiler already makes you handleundefined. That contrast is what makesstateMachineStatethe trap. A list row cannot show the ordered products, the shipping method, or the payment state unless the criteria asks for those associations.useCustomerOrdersreads onlydata.ordersfrom the response and discardspaymentChangeable. SendingcheckPromotion: trueon the list gains nothing through this composable;paymentChangeableis available onuseOrderDetails, whose request always sets it.totalPagesisorders.totaldivided by the currentlimitref and rounded up. Writinglimit.valueafter a load changestotalPageswithout reloading anything, so the last page can point past the end of the data you hold.- The generated body type restricts
filterto thedeepLinkCodeguest-authentication filter, so narrowing the order list by other fields is not covered by the generated types. Usesortandpageinstead, and set the page size throughlimit.value. cancel()andchangePaymentMethod()callloadOrderDetails()again, so the detail view is up to date afterwards. Theordersref ofuseCustomerOrdersis separate state and still shows the previous status.- Because
useOrderDetailsshares one order, a nested component created with a different order id reads the ancestor's order until its ownloadOrderDetails()resolves, and then replaces the ancestor's order as well.
Common Mistakes
- Do not pass
limitinside theloadOrderscriteria. Setlimit.valueon the composable. - Do not trust the generated
Ordertype as a description of what the list returned. It describes the entity, not the associations you requested. - Do not carry a list row into the detail view as a finished order. Call
loadOrderDetails()so line items, deliveries, transactions, andpaymentChangeableexist. - Do not keep a local
currentPageor a local copy ofordersnext touseCustomerOrders. - Do not decide whether the payment can still be changed from list data. Read
paymentChangeablefromuseOrderDetails. - Do not call
handlePayment()and assume the customer left the page. WatchpaymentUrland redirect explicitly. - Do not redirect to
paymentUrlwithout checking its scheme —new URL()alone parsesjavascript:anddata:too. - Do not unmount or disable the pagination control the customer is operating. The browser blurs a removed or disabled element and focus falls to the document body — keep it mounted, use
aria-disabled, and guard the handler. - Do not replace the order list with the error. The page the customer was reading is still in
orders, and swapping it out leaves them with no retry and no way back. - Do not expose raw API error details from the order routes in the UI. Branch on the guest authentication codes and render your own message.
Testing Checklist
- Loading the order history calls
readOrder post /orderwith"total-count-mode": "exact"and the currentlimit. - Paging calls
changeCurrentPageand re-sends the criteria of the previous call with the newpage. - A row shows the order state only when the criteria requested the
stateMachineStateassociation. - Opening an order calls
readOrder post /orderwithidsset to that order andcheckPromotion: true, and fillsorder,status, andpaymentChangeable. getPaymentMethods()callsreadPaymentMethod post /payment-methodwithonlyAvailableset to true.changePaymentMethod()callsorderSetPayment post /order/paymentand reloads the order details.cancel()callscancelOrder post /order/state/canceland reloads the order sostatusTechnicalNamereflects the cancelled state.handlePayment()callshandlePaymentMethod post /handle-paymentand setspaymentUrlto the returned redirect URL.- A failing request shows a list-level error instead of an empty order history, keeps the previously loaded page on screen, and offers a retry.
- Starting a page change announces the loading state and leaves focus on the pagination control that triggered it.
- A
paymentUrlwhose scheme is nothttps:— includingjavascript:anddata:— does not trigger a navigation. - Order dates render in the visitor's browser locale (
browserLocale) and totals in the session currency, not the host default. - A session without a logged-in customer renders the signed-out state and sends no order request.
- A guest reaching an order by deep link is prompted for email and postcode on
CHECKOUT__GUEST_NOT_AUTHENTICATEDrather than shown the signed-out state.