DocsRunning a store
Cart offers
On this page5 sections
An offer is a cart an admin builds for one recipient and sends as a link. The recipient completes it through the normal checkout. Use one for:
- a custom quote
- an order taken by phone or email
- a receipt for an extra payment
An offer is never the customer’s own cart. It sits beside it, and it is never merged into it.
Building and sending one
Orders → Offers → New offer. The separate Carts page lists shopper carts; Offers lists offers, with their recipient, state and expiry. Each page creates its own kind of cart, and the detail page returns to that list. Make it out to:
- An existing customer. They must sign in to open it.
- A new customer. The account is created with it.
- A guest email. Anyone with the link can open it.
On the cart page:
- Line items: add catalog products as on any cart. Each line on an offer also has a
price field.
- A price you type is the offer price, and checkout keeps it instead of repricing from the catalog.
- Reset puts a catalog line back on its catalog price.
- Custom line: a free-form charge with no product behind it, such as an extra payment,
a service or a fee.
- You give it a name, price, quantity and tax class.
- Its quantity must be a whole number.
- It never ships, and it reserves no stock.
- On the order it is marked
isCustom.
- Offer: the expiry date and a message to the recipient. A customer offer can also be listed in their account under Offers, not only behind the link. The account navigation and overview show the active-offer count, with a dot when there are unopened offers. Those rows show New until the customer opens them.
- Locked for the recipient: the parts the recipient sees but can’t change. See the locks table below.
- Checkout: the shipping and billing address, allowed payment methods and delivery the recipient starts from. Delivery choices are quoted by the storefront’s own checkout for the saved shipping address, so save the address first. Anything left empty, the recipient fills in, unless it’s locked.
Line edits apply straight away. The offer settings, locks and checkout are staged behind the page-header Save.
The header actions are:
- Preview email: shows the recipient, subject, HTML and plain text without sending. It also lets you copy the link. Save staged changes first to preview the current terms.
- Send: emails the link, and sends again on every use.
- Copy link: to share it some other way.
- Replace link: every link handed out so far stops working.
- Withdraw offer: the offer can’t be completed any more. This can’t be undone.
Send waits until nothing is staged, so the email never describes old terms.
Locks
| Lock | What the recipient can’t change | Needs |
|---|---|---|
| Items and quantities | Adding, removing or changing lines | - |
| Billing address | The billing address | A billing address filled in |
| Shipping address | The shipping address and destination | A shipping address. Only on a cart that ships |
| Delivery | The delivery option, method and pickup point per group | A delivery chosen. Only on a cart that ships |
| Coupons, gift cards and store credit | Anything taken off the price | - |
Custom lines and lines with an admin-set price cannot be changed or removed by the recipient, even with items unlocked. Adding the same SKU again is refused. Admins can still edit them. Offers cannot be duplicated into shopper carts.
Locks are enforced by the backend, whatever the storefront shows. A checkout step that
would change a locked part is refused whole with CART_OFFER_LOCKED. The recipient’s email
on a guest offer is always fixed. Locking coupons also skips automatic promotions, including
free gifts and promotion shipping discounts.
Payment methods
Checkout → Payment methods lists the channel’s enabled methods. Leave the list empty
to allow any eligible method, select one to pre-select that method, or select several to
let the recipient choose among them. Checkout still applies the normal country eligibility
rules. The backend refuses a method outside the list with
CART_OFFER_PAYMENT_NOT_ALLOWED, both when setting it and when placing the order.
The link
The link is <channel public URL>/offer/<token>. The channel needs a public URL before an
offer can be sent.
The token is a 22-character URL-safe random credential (128 bits), stored on the offer. It contains no cart id or other offer details:
- Showing it again: admins with
orders.updateon the channel can retrieve the stored token and preview its email. - Replacing it: generates a new token and bumps the link version, invalidating all earlier tokens.
- Expiry: read from the cart each time the link is used, so extending an offer needs no new link.
Offer links don’t use any session or link key.
The storefront route app/offer/[token] handles a followed link:
- It calls
openCartOffer, which checks the link but records no open. - If the offer was made out to an account and nobody is signed in, it sends the shopper to sign in, then back.
- It keeps the token in an httpOnly cookie and sends it as
X-Cart-Offeron every call.
Without the header, an offer’s id opens nothing. An offer listed in a customer’s account opens for that customer without the link.
Checkout selects an offer through /checkout?offer=<cart id>; plain /checkout always
uses the shopper’s own cart. Every checkout form submits the cart id it rendered, and the
backend checks access again. Cookies carry the offer credential, not the checkout selection,
so opening an offer in another tab cannot redirect a regular-cart submission onto it.
An older guest-offer tab whose credential was replaced must reopen its original link.
- The offer page’s Continue to checkout opens that offer’s checkout URL.
- Edit cart returns to the same offer; regular-cart links open the shopper’s own cart.
- Placing an offer order clears its matching offer cookie and leaves the shopper’s cart and any different offer session untouched.
A guest offer places a guest order for its recipient, even when someone is signed in on that browser.
Lifetime
| State | When |
|---|---|
| Draft | Not sent yet |
| Sent | Emailed at least once |
| Opened | The recipient viewed the offer page. The storefront reports it from the browser with markCartOfferOpened, so a mail scanner that follows the link doesn’t count |
| Completed | Placed - the console links to the order |
| Expired | Past its expiry - the link explains, and checkout refuses |
| Withdrawn | Revoked by an admin |
New offers expire after sales/cart/offer_expiry_days (default 7, per channel), and each
offer’s date can be changed.
Offers are not:
- Pruned: the cart retention job skips an open offer, however long it sits.
- Treated as abandoned carts: abandoned-cart recovery skips offers.
An offer can be placed even where sales/checkout/allow_guest is off or the order is
below sales/checkout/min_order. The admin chose its recipient and amount.
Email and events
Send offers the mail to a Pulse flow on the cart_offer trigger first. A seeded
Cart offer flow and email exist for it. When no flow takes the mail, core sends its own
template (lib/email/cart_offer_template.go).
Preview email renders core’s template using the same composition as its send path. An active Pulse flow may send its own design instead. If the channel has no public URL, the preview shows the relative link path and marks the link incomplete; sending needs the public URL configured under Settings → Identity.
The flow email can use these merge tags:
{{offer.link}}{{offer.expiresAt}}{{offer.message}}
Webhooks cart.offer.sent, cart.offer.opened and cart.offer.expired report each step
(webhooks.md). The cart-offer-expiry job announces expiries every five
minutes.
Where it lives
| Part | Path |
|---|---|
| Model, locks, link, access | internal/cart/offer*.go - an offer is a carts row with kind = 'offer' |
| Admin API | graph/schema/admin/carts.graphqls, graph/admin/cart_offer_helpers.go |
| Storefront API | openCartOffer, myCartOffers and Cart.offer in graph/schema/front/cart.graphqls |
| Console | apps/dashboard/src/features/carts/offer/ |
| Storefront | app/offer/[token]/route.ts, app/[channel]/[locale]/cart/offer/, app/[channel]/[locale]/account/offers/ |
