Skip to content

Direct Checkout Links

A direct checkout link puts one product into an order of its own and opens checkout for it. Nothing is added to the shopper's basket, so whatever they were already shopping for is still there afterwards.

It's built for moments where a basket is in the way: a QR code on a collection bucket at an event ("scan to give £2"), a "donate now" button in an email or on another website, or a single-item offer in a social post.

https://shop.example.com/buy/donate?amount=2&src=summer-fair-2026
Parameter Meaning
/buy/{seoName} The product, by its SEO name (as in /product/{seoName})
amount For an open-amount product (a donation, or a price-as-quantity product): the amount in whole currency units. amount=2 is £2
qty For any other product: the quantity
v A variant SKU, as on the product page. A product with variants needs one, or the shopper is sent to the product page to choose
src Free-text attribution, saved on every order made through the link (letters, digits, spaces and _ . : / -, up to 64 characters)

Values are read leniently: amount=£2.50 becomes 2 (open amounts are whole units). Out-of-range amounts and quantities are clamped to the product's minimum and maximum. A missing amount uses the product's default quantity, then its minimum.

What the shopper sees

  1. The link creates the order and redirects to /checkout/{orderId}. A direct order always gets the single-page unified checkout (the checkout-unified page), even on a site whose own checkout page is the older multi-step one, so long as the site takes card payments through Stripe. A site without Stripe (e.g. test-payment mode) keeps its own checkout, where the amount can't be changed. A site can also put every checkout on that page with the Use single-page checkout setting (unified-checkout-enabled, Settings › Checkout and payments › Checkout). Default pages are only installed into an empty database, so system update 101 (AddUnifiedCheckoutPage) adds the shared checkout-unified page to older installs.
  2. A Your order block at the top shows the product and lets them change it before paying:
    • open-amount products get amount buttons (the link's amount plus the next few steps up, e.g. £2 / £3 / £5 / £10) and an Other amount box;
    • other products get a quantity stepper (unless the product's quantity type is FIXED);
    • donations also get the Gift Aid declaration tickbox. The declaration names the site (see Gift Aid), and a site can override the wording in Site Text.
  3. Express wallets (Apple Pay / Google Pay / Link) sit right under it, and their sheet total follows any change to the amount.
  4. After payment the shopper gets the usual order confirmation page and emails.

If the order can't be made — the product is out of stock, or the shopper has to choose a variant or a required option first — they land on the product page instead. A disabled or unknown product is a 404.

  • Admin: Catalogue › Products › edit › Checkout link tab. Pick an amount (or quantity), a variant and a source, then copy the link. Opening it from there creates a real, unpaid order.
  • REST: GET /v1/admin/product/{id}/checkoutLink?amount=2&source=summer-fair-2026 (getProductCheckoutLink) returns the link plus whether the product is open-amount and enabled. It only builds the link; no order is created.
  • MCP: the products tool's get action returns buyUrl alongside url. Append ?amount=N (open amount) or ?qty=N, and src= / v= as needed.

How it works

Piece Where
Link building, order creation, amount rules, checkout editing DirectCheckoutService / DirectCheckoutServiceImpl (commerce-core/.../checkout/direct/)
GET /buy/{*seoName} DirectCheckoutController (web-mvc)
Unified checkout for direct orders CheckoutController.findCheckoutPage
Order markers DirectCheckoutOrders (common): orderProperties.directCheckout, directCheckoutSource, directCheckoutInitialQuantity
Checkout editing CheckoutSessionPatch.lineItems → DirectCheckoutService.applyLinePatches; CheckoutSessionDTO.directCheckout describes the editable lines

A second order, not the basket. The order is created with associateToUser(false), so User.currentOrderId still points at the basket. Because the checkout's JSON API (/api/checkout/session) normally works on the basket, the checkout page passes ?orderId= for a direct order (data-order-id on #uc-shell). The older checkout's payment calls (/rest/extensions/v1/payment/confirm-payment and apply-gift-card) take the same parameter, read from the /checkout/{orderId} URL. Both only accept an order that belongs to the caller and is still open (OrderService.findCheckoutOrder).

Never adopted as the basket. When a user's basket pointer goes stale, retrieveCurrentOrderOrCreate falls back to their newest open order. It skips direct orders, so an unpaid "buy now" order can't turn up in the basket later. Abandoning a payment-locked direct order likewise leaves the basket attached.

First visits and bots. A QR scan is often the visitor's first request. A first-time visitor is normally only saved once a response is 2xx, and this one is a redirect, so the controller saves them first (UserResolutionFilter.persistDeferredUser). Requests from crawlers and link previewers go to the product page without creating an order. /buy/ is disallowed in the default robots.txt, and the redirect is sent with Cache-Control: no-store.

Attribution. src is stored in orderProperties.directCheckoutSource, which shows in the admin order view and in REST order responses.

Limits

  • Open amounts are whole currency units, because open-amount products are priced per currency unit.
  • One product per link.
  • The direct order isn't linked to the shopper's account basket, so if they leave checkout without paying the order is simply abandoned (it isn't merged into the basket on login).