The templates below mirror a real production checkout (
checkout.ef). Every directive shown is copied from a page that renders correctly against a live merchant — not invented for the docs.How a checkout page renders
Two passes build the page:- Server pass (
CheckoutProcessor). Runs if the page has the “Is checkout page” flag on, or if the page’s backend script named a product (see Step 1b). It resolves the product, builds thecheckoutscope (customer, totals, subscription, bumps, coupon, wallet settings), and swaps the checkout tags -<checkout-cc-panel>becomes a gateway-specific card<div>,<checkout-wallet>becomes the wallet containers. It injects the checkout payload ontowindow.efScope.checkout. - Client pass (frontend template engine). In the browser, it evaluates every block marked
data-frontend-template="true"against the live scope (including values only known client-side, such as whether a wallet button actually rendered), fills the card/wallet forms, and wires the submit button.
Prerequisites
- Enable “Is checkout page” on the page. Open Page List → Edit Page → toggle Is checkout page on → Save. (A page that names its product in a backend script can skip this - see Step 1b.)
- Assign a merchant to the domain (Settings → Domains → Edit → Merchant). The card panel is swapped for a gateway-specific form (Stripe / NMI / Authorize.Net) resolved from this merchant.
- Link at least one product so the page can resolve something to sell - from
?p=<code>, a cart, or a backend script.
Step 1 — resolve the product from the URL
A single-product checkout loads its product from thep query parameter: /checkout?p=your-product-6-bottles. The runtime reads req.query.p, looks the product up for the brand, and populates the checkout scope (title, image, price, retail price, and - if the product is a subscription - the full billing cadence).
So a checkout page reached this way is generic; the ?p= code decides what it sells. A plan or quantity selector is just a set of links (or a script) that point at the same page with a different p:
If there is no
p in the URL, the page falls back to what its backend script named (Step 1b), and then to cart-only mode (see Step 2). A page with none of the three cannot resolve anything to sell.checkout.subscription and the totals are already correct on first paint — no client fetch. See Buy links for how funnel buttons build the ?p= link.
Step 1b - set the product from a backend script
A?p= in the URL assumes the buyer arrives from somewhere else - a landing page, a funnel button, a buy link. A one-page VSL + checkout has no such link. The visitor lands on the video, watches, and scrolls down to a checkout on the same page, so nothing ever put a product code in the URL.
For that shape, the page names its own product in a <script scope="backend"> block:
- Resolves the product server-side, exactly as
?p=would - same title, image, price, retail price and subscription cadence in thecheckoutscope, correct on first paint. - Turns the page into a checkout page. You do not need the “Is checkout page” toggle. Naming a product is the flag, so the checkout tags get swapped and
checkout.*is populated on a page the dashboard still lists as an ordinary one. - Pins the selection to the session, so the submit charges what the page rendered rather than whatever the browser posts back.
The rest of the guide applies unchanged. The order summary, card panel, wallets, bumps and the formless submit are all built exactly as in Steps 2-8 - only where the product came from is different.
Precedence: the script wins over ?p=
When a page sets checkout_product, it outranks any ?p= in the URL. That is deliberate rather than restrictive, because the backend sandbox already receives the query string - a page that wants the URL to decide simply says so:
A page made a checkout page only by its backend script cannot be picked as a funnel’s or merchant’s checkout page target - the Set Checkout Page node validates the stored flag, which is still off. That is usually what you want, since such a page sells its own product rather than whatever a buy link asks for. If you need it in both roles, turn the “Is checkout page” toggle on as well; the script keeps working either way.
Selling several products from one page
Two ways, depending on whether the buyer chooses. A fixed bundle - the page decides, the buyer does not. Usecheckout_cart with { code, quantity } lines. The order summary renders through the is_cart branch (Step 2) and the totals are the sum of the lines:
[ADD-TO-CART=<code>] buttons in the page copy and leave checkout_product / checkout_cart unset; the checkout section below reads the cart the buttons built:
The buyer-filled cart lives in browser storage, so it survives a scroll but not a cleared browser. A fixed
checkout_cart is resolved on the server every render and cannot be edited by the visitor at all - prefer it whenever the bundle is not the buyer’s choice.What the buyer cannot change
Whichever of the two the page sets, the resolved codes are pinned to the session against that page’s id while it renders. On submit the runtime charges the pin, not the posted fields - so an edited form, a stale tab, or a hand-crafted POST cannot swap the product out from under a page that already showed a price. Pages that use?p= or a buyer-filled cart pin nothing and behave exactly as they always have.
Bumps are unaffected and stack on top of any of these. Configure them as in Step 7 - from page config or the Set Checkout Bumps node - and the buyer’s selections are added to the pinned product or bundle at submit.
Step 2 — single product vs cart
The order-summary area renders one of two shapes, chosen by scope flags:Most carts here are Shopify-style carts - multi-item carts synced from Shopify. Most direct-response checkouts are single-product (one
?p=); the is_cart branch exists so the same layout can also render a synced Shopify cart with per-line quantities and remove buttons. See Shopify Checkout. The same branch renders the two on-page multi-product shapes from Step 1b: a fixed checkout_cart bundle, and a cart the buyer fills with [ADD-TO-CART=<code>] buttons.data-frontend-template="true" (see Step 6 below). This is the exact production markup:
Step 3 — subscription vs one-time (conditional)
Key idea: the same checkout page serves both one-time and subscription products, because the?p= code decides. So every subscription-specific element must be gated — never assume a subscription. Gate on the checkout.subscription object (it is null for one-time products, so it is falsy):
<template-if data-condition="checkout.subscription">— subscription framing (recurring heading, billing note, “save vs one-time”, “Subscribe” CTA).<template-if data-condition="!checkout.subscription">— one-time order framing.
The billing note (four cases)
Subscription products differ in how the first charge works, so the recurring note has to cover four mutually-exclusive cases. This is the production block verbatim — it readscheckout.subscription.{intro_offer, first_charge_free, trial_days, frequency, frequency_unit} and formats money with formatPrice(...) and values with [[ … ]]:
checkout.subscription.intro_offer.recurring_price is the price charged after the intro ends. The frequency_unit == 'month' ? … ternary just pluralizes “month/months”; other units (day, week, year) are printed as-is. For the full subscription scope shape (price_steps, prepaid, tiers), see Frontend Template Engine: Checkout.
In cart mode subscription data lives on each line (
item.is_subscription, item.subscription), not on checkout.subscription.Step 4 — wallets (Apple Pay / Google Pay)
Place a<checkout-wallet> tag where you want the express buttons. The runtime swaps it for an ef-wallet-standalone container holding #applePay and #googlePay, and the client injects whichever wallet the device supports:
- Buttons appear automatically when the device/browser supports a wallet — you do not render them yourself.
checkout.wallet_visibleflipsfalse → trueonly once a wallet button actually renders (it is promoted, never reset). Gate the “or pay another way” divider on it so the divider never shows above an empty space. Addforce="true"to pre-render the native Apple Pay button on iOS and setwallet_visibleimmediately, avoiding a first-paint layout shift.- Billing address comes from the wallet. When a customer pays with Apple/Google Pay, the correct billing address is taken from the wallet sheet — no extra fields needed in your template.
<checkout-wallet force="true" wallets="applePay,googlePay"/> at the top and disable the card panel’s own wallet tab with data-applepay="false" (next step).
Step 5 — the card panel
Drop in a single<checkout-cc-panel> tag. Do not hand-build card inputs — the runtime swaps this tag for a gateway-specific secure card form resolved from the domain’s merchant:
- Server-side, the tag is replaced with
<div class="checkout-cc-panel" data-gateway="{stripe|nmi|authorize_net}" data-accepted-cards="…">. The gateway is resolved from the merchant assigned to the domain. - Client-side, the secure card fields for that gateway are injected (Stripe Elements, NMI CollectJS, or Authorize.Net Accept.js). A resolved merchant/gateway is required for real tokenization.
data-accepted-cardssets the accepted card brands;data-applepay="false"suppresses the panel’s built-in wallet tab (use it when you place<checkout-wallet>separately).
Step 6 — mark client-side blocks with data-frontend-template="true"
Add data-frontend-template="true" to every template-if, template-else, and template-foreach whose condition depends on data that is only known in the browser: the wallet divider, subscription blocks, order bumps, cart items, coupon state, and any bump.added / checkout.wallet_visible conditional.
Why: the server template pass and the client template pass are separate. Values like checkout.wallet_visible (true only after a wallet renders), bump.added (toggled by the shopper), and coupon state (applied after an async validation) do not exist during the server pass. Marking a block data-frontend-template="true" tells the server pass to leave it alone and hands it to the client engine, which evaluates it against the live scope. Without the flag, the server tries to evaluate the condition against empty scope and the block is dropped or renders wrong.
@if(query.p == 'your-product-4-weeks')) are the opposite case - those are evaluated during the render and do not take the attribute.
Pair client-side blocks with
data-ef-cloak (and the CSS [data-ef-cloak] { display: none !important; }) on anything that would flash before the engine runs. Also hide the raw template-if / template-foreach tags with display: none !important so unprocessed markup never flashes.Step 7 — order bumps
Bumps come fromcheckout.bump_products — an array with per-bump code, name, description, price (formatted), price_raw, image, added, and added_message. Loop it and render an offer state (!bump.added) and an added state (bump.added). Toggling with @click="bump.added = true/false" mutates the scope and re-renders:
bump.added:
Placing a specific bump in a specific location
The loop above renders every bump in one place, in payload order. Most real checkouts want the opposite: a small shipping-protection checkbox tucked under the shipping fields, and a large product-bundle card in the sidebar - each styled differently. There is no “get bump by code” lookup. The idiom is to loopcheckout.bump_products as usual and filter inside the loop on bump.code. Because the loop renders nothing when the condition never matches, you can repeat this block as many times as you like, anywhere on the page, and each bump lands in its own slot:
foreach → code filter → added/not-added - and every one of them needs data-frontend-template="true". A missing flag on any level drops the block during the server pass.
Example A - compact checkbox bump, next to the shipping fields
Checkbox toggling.
@change="bump.added = $event.target.checked" works because the event scope exposes $event (and $el / $target) alongside the loop variable. @change and @input take the same alias families as @click (data-ef-on:change, data-ef-on-change, data-ef-change).This example is safe without :checked because the checkbox only exists in the !bump.added branch, so it always renders unchecked. If you collapse the two states into one block instead, bind the state explicitly: <input type="checkbox" :checked="bump.added" @change="bump.added = $event.target.checked">.Example B - rich product card, in its own container
Same filter, different bump code, different markup and placement - this one sits in a dedicated wrapper so CSS can float it beside the summary:The payload only carries
code, name, description, price, price_raw, image, added and added_message. Anything richer - the claims list, a bespoke heading, a hero image at a specific size - is hard-coded in the template, as above. Use ef-text / ef-src for the fields that come from the bump so price, name and image stay in sync with the Set Checkout Bumps node, and hard-code only the copy the node cannot supply.The totals loop stays unfiltered
Wherever the bump was rendered, the price summary uses the same single loop over all bumps, filtered only onbump.added. Nothing there needs to know about placement or codes:
bump.added, which re-renders the block into its added state, adds the row above, and rolls the bump price into checkout.subtotal and checkout.total - one state change, three places updated, no extra wiring.
Where bumps come from
checkout.bump_products is filled from three sources, which merge. A code named by more than one is offered once:
- The page’s own backend script, via
checkout_settings.bumps. Use this when the page should always offer the same bump. See checkout_settings order bumps. - The Set Checkout Bumps node (
set_checkout_bumps) in the funnel graph, whosebumpsfield is an array of bump objects (title,description,price,added_message, product code). The node is only offered when the page is flagged as a checkout page. - The
?bumps=query parameter, a comma-separated list of bump product codes. This is how a/bbuy link carries a bump into the checkout.
bumps offers, selected_bumps chooses
The two query parameters do different jobs, and confusing them loses the offer:
bumpsis the offer list: which bumps the checkout renders at all.selected_bumpsis the shopper’s choice among them, and is what the page writes back to the URL as they tick and untick. When it is present it also decides which boxes start ticked, so an emptyselected_bumps=means “everything was unticked” and survives a refresh.
bumps from the current selection. That deletes the offer the moment nothing is ticked, and the next reload renders no bump at all.
Step 8 — the formless submit
Checkout submission is not a form submit. The<form> in the examples is only for autofill/semantics (onsubmit="return false;"); the runtime sets its form reference to null and collects every bound field from the page wrapper instead. Submission is a click on a [data-checkout-submit] button that preventDefault()s and posts via fetch — so fields do not have to live inside the <form>, and method="POST" has no effect.
Wire it with three attributes:
data-template-value="checkout.customer.{field}"on each input/select — binds it into the checkout payload (email,phone,shipping_address,shipping_city,shipping_state,shipping_zip,billing_*, …).data-checkout-error="{field}"on an empty element — the validation adapter fills it with that field’s error message.data-checkout-messageon one element — the general status/error line for the whole submit.data-checkout-submiton the pay button (type="button").
Backend config: checkout_settings
Checkout options are set in a <script scope="backend"> block via setVariable("checkout_settings", { … }). This is the production block:
process-checkout (to enforce the same rules on submit). It sits alongside checkout_product / checkout_cart (Step 1b) in the same backend block: one names what the page sells, the other configures how it takes payment.
Common keys:
Full semantics: Checkout functionality reference →
checkout_settings.
Output bindings reference
Two ways to print scope values, used throughout the examples above:
Use
[[ … ]] where you need a value inside an attribute or mixed with other text (data-code="[[ item.code ]]"); use ef-text / ef-src / ef-alt for a whole element’s text or image. formatPrice(raw) formats a raw number as currency.
<template-foreach> also drives select dropdowns for country/state, each option { value, label }:
Page builder vs code editor
Everything above is code-editor HTML. In the page builder, use the Checkout block category, which maps to the same tags/bindings:- Checkout Details — contact + shipping + billing fields
- Credit Card Form —
<checkout-cc-panel> - Checkout Button —
<button data-checkout-submit> - Price Summary — totals section
- Checkout Coupon — coupon input with apply/clear (
data-apply-coupon,data-clear-coupon) - Checkout Bumps — order bumps
- Single Product Summary / Cart Products — the
is_single_product/is_cartblocks
<checkout-wallet force="true" wallets="applePay,googlePay"/> custom-code block above Checkout Details and set data-applepay="false" on the Credit Card Form.
Related docs
- Checkout functionality reference — scope keys,
checkout_settings, abuse protection, submit flow - Frontend Template Engine: Checkout —
checkout.*bindings, totals, subscription shape - Apple Pay and Google Pay — wallet setup
- Shopify Checkout — where
is_cartcarts come from - Buy links — building the
?p=link - Backend scripts: actions -
setVariableand the rest of the<script scope="backend">surface used in Step 1b - Set Checkout Bumps node and Split testing — configuring / A/B-testing bumps
- Checkout redirect behavior — post-checkout routing