Skip to content

Dispatch Tracking, Cancellations & Returns

What happens to an order after it's placed: dispatching it with a tracking number, cancelling it before it ships, and taking items back after it has shipped. Each is available in the admin, the REST admin API and over MCP, and runs through the same service code in all three.

Dispatch tracking

When an order is dispatched you can record how it's being delivered:

Field Limit Notes
carrier 100 characters e.g. Royal Mail
trackingNumber 100 characters
trackingUrl 500 characters Must be a full http:// or https:// link, because it's rendered as a link in the email

All three are optional, so an order can still be dispatched without tracking. Values are trimmed, and an invalid value stops the dispatch with a message naming the field.

Where it's stored. Order.dispatchTracking (DispatchTracking, with a recordedAt date). The admin order view, orders get over MCP and the REST Order model all show it.

How it's recorded. OrderService.dispatchOrder(order, user, siteContext, tracking) passes the values to the site's dispatchOrder pipeline as additionalParameters (carrier, trackingNumber, trackingUrl). The recordDispatchTrackingProcessor step (priority CORE + 200, after setOrderToDispatchedProcessor) saves them on the order and writes DISPATCH_TRACKING_RECORDED <carrier> <number> to the activity log. The dispatch email step runs after it, so the email includes the tracking. The effective pipeline is:

  1. orderStateValidationProcessor (the order must be PACKING)
  2. conditionalCaptureProcessor (captures if the site captures at dispatch)
  3. setOrderToDispatchedProcessor
  4. recordDispatchTrackingProcessor
  5. sendOrderDispatchEmailProcessor
  6. redirectToOrderPageProcessor

The email. email/orderDispatched (and its -text variant) shows a "Track your parcel" block with the carrier, the tracking number and a link, when the order has tracking. The wording comes from the orderNotificationEmail.tracking* message keys (English and Dutch).

Resending the dispatch email

OrderEmailResendService.resendDispatchEmail queues the customer's dispatch email again, with the order's current tracking, and logs DISPATCH_EMAIL_RESEND_QUEUED. The dispatch pipeline and the resend share OrderDispatchEmailSender, so both use the same site settings (orderDispatchTemplate, orderDispatchSubjectPattern, orderNotificationFrom*). A resend is refused if the order isn't DISPATCHED or DELIVERED, and nothing is sent if the order has no customer email address.

Where to find it

Dispatch with tracking Resend dispatch email
Admin Order view → Dispatch Order opens a form for the tracking Order view → Resend Dispatch Email (admins only)
REST POST /v1/admin/orders/{orderId}/dispatch with an optional DispatchOrderRequest body POST /v1/admin/orders/{orderId}/dispatch/resend-email (409 if not dispatched, 422 if no customer email)
MCP manageFulfilment dispatch with carrier, trackingNumber, trackingUrl manageOrderEmails resendDispatch

Over MCP the resend is refused if the dispatch email was sent for the order in the last 15 minutes, whether by the dispatch itself or by an earlier resend.

Order cancellation

A SUBMITTED or PACKING order can be cancelled. Cancelling gives the customer their money back, marks the order CANCELLED, puts its stock back and emails the customer. A DISPATCHED or DELIVERED order can't be cancelled; it needs a return.

What happens to the payment

OrderCancellationService.plan(order, siteContext) works this out before anything changes. The admin modal, the REST dry run and the MCP dry run all show the plan.

Payment state What cancelling does
CAPTURED, PARTIAL_REFUND (or legacy PAID) REFUND: what's left (captured amount minus refunds so far) is refunded by running the site's refundOrder pipeline
AUTHORISED (not captured yet, e.g. a site that captures at pack or dispatch) VOID: the Stripe PaymentIntent is cancelled, releasing the hold on the card; the payment becomes VOIDED
No payment, INITIAL, REFUNDED, VOIDED NONE
Anything else (PENDING_REFUND, DISPUTED, FAILED...) Refused: sort the payment out with the provider first

Cancelling is also refused when:

  • the order has a line that cancelling can't undo: a subscription, a virtual gift card, a gift card top-up or a donation (their side effects happen at checkout);
  • the order was paid partly by another payment intent (a gift card or split tender), because the refundOrder pipeline only refunds the main card payment.

The cancelOrder pipeline

OrderCancellationService.cancel(order, user, siteContext, reason, restock) checks the plan, then runs the site's cancelOrder pipeline through OrderService.cancelOrder. The pipeline gets the reason and restock values as additionalParameters:

  1. orderStateValidationProcessor: the order must be SUBMITTED or PACKING (validStates config, defaulting to those two).
  2. releaseOrderPaymentProcessor: refunds or releases the payment as above, and records cancellationPaymentAction / cancellationPaymentAmount on the order. If the provider refuses, it stops the pipeline with error, so the order is left as it was.
  3. setOrderToCancelledProcessor: sets the state to CANCELLED and stores the reason in the cancellationReason order property and the activity log.
  4. restockCancelledOrderProcessor: puts the stock back (see below), unless restock is false.
  5. sendOrderCancellationEmailProcessor: emails the customer.

The pipeline is annotation-driven (@PipelineStep), so it reaches every site on deploy. defaultProcessors.json has rows for it, and system update 84.json adds the orderStateValidationProcessor / cancelOrder config row to existing installs, so the valid states can be changed per site in the pipeline admin.

Restocking

At checkout, updateInventory now records what it actually took in the order's stockTaken property ([{skuId, quantity}]). A SKU that was already out of stock isn't decremented, so it isn't recorded. Restocking puts back exactly that list with InventoryService.increaseStock on the default warehouse. Orders placed before this change have no record, so their line quantities are used instead. Untracked SKUs (unlimited stock) are left alone, and quantitySold isn't reduced.

The customer email

email/orderCancelled (HTML and text) tells the customer the order was cancelled and whether they were refunded or had the hold on their card released, with the amount. The cancellation reason is internal and isn't included.

Config key (order-cancellation-emails) Default
order-cancellation-email-enabled true Send the email
order-cancellation-email-subject Order #{0} has been cancelled {0} is the order number
order-cancellation-email-template orderCancelled Template under email/

The From and BCC addresses are the same orderNotificationFrom* settings the dispatch email uses.

Where to find it

Admin Order view → Cancel Order (admins only) opens a confirmation showing what will be refunded or released and how much stock goes back, with a required reason and a restock checkbox. Manage Orders → Cancelled Orders lists them.
REST POST /v1/admin/orders/{orderId}/cancel with {reason, restock, dryRun}. 409 if the order can't be cancelled or the provider refused (the order is unchanged).
MCP manageCancellations (mcp:admin): reason and idempotencyKey are required, dryRun previews, and a refund is capped by mcp-refund-limit. Releasing an authorisation moves no money, so the limit doesn't apply to it.

Effect on the rest of the platform

  • OrderStates.CANCELLED counts as placed (isPlaced()), so a cancelled order can't be deleted, used as a basket or resubmitted (BasketAlreadySubmittedError.state includes CANCELLED).
  • The revenue, statistics, export and gift-aid queries list SUBMITTED, PACKING and DISPATCHED explicitly, so a cancelled order drops out of them. Figures already calculated for past periods aren't recalculated.
  • It also drops out of the admin's Active and All Orders lists and appears under Cancelled Orders. The customer still sees it in their order history, with the status "Your order has been cancelled".

Returns (RMA)

A return records items coming back from a DISPATCHED or DELIVERED order. Staff record returns in the admin, API or MCP; customers can ask for one from their order page (see Customer return requests). It moves through:

PENDING_APPROVAL ──approve──▶ REQUESTED ──receive──▶ RECEIVED ──refund──▶ REFUNDED
       │                          │                     │
       └──────────reject──────────┴─────────────────────┴──────▶ REJECTED

Staff-recorded returns start at REQUESTED. REFUNDED and REJECTED are final. An order that hasn't been dispatched yet should be cancelled instead.

Data

Returns live in their own orderReturns collection (OrderReturn), scoped by siteId (and carrying catalogueId), with indexes on siteId + state + created, siteId + orderId and siteId + number. Only ReturnServiceImpl touches OrderReturnRepository; everything else goes through ReturnService, which takes the caller's SiteContext, so a return on another site is reported as not found. The old, unused ReturnOrder class was removed.

Field
number <orderNumber>-R<n>, e.g. 1001-R1
lines order line id, SKUs, quantity and amount (the line's share of its discounted price, pro rata by quantity)
refundAmount sum of the line amounts: what a refund pays by default
refundedAmount what was actually refunded
restocked whether the units went back into stock on receipt
reason / rejectionReason why it came back / why it was turned down (sent to the customer)
source STAFF or CUSTOMER
customerReason / customerComments the reason the customer picked (DOES_NOT_FIT, FAULTY_OR_DAMAGED, NOT_AS_DESCRIBED, WRONG_ITEM, CHANGED_MIND, OTHER) and what they wrote
history every step, with who did it and an optional note

Each step is also written to the order's activity log (RETURN_REQUESTED, RETURN_RECEIVED, RETURN_REFUNDED, RETURN_REJECTED).

Rules

  • Create: quantities per order line, each at most what was ordered less what earlier returns (other than rejected ones) already cover. Subscriptions, virtual gift cards, gift card top-ups and donations can't be returned. A reason is required.
  • Receive: optionally restocks each returned SKU (variant SKU where there is one) with InventoryService.increaseStock on the default warehouse.
  • Refund: only from RECEIVED. Runs the order's refundOrder pipeline for the return's value, or a different amount (e.g. less for damaged goods). The amount can't exceed what's left to refund on the order, and the payment must be CAPTURED, PARTIAL_REFUND or PAID. An atomic refundInProgress claim on the return stops two refunds of the same return running at once. If the provider doesn't refund, the return stays RECEIVED.
  • Reject: from REQUESTED or RECEIVED, with a reason that's sent to the customer. Rejected quantities become returnable again.

Emails

One template, email/orderReturnUpdate (HTML and text), switches on returnState. It's sent when a return is requested, refunded or rejected, and lists the returned items and the amount.

Config key (order-return-emails) Default
order-return-emails-enabled true Send return emails
order-return-email-subject Your return {1} for order #{0} {0} order number, {1} return number
order-return-email-template orderReturnUpdate Template under email/

Where to find it

Admin Manage Orders → Returns lists returns with a state filter (customer requests are marked). A dispatched order's page has a Returns panel and a Create return form (quantity per line, reason). Each return has a page with its lines, history and the Mark as received (with restock), Refund (admins only) and Reject actions. Fulfillers can do everything except refund.
REST GET/POST /v1/admin/returns, GET /v1/admin/returns/{returnId}, and POST .../receive, .../refund (with dryRun) and .../reject. Errors are 409 with a ReturnError message.
MCP returns (mcp:read: list, get; the customer's name and email need mcp:write) and manageReturns (mcp:write: create, receive, reject; refund also needs mcp:admin, an idempotencyKey, respects mcp-refund-limit and supports dryRun). opsInbox counts returns to receive and to refund.

Customer return requests

Customers can ask to return items from the order page (/o/{orderId}/{code}, reached from their order emails, or from their account order history when logged in). It's off until a shop turns it on.

Turning it on for a shop

  1. Apply the Customer return requests upgrade under Platform Upgrades. It adds the orderReturnRequest extension to the order confirmation page, below the order summary. New installs have it already.
  2. Set the site config (category customer-returns):
Key Default
customer-returns-enabled false Show the "Return items" form
customer-return-window-days 30 Days after dispatch a customer can ask; 0 for no limit
customer-returns-require-approval true Requests wait for staff approval; when off they're accepted straight away
customer-return-instructions empty What to do once accepted, e.g. where to post the items; in the email and on the order page
customer-return-staff-email none Address to email when a customer asks

What the customer sees. The orderReturnRequest extension checks the visitor may see the order (the confirmation code from the link, or the logged-in owner; anyone else sees nothing). It lists the order's returns and their progress, shows the return instructions once a return is accepted, and, when the order is eligible, offers a "Return items" form: a quantity per line (up to what's left to return), a reason and optional comments. The form uses a native <details> element, so it works in both themes without Bootstrap JS. Posting it creates the return and redirects back to the order page with ?returnRequested=<number>, so a refresh doesn't send it twice. An order is eligible when customer returns are on, it's DISPATCHED or DELIVERED, it's within the window (counted from the order's DISPATCHED status change, or its dispatch tracking, or failing that its submission), and something is left to return. Otherwise the panel says the window has closed or everything is already being returned.

What staff see.

  • A returnRequestedEvent in the admin notifications feed, linking to the return. It holds no personal data. Turn it off with supressedAdminNotifications.
  • An email to customer-return-staff-email, if set (email/orderReturnRequestedStaff).
  • The return under Manage Orders → Returns, marked Customer, showing the reason and comments. Approve and send instructions moves it to REQUESTED and emails the customer the return instructions. Decline rejects it with a reason that's emailed to the customer.
  • opsInbox counts returns to approve; MCP manageReturns approve; REST POST /v1/admin/returns/{returnId}/approve.

Emails to the customer. The orderReturnUpdate template covers each step: request received (PENDING_APPROVAL), accepted with the instructions (REQUESTED), refunded and declined.

Not covered yet

  • Customers can't cancel their own request; staff decline it.
  • The request form isn't in the customer REST API, only on the storefront page.
  • The delivery charge isn't included in a return's value; refund it by passing an amount.
  • Revenue reports still count the original sale; the refund shows on the payment.

Pipeline validation

orderStateValidationProcessor and paymentStateValidationProcessor return validationFailed when the order or payment is in the wrong state. The annotated pipeline executor stops on that result, just as it does on error. So dispatching an order that isn't PACKING, or refunding a payment that isn't captured, changes nothing and reports failure.

If a site has no validStates configured for a pipeline, the processors fall back to the defaults in defaultProcessors.json: packOrder accepts SUBMITTED, dispatchOrder accepts PACKING, cancelOrder accepts SUBMITTED or PACKING, and refunds accept PAID, CAPTURED or PARTIAL_REFUND.