Order Details
Goal
Build a page that shows one placed order — its state, line items, addresses, totals, documents and downloads — and lets the customer cancel it. The important part is that POST /order is a search whose result depends entirely on the associations you ask for, so a detail page is defined by its criteria rather than by its id.
This recipe is about the single order. The paginated list it is opened from is the Order History recipe, and reaching one order without a customer session is the Guest Order Lookup recipe.
Shopware Flow
readOrder post /order is a filtered entity search. Passing ids: [orderId] narrows it to one order, but the response is still an EntitySearchResult, and the order has to be taken from orders.elements[0].
What the operation does not do is decide which nested data comes back. Without associations an order has no line items, no deliveries, no transactions and no addresses — it is a price and a set of ids. Even the state is an association: status reads stateMachineState.translated.name, which is why stateMachineState sits in the default tree next to the rest. useDefaultOrderAssociations exists precisely to supply the tree a detail page needs, and useOrderDetails merges anything extra into it instead of replacing it.
Step 1
UI: Open one order
The page receives an order id from the route and creates the composable with it. Nothing is requested yet — the id alone is the whole input.
- Code
const { order, loadOrderDetails } = useOrderDetails(orderId)- State
- route param orderId
- Types
Read the diagram from left to right:
- The route hands an order id to
useOrderDetails(orderId). - The composable merges
useDefaultOrderAssociations()with any associations you passed, usingdefu. readOrder post /orderis sent withids, the mergedassociations, andcheckPromotion: true.useOrderDetailsstoresorders.elements[0]as the shared order andpaymentChangeableseparately.- The UI reads
status,total,subtotal,shippingCosts,billingAddress,shippingAddressandpersonalDetailsfrom computed properties. cancel()and the two download methods act on that order: the cancellation reloads it,getMediaFilereturns aBlob, andgetDocumentFilereturnsBlob | string— the XML and HTML document variants come back as text.
You do not need to reload the order after useOrderDetails' cancel() or changePaymentMethod() — both call loadOrderDetails() themselves.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Load one order | loadOrderDetails() | POST /order | readOrder body |
| Read the search result | order | POST /order | readOrder response |
| Cancel the order | cancel() | POST /order/state/cancel | cancelOrder body |
| Read the new state | await cancel() | POST /order/state/cancel | cancelOrder response |
| Download a document | getDocumentFile(documentId, deepLinkCode) | POST /document/download/{documentId}/{deepLinkCode} | download response |
| Download a digital file | getMediaFile(downloadId) | GET /order/download/{orderId}/{downloadId} | orderDownloadFile response |
| List selectable payments | getPaymentMethods() | POST /payment-method | readPaymentMethod response |
| Change the payment method | changePaymentMethod(paymentMethodId) | POST /order/payment | orderSetPayment body |
| Start the payment | handlePayment(successUrl, errorUrl) | POST /handle-payment | handlePaymentMethod body |
changePaymentMethod() and cancel() each send a second request of their own: both await loadOrderDetails() before they resolve, so the first row runs again without you asking for it. cancel() returns the new StateMachineState, but status and statusTechnicalName are not read from it — they are computed over the shared order and only change once the reload that cancel() triggers has finished.
The accept value on the two download rows — application/pdf for a document, application/octet-stream for a media file — is part of the generated operation type, not a header: the type requires it, and on the document route it also picks which response variant — PDF, HTML or XML — the call is typed as. The API client does not forward it: every request still goes out with the client's default Accept: application/json. The binary body arrives because the Store API answers with the file's own content type, and the fetch layer parses the response by that type. Only headers reaches the wire, and for these two operations the generated headers type declares nothing but sw-language-id, so there is no typed way to set Accept per request.
Composables
useOrderDetails: takes an order id and optional extra associations. Readsorder,status,statusTechnicalName,total,subtotal,shippingCosts,billingAddress,shippingAddress,personalDetails,shippingMethod,paymentMethod,documents,hasDocuments,paymentChangeable,paymentUrl. Acts withloadOrderDetails,cancel,changePaymentMethod,handlePayment,getDocumentFile,getMediaFile,getPaymentMethods.useDefaultOrderAssociations: returns the default association tree —stateMachineState,lineItemswithcoveranddownloads.media,addresses,deliverieswithshippingMethod,shippingOrderAddressandstateMachineState, andtransactionswithpaymentMethodandstateMachineState. Override it in your project when every order page in your storefront needs a different tree. Note thatdocumentsis not in the tree and does not need to be — the Store API returns it with the order.useOrderPayment: takes theordercomputed returned byuseOrderDetailsand drives the payment of an already placed order. ReadsactiveTransaction,state,isAsynchronous,paymentMethod,paymentUrl; acts withhandlePaymentandchangePaymentMethod. This is what the starter template uses on the checkout success page, and it is the composable to reach for when a payment has to be retried or redirected. ItspaymentMethodis the first transaction whose payment method is active, which is not necessarily the last oneuseOrderDetailsreports.
Each composable owns its own paymentUrl ref — they are not provided or shared — and handlePayment() writes the redirect target into that ref rather than handing it back; useOrderDetails' version returns nothing at all. Watch the ref belonging to whichever composable's handlePayment() you called, and check the URL's scheme before following it — new URL() parses javascript: and data: without throwing. The Payment recipe owns that guard and the rest of the retry flow.
Types
Use generated Store API types when you need to type the order criteria, the search response, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type ReadOrderBody = operations["readOrder post /order"]["body"];
type OrderRouteResponse = Schemas["OrderRouteResponse"];
type Order = Schemas["Order"];
type OrderLineItem = Schemas["OrderLineItem"];
type Document = Schemas["Document"];
type StateMachineState = Schemas["StateMachineState"];ReadOrderBody is where the criteria live. It is also the type that reveals the guest authentication fields — filter, email, zipcode and login — which a customer-session page never touches and a guest order lookup is built on.
Minimal Vue Example
<script setup lang="ts">
import { ApiClientError } from "@shopware/api-client";
import { downloadFile } from "@shopware/helpers";
import type { Schemas } from "#shopware";
const orderId = useRoute().params.id as string;
const {
order,
status,
statusTechnicalName,
total,
subtotal,
shippingCosts,
billingAddress,
shippingAddress,
personalDetails,
shippingMethod,
paymentMethod,
documents,
hasDocuments,
loadOrderDetails,
cancel,
getDocumentFile,
} = useOrderDetails(orderId);
const isLoading = ref(true);
const isCancelling = ref(false);
const loadError = ref("");
const cancelError = ref("");
const documentError = ref("");
const canCancelOrder = computed(
() =>
!!order.value &&
!["cancelled", "completed"].includes(statusTechnicalName.value ?? ""),
);
const messageFor = (error: unknown, fallback: string) =>
error instanceof ApiClientError && error.status === 403
? "Your session has expired. Please sign in again."
: fallback;
onMounted(async () => {
try {
await loadOrderDetails();
} catch (error) {
console.error(error);
loadError.value = messageFor(error, "This order could not be loaded.");
} finally {
isLoading.value = false;
}
});
const requestCancellation = async () => {
if (isCancelling.value) return;
cancelError.value = "";
isCancelling.value = true;
try {
await cancel();
} catch (error) {
console.error(error);
cancelError.value = messageFor(
error,
"We could not confirm the cancellation. Please reload this page before trying again.",
);
} finally {
isCancelling.value = false;
}
};
const downloadDocument = async (orderDocument: Schemas["Document"]) => {
documentError.value = "";
const fileType = orderDocument.fileType ?? "pdf";
try {
const file = await getDocumentFile(
orderDocument.id,
orderDocument.deepLinkCode,
);
// A PDF arrives as a Blob, the HTML and XML variants as text. Both are
// valid documents, so wrap the text instead of treating it as a failure.
const blob =
typeof file === "string"
? new Blob([file], {
type: fileType === "xml" ? "application/xml" : "text/html",
})
: file;
if (!(blob instanceof Blob) || blob.size === 0) {
documentError.value = "This document is no longer available.";
return;
}
downloadFile(blob, `${orderDocument.config.name}.${fileType}`);
} catch (error) {
console.error(error);
documentError.value = messageFor(
error,
"This document could not be downloaded.",
);
}
};
</script>
<template>
<p v-if="isLoading" role="status">Loading your order…</p>
<p v-else-if="loadError" role="alert">{{ loadError }}</p>
<p v-else-if="!order">This order does not exist.</p>
<article v-else>
<h1>Order {{ order.orderNumber }}</h1>
<p aria-live="polite">Status: {{ status }}</p>
<p>
Customer: {{ personalDetails.firstName }} {{ personalDetails.lastName }}
</p>
<h2>Items</h2>
<ul>
<li v-for="item in order.lineItems" :key="item.id">
{{ item.label }}
<span class="sr-only">Quantity:</span> × {{ item.quantity }}
<span class="sr-only">Total:</span> — {{ item.totalPrice }}
</li>
</ul>
<h2>Summary</h2>
<dl>
<dt>Subtotal</dt>
<dd>{{ subtotal }}</dd>
<dt>Shipping</dt>
<dd>{{ shippingCosts }}</dd>
<dt>Total</dt>
<dd>{{ total }}</dd>
</dl>
<section v-if="shippingAddress">
<h2>Delivery</h2>
<p>{{ shippingAddress.street }}, {{ shippingAddress.city }}</p>
<p>{{ shippingMethod?.name }}</p>
</section>
<section v-if="billingAddress">
<h2>Billing</h2>
<p>{{ billingAddress.street }}, {{ billingAddress.city }}</p>
<p>{{ paymentMethod?.name }}</p>
</section>
<section v-if="hasDocuments">
<h2>Documents</h2>
<p v-if="documentError" role="alert">{{ documentError }}</p>
<button
v-for="orderDocument in documents"
:key="orderDocument.id"
type="button"
:aria-label="`Download ${orderDocument.config.name} (${orderDocument.fileType ?? 'pdf'})`"
@click="downloadDocument(orderDocument)"
>
{{ orderDocument.config.name }}
</button>
</section>
<p v-if="cancelError" role="alert">{{ cancelError }}</p>
<button
v-if="canCancelOrder"
type="button"
:aria-disabled="isCancelling"
:aria-busy="isCancelling"
@click="requestCancellation()"
>
{{ isCancelling ? "Cancelling…" : "Cancel this order" }}
</button>
</article>
</template>Three choices in the markup are deliberate. The cancel button carries aria-disabled rather than disabled, because a disabled control cannot hold focus — a keyboard user who just pressed it would be thrown back to the top of the document; aria-disabled does not stop activation, so the if (isCancelling.value) return guard at the top of the handler is what actually prevents a second request. The error paragraphs are role="alert" and sit beside the control they belong to rather than in the v-if chain, because a failed cancellation or download leaves the order itself perfectly valid — replacing the whole page with the message would destroy what the customer came to read. And the status paragraph is aria-live="polite", because cancel() reloads the order and changes that text without the customer touching it.
.sr-only is the usual visually-hidden utility; the starter ships one, and any design system has an equivalent.
State And Session
The order is resolved from the sw-context-token: readOrder post /order returns only orders that belong to the customer the token identifies. An order id alone grants nothing. Without a customer session the route does not return an empty result — it answers 403 with CHECKOUT__CUSTOMER_NOT_LOGGED_IN, so the page needs a logged-in or guest session before it loads anything.
The one way in without that session is the guest authentication the request body carries: a filter restricted to deepLinkCode, the buyer's email, the billing zipcode and login. useOrderDetails takes an order id and associations only, so that request is built with apiClient.invoke directly — and it is a two-step flow, because the code alone is rejected with CHECKOUT__GUEST_NOT_AUTHENTICATED until the credentials arrive with it. The Guest Order Lookup recipe walks through it, including the error codes to branch on and the session token login: true establishes. Once that session exists, everything on this page works for a guest exactly as it does for a registered customer.
Keep this route out of the shared HTML cache. Loading from onMounted is deliberate: it is what keeps the order number, the addresses and the line items out of the server-rendered response. vue-starter-template applies isr to /** and opts /account and /account/** out of it with ssr: false, so the starter's own order page is safe — but mount an order page at another path, or refactor the load to useAsyncData/callOnce, and one customer's order is rendered into HTML that ISR then serves to everyone else. Personalized data does not belong in an ISR-cached response.
useOrderDetails keeps the loaded order in the swOrderDetails injection. That is a single slot, not a cache keyed by id, and it is shared along the provide/inject chain: a component that calls useOrderDetails provides its ref to everything below it, so a descendant calling the composable with a different id overwrites the ancestor's order. Two siblings get their own refs only when no ancestor called the composable — inject walks the whole parent chain and falls back to its default just on a miss, so under a providing ancestor both siblings share that one slot and whichever loads second wins.
The order id itself is read once, when the composable is created. It is a plain string, not a ref, so loadOrderDetails, cancel and getMediaFile keep pointing at the id the setup captured. In a Nuxt page this needs no work from you: Nuxt keys pages by their interpolated path, so /account/order/details/[id] remounts on its own when the id changes and setup re-runs. It matters for a component that receives the id as a prop — put a :key on it — and for any route where the id is not a path param.
The order is a snapshot. Its line items, prices and addresses are OrderLineItem and OrderAddress entities copied at order time, not references to the current product or customer address. Nothing that changes in the catalogue or the account afterwards is reflected here.
Edge Cases
orders.elementsis empty when the id is unknown or when the order belongs to a different customer. Distinguish "not found" from "not yours" in the UI at your own risk — the API does not. No session at all is a different case: that is a403, not an empty list.shippingAddressreadsdeliveries[0].shippingOrderAddress, so an order with no delivery — a purely digital order — has no shipping address at all.billingAddressis found by matchingbillingAddressIdagainst theaddressesassociation. Dropaddressesfrom the associations and it becomesundefinedeven though the id is present.paymentMethodis the last transaction's method andshippingMethodthe last delivery's. An order whose payment method was changed has more than one transaction, and the last one is the current one.paymentChangeableis only populated becauseloadOrderDetailssendscheckPromotion: true. It is a map keyed by order id in the response, exposed as a boolean for this order, defaulting tofalse.cancel()cannot be reverted and returns the newStateMachineState. Whether it is allowed depends on the order state on the server, so a rejected call is a normal outcome, not a bug.cancel()is two awaits: the cancellation, thenloadOrderDetails(). A rejection at the call site does not tell you which one failed, so the order may already be cancelled while the page still shows the old state. Word the message accordingly rather than claiming the cancellation failed.getDocumentFileneeds both the document id and itsdeepLinkCode. Both come from thedocumentsarray the order already carries — it is not part of the default association tree and does not have to be added.- A document download answers
204when no such document is found — deprecated, and a404from 6.8.0.0 on. Today that resolves successfully with an empty body, so check the returned content before handing it todownloadFile. A406(unsupported mime type) and the later404throw like any other error status. getDocumentFileis typedPromise<Blob | string>, notPromise<Blob>, and the string arm is a document rather than a failure. Because theacceptvalue never reaches the wire, which arm you get follows the document's own file type: a PDF is parsed as aBlob, whiletext/htmlandapplication/xmlare parsed as text. OnlygetMediaFilereturns a plainBlob.downloadFileis generic, so the compiler will not catch astringreaching it —URL.createObjectURLthrows on one at runtime. Wrap the text in aBlob; rejecting it reports a perfectly good HTML document to the customer as missing.- The
readOrderbody is thefields-less criteria variant, not the fullCriteria.associations,ids,filterand the rest are identical, but passingfieldsis an excess property the type rejects. getMediaFileonly works for line items whosedownloadsassociation is present, which the default associations request throughlineItems.downloads.media.handlePaymentdeclares a thirdpaymentDetailsargument in both composables, and neither implementation reads it. Payment data that a provider needs has to travel through that provider's own integration.statusis the translated state name andstatusTechnicalNamethe stable one. Branch onstatusTechnicalName; showstatus.- The order id is captured when the composable is created and never re-read. A Nuxt page remounts on an id change by itself, but a component holding the composable behind a prop does not — key it, or every action stays pointed at the previous order.
Common Mistakes
- Do not treat
readOrder post /orderas a read-by-id. It is a search, and the order lives inorders.elements[0]. - Do not replace the default associations when you only need one more. Pass the extra tree and let
defumerge it. - Do not branch on the translated
statusstring. It changes with the language. - Do not call
loadOrderDetails()again afteruseOrderDetails' owncancel()orchangePaymentMethod().useOrderPayment.changePaymentMethod()is the exception — it does not reload anything. - Do not call
useOrderDetailsfor a second order below a component that already called it for a first. They share oneswOrderDetailsslot along the provide/inject chain. - Do not expect a composable held by a component to follow a changing route param. Key that component on the id. A Nuxt page already remounts itself, so it needs no
definePageMetakey for this. - Do not resolve product data from an order line item against the current catalogue. The line item is a snapshot.
- Do not build a document URL by hand.
getDocumentFilereturns the binary anddownloadFilefrom@shopware/helpersturns it into a download. - Do not hand the result of
getDocumentFilestraight todownloadFile, and do not treat its string arm as an error. Wrap a string in aBlob, then check the result is non-empty — a missing document resolves with an empty body rather than throwing. - Do not write
catch {}without binding the error. You cannot log it, you cannot map it, and a programming error reaches the customer disguised as a failed order. - Do not put a failed cancellation or download in the same slot as a failed load. The order is still valid; replacing it with the message destroys what the customer came to read.
- Do not show a cancel button for every state. Read
statusTechnicalNamefirst.
Testing Checklist
- Opening the page sends exactly one
readOrder post /orderwith the order id inids. - The request carries the default associations, and extra associations passed to the composable are merged rather than replacing them.
- An unknown order id renders an empty state instead of throwing, while a request made without any session fails with
403 CHECKOUT__CUSTOMER_NOT_LOGGED_IN. - Once a guest lookup has established a session, this page loads the order with no credentials of its own.
statusshows the translated state andstatusTechnicalNamethe technical one.- Line items, addresses, shipping method and payment method render from the associations without a second request.
cancel()callscancelOrder post /order/state/canceland then reloads the order, and the rendered state changes.- A rejected cancellation shows a UI-level error beside the button and leaves the rest of the order rendered.
- Downloading a document calls
download post /document/download/{documentId}/{deepLinkCode}with the document'sdeepLinkCode, and the returned binary reaches the browser as a file. - Downloading a document that no longer exists shows a message instead of doing nothing.
- An order without deliveries renders without a shipping address block.