Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

Storefront

This page covers the pages KahunaCart renders for customers: their routes, templates, Twig functions, styling and cache headers. It is for a store administrator setting up the shop and for a Twig themer customising it.

KahunaCart owns a set of routes under a base you configure (route, default /shop) and injects a synthetic page at each one. There are no pages to create in your tree.

Routes

Method Route What it does
GET {base} Product listing
GET {base}/search?q= Product search
GET {base}/category/{slug} Products in one category
GET {base}/product/{slug} Product detail
GET {base}/cart Cart view
POST {base}/cart Cart actions — add, update, remove, coupon
GET {base}/checkout Checkout form
POST {base}/checkout Checkout submit
GET {base}/order/{hash} Confirmation and receipt, hash-protected
GET {base}/order/{hash}/invoice Printable invoice, same hash rule. See Customer accounts
GET {base}/account Account overview — login required
GET {base}/account/orders Order history — login required
GET {base}/account/downloads Every live download grant — login required
GET {base}/account/addresses The address book — login required
POST {base}/account/addresses Address actions — save, delete, set default
GET {base}/download/{token} A purchased file, token-protected and counted
GET {base}/payment/return/{transactionHash} Where a redirect provider sends the customer back
POST {base}/webhook/{slug} Provider webhooks — public, signature-verified by the provider

Route matching is exact. A URL with extra segments, such as /shop/product/my-widget/anything/here, falls through to normal Grav routing, so a real page at /shop/about still works and anything else 404s. Filters live in the query string and do not affect matching.

Category URLs stay flat however deep the tree gets: /shop/category/mens, never /shop/category/clothing/mens. See Products and catalog.

Unpublished products, unknown category slugs and orders still in cart status return a 404. Webhooks and downloads never render a page: a webhook answers JSON and exits, and a download either streams bytes or renders the denial page.

On a multilanguage site {base} carries the language prefix, so /de/shop/product/reef-runner-tee and /en/shop/product/reef-runner-tee are the same product. Every link, form action and redirect keeps the visitor in their language, and kahunacart_base_route() returns the prefixed route. Webhook URLs stay unprefixed. See Multilanguage.

Important

Changing route changes your webhook URLs. Update them at every payment provider when you do.

Templates and theme overrides

The plugin appends its own templates/ directory to the Twig search path, so your theme wins.

Template Rendered at
kahunacart-products.html.twig {base}
kahunacart-search.html.twig {base}/search
kahunacart-category.html.twig {base}/category/{slug}
kahunacart-product.html.twig {base}/product/{slug}
kahunacart-cart.html.twig {base}/cart
kahunacart-checkout.html.twig {base}/checkout
kahunacart-order.html.twig {base}/order/{hash}
kahunacart-invoice.html.twig {base}/order/{hash}/invoice
kahunacart-account.html.twig {base}/account
kahunacart-account-orders.html.twig {base}/account/orders
kahunacart-account-downloads.html.twig {base}/account/downloads
kahunacart-account-addresses.html.twig {base}/account/addresses
kahunacart-processing.html.twig {base}/payment/return/{hash} while a payment settles
kahunacart-denied.html.twig A download that was refused
partials/kahunacart-buy-button.html.twig Add-to-cart form, including the option picker
partials/kahunacart-product-grid.html.twig The card grid, shared by the listing, categories and search
partials/kahunacart-gallery.html.twig Product images on the detail page
partials/kahunacart-search.html.twig Search form
partials/kahunacart-seo.html.twig JSON-LD block, included from the page body
partials/kahunacart-seo-head.html.twig Canonical link, included from a head block
partials/kahunacart-categories.html.twig Category navigation as a flat row of chips
partials/kahunacart-catalog-nav.html.twig The sidebar: the category tree and the filter panel
partials/kahunacart-subcategories.html.twig Subcategory cards on a category landing page
partials/kahunacart-tags.html.twig A product's tags, on its own page
partials/kahunacart-breadcrumbs.html.twig The visible ancestor trail, on category and product pages
partials/kahunacart-coupon.html.twig Discount-code form
partials/kahunacart-totals.html.twig Subtotal, adjustments, total
partials/kahunacart-messages.html.twig Flash messages
partials/kahunacart-account-nav.html.twig Account section navigation
partials/kahunacart-address.html.twig One address as a postal block
partials/kahunacart-address-form.html.twig Add/edit address form
partials/kahunacart-address-suggestion.html.twig Address-validation panel at the top of checkout
partials/kahunacart-country-select.html.twig The country field, as a closed list of ISO codes
partials/kahunacart-order-status.html.twig The three status axes of one order
emails/order-confirmation.html.twig / .txt.twig Confirmation email
emails/payment-received.html.twig / .txt.twig Payment-received email
emails/shipped.html.twig / .txt.twig Shipped email
emails/refund.html.twig / .txt.twig Refund email
emails/set-password.html.twig / .txt.twig Set-password invitation
emails/abandoned-cart.html.twig / .txt.twig Reminder email

Every page template extends partials/base.html.twig and fills a content block, which your theme must provide. The processing, product and category templates also override a head block: a meta-refresh on the first, the canonical link on the other two. Without a head block in your base template, the payment-return page does not auto-refresh and no canonical link is emitted. Meta tags do not depend on that block; see Search engines.

Important

The messages partial drains the flash queue. Include it exactly once per page.

Override a storefront template

  1. Copy the template from user/plugins/kahunacart/templates/ into user/themes/<your-theme>/templates/, keeping the same relative path.

  2. Edit your copy.

  3. If you copied kahunacart-product.html.twig or kahunacart-category.html.twig, carry the two SEO includes across:

    TWIG
    {% block head %}{{ parent() }}{% include 'partials/kahunacart-seo-head.html.twig' %}{% endblock %}
    {# ...and inside your content block: #}
    {% include 'partials/kahunacart-seo.html.twig' %}
    
  4. If you copied kahunacart-checkout.html.twig, include partials/kahunacart-address-suggestion.html.twig at the top of the form.

Your copy now renders instead of the plugin's. Skipping step 3 costs those pages their structured data and canonical link; skipping step 4 means the address-validation panel never renders.

Product images

A product's pictures come from the plugin's own table, not from a page media folder. They live in user/images/kahunacart/products/{productId}/, which the web server delivers directly. Uploading and reordering happens in the admin; the endpoints are in Products and catalog.

  • Product cards show the first image, cropped to 600×450. The variable is product.image, and it is null for a product with no picture, in which case the card renders without a figure.
  • The product page shows a gallery: the images scaled to fit 900 pixels wide in a scroll-snapping row, with 120×120 thumbnails beneath when there is more than one. The thumbnails are in-page links, so the gallery works with a click, a swipe, a keyboard, and with scripting off.
  • alt is always rendered. An undescribed image gets alt="", which declares it decorative rather than letting a screen reader read the filename.
  • Every <img> carries width and height. Everything but the first gallery image is loading="lazy".

Resized copies are generated by Grav's media machinery and cached in images/. Set images.derivatives to false to serve the uploaded files at their original size instead.

Note

Animated GIFs are always served as uploaded, and so are AVIF files on hosts whose image library was built without AVIF support.

Search engines

seo.enabled (default true) adds structured data and social metadata to product and category pages, and to nothing else.

Structured data. A Product block on product pages carries the name, description, image URLs, the default variant's SKU, and an offer with the price, currency and availability. Availability follows the same stock rules the buy button obeys.

A BreadcrumbList goes on both page types and names the real ancestor chain: shop, then every category from the root down. A product filed in more than one category walks the trail of the first by position. Both blocks come from partials/kahunacart-seo.html.twig.

Social and description metadata. description, og:title, og:description, og:image, og:url, og:type, og:site_name, og:locale and the Twitter card tags. These ride on Grav's page metadata rather than a template block, so they work in any theme. The description is the product's summary, or the opening of its description with the Markdown stripped, cut at a word boundary around 160 characters. The card type is summary_large_image when there is a picture and summary when there is not.

Canonical links. seo.canonical (default false) emits <link rel="canonical"> from partials/kahunacart-seo-head.html.twig. Set it to true only if your theme emits no canonical link of its own. The Kahuna and Quark2 themes both emit one, and a search engine that finds more than one discards all of them.

Sitemap. seo.sitemap (default true). With grav-plugin-sitemap installed, every published product and category is added to sitemap.xml with the date it was last edited. The plugin's own page stubs carry sitemap: ignore, because each is one template rendered at many URLs.

Turn off SEO output

  1. Set seo.enabled to false.

Product and category pages now emit no JSON-LD, no meta description and no Open Graph tags. To drop only one part, leave seo.enabled alone and set seo.canonical or seo.sitemap to false instead.

{base}/search?q= searches published products by title, summary, description and variant SKU. On a multilanguage site it also matches the active language's translated title, summary and description alongside the base columns, so an untranslated product stays findable. See Multilanguage.

The query is a portable, case-insensitive LIKE substring match, with % and _ escaped so a search for 100% finds the product with a percent sign in its name. It behaves identically on SQLite, MySQL and PostgreSQL.

The form is a GET, so a result page has a URL that can be linked, shared and bookmarked; kahunacart_search_url() builds it. Results are capped at search.limit (default 50), and past that the page asks for a narrower term. There is no pagination and no relevance ranking: results come back newest first. Search result pages are noindex, follow.

Note

LIKE '%term%' cannot use an index, so every search reads every published product row. That suits the hundreds or low thousands of products this plugin targets. For a much larger catalog, replace ProductRepository::searchPublished() with a full-text index or a dedicated search service. On SQLite, LOWER() also folds ASCII only, so a search for an accented capital matches only what a plain lowercase comparison would.

Add the search form to a page

  1. Add the include to any template:

    TWIG
    {% include 'partials/kahunacart-search.html.twig' %}
    

The form posts to {base}/search. The product listing ships with it already.

Catalog navigation and filters

Presence of data is the switch, and there is no configuration for it. A store with no categories renders one flat listing: no sidebar, no tree, no breadcrumb and no landing pages. The same rule applies per block: the tree needs categories, the subcategory facet needs the category being viewed to have children, and the tag facet needs tags. When no block has anything to offer, catalog_nav is null and the template emits no <aside>.

Filtering is not tied to categories. A category-free store still gets its filter panel — tags, price, in stock — but no tree above it and no subcategory facet inside it.

Category landing pages

A category page is either a department or a shelf, decided by that category's display column and described in Products and catalog. A department renders subcategory cards — picture, name and product count — above the product grid or instead of it.

Card images are cropped to 640×360, and a category with no picture renders the same card without the figure. The count is descendant-inclusive. The category's own picture renders above the description at up to 900 pixels wide.

Opening a parent category lists the products in it and in every category under it, and a product filed in two categories inside the same subtree comes back once.

The category tree sidebar

The tree is server-rendered and contains no JavaScript. Branches expand with <details>, and the branch down to the category being viewed starts open. The link sits inside the <summary>, so clicking the text navigates and clicking the marker toggles the branch.

The current category is marked with aria-current="page", and its ancestors carry a class of their own so a theme can draw the trail. Counts render beside a node only when there is something to count.

Filter parameters

The filter panel is a <form method="get">, so a filtered listing is a real URL that can be linked, shared, bookmarked and crawled, and it needs no nonce and no JavaScript.

Parameter Means
sub=<slug> Refine to one subcategory of the category being viewed. Single value.
tag[]=<slug> A tag facet, repeatable. tag=a,b is accepted too and means the same thing.
min=, max= A price range, typed the way a price is written in the store's currency.
stock=1 In stock only.
q= The search term, shared with the search route.

Within the tag facet the values are ORed; across facets it is AND. tag[]=organic&tag[]=sale means organic or on sale, and adding min=10 narrows that set. The repeated tag[] form is what an HTML checkbox group submits; the comma form suits a hand-written link.

Filters compose with search rather than replacing it. The search term rides through the panel as a hidden field, and Clear filters drops the refinements and keeps the term. {base}/search with no q lists nothing even when facets are ticked.

Prices are typed in the store currency and converted through Money, so 10 is 1000 minor units in USD and 10 in JPY. A comma is accepted and normalized; anything else clears the field. Price and stock are asked as two independent questions of the variants table.

Values that name nothing are dropped rather than emptying the page: a tag[] slug from a renamed tag contributes nothing, and a sub= slug not below the category being viewed drops the refinement. One request may name at most 40 tags.

Truncation instead of pagination

A filtered listing asks for one row more than it will show. search.limit (default 50) is the ceiling, and past it the page says "There are more products than fit on one page. Narrow it down with the filters."

An empty result says which kind it is. A category with nothing in it says so. A filter combination that matches nothing says "Nothing matches those filters. Try removing one." and offers the clear-filters link.

Partials a theme can override

Partial Variable it reads
partials/kahunacart-catalog-nav.html.twig catalog_nav — the tree, the filter panel, or null
partials/kahunacart-subcategories.html.twig subcategories — the children of the category being viewed, each with image and product_count
partials/kahunacart-tags.html.twig tags{slug, label, url}, already translated
partials/kahunacart-breadcrumbs.html.twig ancestors{title, url}, root first, never including the current page

Each renders nothing when it has nothing to draw, and all four are styled by the shipped stylesheet through the same --kc-* token chain as everything else.

The classes are kahunacart-catalog-layout and kahunacart-catalog-main around the two columns, plus kahunacart-has-nav on the page wrapper when there is a sidebar. Then kahunacart-cat-* for the tree, kahunacart-filter* for the panel, kahunacart-subcategory-* for the cards, kahunacart-breadcrumb*, kahunacart-tags / kahunacart-tag-list / kahunacart-tag, kahunacart-category-media and kahunacart-listing-more. The state classes are is-current and is-ancestor. Theming lists them all.

The two-column layout exists only when there is a sidebar, and below 40rem it collapses to one column with the sidebar above the listing.

Every string in these partials goes through |t against a PLUGIN_KAHUNACART.* key — REFINE, CATEGORIES, FILTER_TAGS, FILTER_IN_STOCK, PRODUCT_COUNT and the rest — so translate the panel by adding a languages/<code>.yaml.

Styling

KahunaCart ships assets/kahunacart.css, enqueued automatically on its own routes and nowhere else. It styles the kahunacart-* classes the shipped templates carry: .kahunacart-product-grid, .kahunacart-product-card, .kahunacart-cart-table, .kahunacart-totals, .kahunacart-adjustment--{type}, .kahunacart-denied--{reason} and the rest. You do not have to write any CSS to have a usable shop.

Customizing with custom properties

The stylesheet is built on CSS custom properties, so a theme integrates by defining a handful of variables rather than writing rules. Internal --kc-* tokens resolve through a chain: your --kahunacart-* overrides first, then the theme's own --grav-* tokens, then --theme-accent, then neutral fallbacks. Set the --kahunacart-* properties in your theme's CSS:

CSS
:root {
    --kahunacart-accent: #0f766e;
    --kahunacart-radius: 4px;
}

The full variable reference lives in Theming.

Turn off the shipped stylesheet

  1. Set builtin_css to false.

Nothing is enqueued, and the kahunacart-* classes are yours to style from scratch. That is also the right setting if you have replaced every template with your own markup.

Note

The email templates carry no English of their own. Every string goes through |t against a PLUGIN_KAHUNACART.EMAIL_* key, and subjects are translated from PHP with the order number as a positional %s. The emails.abandoned_subject config key still wins outright when it is set. See Emails.

Twig functions

These are available on every page, not just plugin-injected ones.

Function Returns
kahunacart_products(limit = 50, offset = 0) Published products, newest first, each with default_variant and image.
kahunacart_product(slug) One published product or null, with variants (each with option_value_ids and option_label), default_variant, options, options_payload, images, image.
kahunacart_categories() The nested category tree. Each node has children, product_count, subtree_count. Empty when there are none.
kahunacart_tags() Every tag, with translated label, slug, product count and filtered listing URL. Empty when there are none.
kahunacart_product_images(slugOrId) A product's pictures in gallery order, each {id, path, url, alt, position}.
kahunacart_image(image, width = 600, height = null) One image as {url, alt, width, height}, or null for null input.
kahunacart_search_url(query = null) {base}/search, or that result page when you pass a term.
kahunacart_price(minor, currency = null) Minor units as money in the store currency, localized. Also a filter.
kahunacart_cart_count() Total units in the current session's cart, or 0.
kahunacart_base_route() The configured base route, leading slash, no trailing one.
kahunacart_account_url() The customer account route, {base}/account.
kahunacart_customer() The visitor's customer record as an array, or null.
kahunacart_login_url() The Login plugin's sign-in route, or '' when Login is off.
kahunacart_logout_url() The Login plugin's logout task on this page with its nonce, or ''.
kahunacart_countries() Every ISO 3166-1 alpha-2 code with its English name, ordered by name.
kahunacart_default_country() The code checkout.default_country names, or ''.
\|kahunacart_markdown Merchant Markdown as HTML, raw markup escaped and link targets defused.

Hand a product list to the shipped grid and it draws the pictures, prices and empty states for you:

TWIG
{% include 'partials/kahunacart-product-grid.html.twig' with {products: kahunacart_products(8)} %}

Use subtree_count rather than product_count in navigation: on a catalog where every product sits on a leaf, a department's direct count is 0. For ready-made markup, partials/kahunacart-categories.html.twig is the flat row of chips. partials/kahunacart-catalog-nav.html.twig is the full sidebar, but it reads the catalog_nav variable the plugin's own pages build, so include it from a storefront template rather than an arbitrary page.

Given both dimensions, kahunacart_image() crops the picture to fill that box; given a width alone it scales to fit, and an image narrower than that width passes through untouched. Null in, null out:

TWIG
{% set hero = kahunacart_image(product.image, 1200, 500) %}
{% if hero %}
    <img src="{{ hero.url }}" alt="{{ hero.alt }}" width="{{ hero.width }}" height="{{ hero.height }}">
{% endif %}
TWIG
{{ kahunacart_price(1999) }}              {# $19.99 #}
{{ item.total_minor|kahunacart_price }}
{{ kahunacart_price(order.total_minor, order.currency) }}

Important

Always pass the order's own currency when you have one. An order stores its currency at creation and does not follow later config changes.

kahunacart_customer() returns null for both guests and logged-in accounts that have never bought anything, which is the distinction a header link wants. It requires the Login plugin, which also owns the sign-in and logout routes:

TWIG
{% set customer = kahunacart_customer() %}
{% if customer %}
    <a href="{{ kahunacart_account_url() }}">Your account</a>
    <a href="{{ kahunacart_logout_url() }}">Sign out</a>
{% elseif kahunacart_login_url() %}
    <a href="{{ kahunacart_login_url() }}">Sign in</a>
{% endif %}

Use |kahunacart_markdown rather than Grav's markdown filter for anything from the commerce database: a product description, a category description, the offline payment instructions, the invoice footer. Grav's own filter passes raw HTML straight through. See Products and catalog.

TWIG
<div class="kahunacart-description">{{ product.description|kahunacart_markdown }}</div>

Every function here reads the commerce database. If one throws, it returns the emptiest true value it can — no products, a count of zero, an empty string — and logs why, rather than taking the whole site down. With Grav's debugger enabled the exception is rethrown instead.

Note

There is also a kahunacart_base Twig variable, but it exists only on plugin-injected pages. Use kahunacart_base_route() anywhere else.

Caching

Storefront responses declare what a cache may do with them, because Grav's default system.pages.expires: 604800 would otherwise put seven days of Cache-Control: max-age=604800 on every page, including pages built from the session.

Page Header
Cart, checkout, payment return, receipts, invoices, the download denial page, the whole account area private, no-store, max-age=0
Catalog pages (listing, category, product, search) for a visitor with a cart or a login private, no-cache, max-age=0
Catalog pages for an anonymous visitor with no cart Whatever system.pages.expires says

Downloaded files carry the same private headers, because a purchased file's URL carries the token that proves it is that customer's. The session pages also ship noindex, nofollow.

The middle row matters most. A product page is the same for everybody until the visitor has a cart, at which point a theme's header draws a basket count and the page becomes personal. It then stays cacheable by that visitor's browser and never by a shared cache, so a store behind a CDN keeps its catalog cache for anonymous visitors.

Add a buy button to your own page

  1. Drop the partial into any template, blog post layout or landing page:

    TWIG
    {% include 'partials/kahunacart-buy-button.html.twig' with {product: kahunacart_product('my-widget')} %}
    

The page now has a working add-to-cart form. kahunacart_product() attaches everything the partial reads: a <select> when there is more than one variant, a hidden field when there is one, a price, a quantity input and a submit.

A product with option axes also gets the picker — dropdowns, button pills or colour swatches, one per axis. It ships hidden and is revealed by assets/kahunacart-options.js, which writes the resolved variant into the same <select> the form posts. With scripting off the page keeps the plain select.

Important

Outside KahunaCart's own routes, nothing enqueues kahunacart-options.js for you. See Theming.

The form posts to {base}/cart:

Field Value
nonce From nonce_field('kahunacart', 'nonce') — required on every storefront POST
action add
variant_id The chosen variant
qty 1–999

For your own markup, copy those four fields. Quantity is clamped server-side to 1–999 regardless of what the form sends. Adding the same variant twice bumps the existing line's quantity rather than creating a second line.

The cart

{base}/cart renders line items with quantity inputs, a coupon form and the totals partial.

The update form posts action=update with qty[{item_id}] per row; a quantity of 0 removes the line. Removal is a separate one-field form per item posting action=remove with item_id. The coupon form posts action=coupon with code, and an empty code removes whatever is applied. See Coupons for what happens when a code stops being valid.

Important

Every POST under {base} requires a valid nonce. A stale one flashes "Session expired — please try again." and redirects to the cart without doing anything.

Checkout

Checkout collects contact details, a billing address, an optional shipping method, a payment method and notes. From the customer's side:

  1. Click Checkout from the cart.
  2. Enter an email address and a name.
  3. Enter the billing address, choosing a country from the list.
  4. Leave Ship to the billing address ticked, or untick it and continue to enter a delivery address.
  5. Choose a shipping method, if the store offers any. See The two-step shipping flow.
  6. Choose a payment method and click Place order.
  7. Pay on the provider's hosted page, if the provider is a redirect one.

The customer lands on their receipt at {base}/order/{hash}.

Checkout field names

Field Notes
nonce Required
email Required, validated
name The contact name, also written into the billing address
billing_address1, billing_address2, billing_city, billing_region, billing_postcode, billing_country Country is an ISO 3166-1 alpha-2 code, submitted from a <select> and checked against the list server-side
shipping_same Checkbox, checked by default. When set, the shipping address is a copy of billing.
shipping_address1shipping_country Same set with a shipping_ prefix, used when shipping_same is unchecked
shipping_method_id Radio, one per available method
provider Radio, one per registered payment provider
notes Free text, truncated to 2000 characters
terms Only when checkout.require_terms is on
save_address Checkbox, logged-in customers only, checked by default. See Customer accounts

Both address prefixes also accept _name, _company and _phone. The stock template does not render company or the billing phone; add them to your override if you need them, because the server already handles them. Every address field is truncated to 190 characters.

Checkout asks for a name once, at the top, and writes it into the billing address, and into the shipping address when that carries no name of its own. A logged-in customer's form arrives prefilled from their customer record and address book; see Customer accounts. The delivery-address fields appear once the customer unticks Ship to the billing address and continues, so the form behaves the same with scripting off.

The country field

Tax and shipping zones are matched on the ISO 3166-1 alpha-2 code, and a free-text country field fails that match silently. The field is a <select> of all 249 codes with their English names, and a code that is not on the list is refused with "Please choose a country from the list." The same applies to addresses saved in the account address book, since those are prefilled into checkout later.

Set checkout.default_country to the code the select starts on, for example 'US'. Leave it empty and the customer chooses. Override partials/kahunacart-country-select.html.twig to restyle the field, translate the names, or ship the short list of countries you sell to. The Twig functions behind it are kahunacart_countries() and kahunacart_default_country().

What a refused submit keeps

Checkout bounces for ordinary reasons: a lapsed discount code, a shipping method not yet chosen, a mistyped email. Every field the customer filled in comes back on the form — contact details, both addresses, order notes, the payment method, the shipping method and the checkboxes. The values ride the redirect in the session, are read exactly once, and are escaped on output. A customer who comes back to checkout later starts fresh.

Address validation

Address validation is off by default. With a provider and an API key of your own configured, checkout checks the delivery address against a third-party geocoder and offers a correction when one comes back, once per address. A service that is down, slow or out of quota is logged and waved through, so validation never stops an order.

YAML
checkout:
  address_validation:
    provider: mapbox      # none | mapbox | google
    api_key: 'pk.…'
    mode: suggest         # suggest | off

The panel is partials/kahunacart-address-suggestion.html.twig, included at the top of the checkout form. See Address validation for the drivers, the outcome table, and what the order records.

The two-step shipping flow

If your store has shipping zones and the cart contains something physical, checkout becomes two steps, because shipping methods cannot be priced until an address is known.

  1. The customer fills in their address and submits. No method has been chosen, so the order is refused and they are redirected back with a message.
  2. Checkout re-renders. The zone now resolves and the real methods appear with their real prices. The submit button says Continue to shipping on the first pass and Place order once a method is selected.
  3. They pick a method and submit again.

The address is remembered between the two passes. A store with no shipping zones, or a digital-only cart, never sees this: the shipping fieldset is not rendered at all.

Blocking messages the customer may see:

  • "Please enter your delivery address, including the country, so we can work out shipping." — no country on the address.
  • "We are unable to ship to that address. Please check it, or contact us for help." — the address matches no shipping zone.
  • "Please choose a shipping method." — methods exist, none selected.

What happens on submit

The order gets the customer's details, then shipping and tax are recalculated against the new addresses before anything is charged. If a coupon has stopped applying, the customer goes back to checkout with the reason rather than through to payment. Then the chosen provider is asked to start the payment, and one of three things happens:

  • complete — the order is placed on the spot and the customer lands on their receipt. This is the offline method, and any provider that captures synchronously.
  • redirect — the customer goes to the provider's hosted page. Their stock is held for the trip.
  • failed — an error is flashed and they stay on checkout.

The confirmation page

{base}/order/{hash} is the receipt. The hash is 32 hex characters of randomness and is the customer's only handle on the order, so the page is not linked from anywhere and needs no login.

It shows the order number, payment status, line items, adjustments, the total, and, for offline orders, the payment instructions from offline.instructions rendered as Markdown.

Digital orders also get a Your downloads section listing each live grant with its filename, remaining downloads (or "Unlimited downloads") and expiry date. Revoked grants are filtered out entirely. See Digital products.

The payment-return page

A customer coming back from a hosted provider lands on {base}/payment/return/{transactionHash}. If the webhook has already completed the order, they are redirected straight to their receipt. If it has not landed yet, which is common because a browser redirect often beats a server-to-server callback, they get a holding page that meta-refreshes every three seconds until it has.

The webhook is the authoritative confirmation. The customer coming back is advisory.