Docs

Running a store

DocsRunning a store

Cart offers

5 sections6 min readdocs/cart-offers.md

On this page5 sections
  1. 1Building and sending one
  2. 2The link
  3. 3Lifetime
  4. 4Email and events
  5. 5Where it lives

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

LockWhat the recipient can’t changeNeeds
Items and quantitiesAdding, removing or changing lines-
Billing addressThe billing addressA billing address filled in
Shipping addressThe shipping address and destinationA shipping address. Only on a cart that ships
DeliveryThe delivery option, method and pickup point per groupA delivery chosen. Only on a cart that ships
Coupons, gift cards and store creditAnything 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 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.update on 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:

  1. It calls openCartOffer, which checks the link but records no open.
  2. If the offer was made out to an account and nobody is signed in, it sends the shopper to sign in, then back.
  3. It keeps the token in an httpOnly cookie and sends it as X-Cart-Offer on 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

StateWhen
DraftNot sent yet
SentEmailed at least once
OpenedThe 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
CompletedPlaced - the console links to the order
ExpiredPast its expiry - the link explains, and checkout refuses
WithdrawnRevoked 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

PartPath
Model, locks, link, accessinternal/cart/offer*.go - an offer is a carts row with kind = 'offer'
Admin APIgraph/schema/admin/carts.graphqls, graph/admin/cart_offer_helpers.go
Storefront APIopenCartOffer, myCartOffers and Cart.offer in graph/schema/front/cart.graphqls
Consoleapps/dashboard/src/features/carts/offer/
Storefrontapp/offer/[token]/route.ts, app/[channel]/[locale]/cart/offer/, app/[channel]/[locale]/account/offers/