Skip to main content
This guide walks through building a working checkout page in the code editor (or page builder). It follows the real runtime: the same page can serve a one-time product, a subscription, or a multi-item cart depending on what the URL - or the page’s own backend script - resolves, so most of the layout is built from conditionals that the browser evaluates against live checkout data. For exact scope keys and totals, see Checkout functionality reference and Frontend Template Engine: Checkout. For wallet setup, see Apple Pay and Google Pay.
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:
  1. 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 the checkout scope (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 onto window.efScope.checkout.
  2. 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.
If “Is checkout page” is off and the page’s backend script sets no product, CheckoutProcessor never runs. The tags are not swapped - <checkout-cc-panel> and <checkout-wallet> reach the browser as raw, inert custom elements, and checkout.* scope is empty. The page looks broken even with a valid merchant. This is the single most common reason a checkout “doesn’t work.”

Prerequisites

  1. 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.)
  2. 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.
  3. Link at least one product so the page can resolve something to sell - from ?p=<code>, a cart, or a backend script.
“Is checkout page” is an app-only page setting. It is stored on the page and toggled in the dashboard. It is not settable from the ef CLI, and ef pages create does not set it - a page pushed with the CLI still needs the toggle flipped in the app, or a setVariable("checkout_product", …) in its backend script, before checkout runs. Quiz pages that take payment must have this flag on as well as their quiz flag; the two are independent.

Step 1 — resolve the product from the URL

A single-product checkout loads its product from the p 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.
Because product resolution happens on the server, 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:
That single line does three things:
  1. Resolves the product server-side, exactly as ?p= would - same title, image, price, retail price and subscription cadence in the checkout scope, correct on first paint.
  2. 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.
  3. 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:
Because the script is ordinary JavaScript, the same hook covers anything you can compute at render: a geo-priced variant, a returning customer’s upgrade code, a split-test arm, an expired-deadline fallback.
Set checkout_product or checkout_cart, never both. A product code always wins and the cart is ignored, so setting both silently sells only the single product.
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. Use checkout_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:
Repeat a code and its quantities add up rather than producing a duplicate line; a line whose code is missing or malformed is dropped without taking the rest of the bundle with it. A cart the buyer fills - the VSL offers several products and the visitor picks. Put [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.
Both branches must be marked data-frontend-template="true" (see Step 6 below). This is the exact production markup:
Do not read cartItems[0] inside the single-product block — use the checkout.product_* keys. In single-product mode cartItems is not the source of truth.

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 reads checkout.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:
The wallets value is camelCase: wallets="applePay,googlePay". The value is passed through verbatim as data-wallets and matched against the payment library’s wallet identifiers (e.g. Stripe’s disableWallets), which are camelCase. Snake_case apple_pay,google_pay is wrong — it is emitted unchanged, matches nothing, and silently fails to filter wallets. wallets and force are plain attributes, not data- prefixed, and <checkout-wallet> may be self-closing.
Notes on behavior:
  • Buttons appear automatically when the device/browser supports a wallet — you do not render them yourself.
  • checkout.wallet_visible flips false → true only 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. Add force="true" to pre-render the native Apple Pay button on iOS and set wallet_visible immediately, 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.
For a wallet-first layout, put <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:
What happens:
  • 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-cards sets the accepted card brands; data-applepay="false" suppresses the panel’s built-in wallet tab (use it when you place <checkout-wallet> separately).
<checkout-cc-panel> requires an explicit closing tag. Written self-closing (<checkout-cc-panel/>) it is neither expanded nor stripped and passes through to the browser as inert markup. (Only <checkout-wallet> / <checkout-apple-pay> may self-close.) Any children you write inside the panel are discarded.

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.
Server-side conditionals that depend on the request (e.g. Blade-style @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 from checkout.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:
Added bumps also feed the totals — loop them again in the price summary, filtered to 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 loop checkout.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:
That gives three nested levels - 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 on bump.added. Nothing there needs to know about placement or codes:
Ticking the checkbox in Example A mutates 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.
A hard-coded bump.code is a contract with the funnel’s Set Checkout Bumps node. If that node stops supplying the code - renamed product, edited node, or a split-test branch that serves a different bump set - the filter simply never matches and the block renders nothing, silently. There is no error and no empty slot to notice in review.Keep the codes in the template and the node in sync, and when you split-test bump sets, make sure every branch either supplies the codes your template targets or the template also has an unfiltered fallback loop.

Where bumps come from

checkout.bump_products is filled from three sources, which merge. A code named by more than one is offered once:
  1. 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.
  2. The Set Checkout Bumps node (set_checkout_bumps) in the funnel graph, whose bumps field 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.
  3. The ?bumps= query parameter, a comma-separated list of bump product codes. This is how a /b buy link carries a bump into the checkout.
?bumps= lives only in that one URL. A visitor who opens the page directly, shares the link, or reloads after the parameter is gone sees no bump. If a page should always offer one, declare it in the backend script or the node instead of relying on the link.
Because a bump set can come from a page-event node, you can A/B test bump configurations: place a Split Test → Traffic Distribution node in front, and give each branch its own Set Checkout Bumps node. Different visitors then get different bumps against the same checkout template, and the split test measures which set converts better. See Page event nodes → Set Checkout Bumps and Split testing.

bumps offers, selected_bumps chooses

The two query parameters do different jobs, and confusing them loses the offer:
  • bumps is the offer list: which bumps the checkout renders at all.
  • selected_bumps is 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 empty selected_bumps= means “everything was unticked” and survives a refresh.
Never rewrite 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-message on one element — the general status/error line for the whole submit.
  • data-checkout-submit on 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:
The published object is read by both the render (to build the rendered dropdowns and labels) and 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.
setVariable is not optional, and assigning the object is not the same thing. Backend scripts run in a sandbox, and only what you pass to setVariable leaves it. A block that just writes var checkout_settings = {...} publishes nothing. It throws no error and the page still renders, so the failure is silent: the country allowlist is not applied and declared bumps never appear. If you build the object in steps, finish with setVariable("checkout_settings", checkout_settings).
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_cart blocks
For a wallet-first layout, add a <checkout-wallet force="true" wallets="applePay,googlePay"/> custom-code block above Checkout Details and set data-applepay="false" on the Credit Card Form.