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 the URL, 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 only if the page has the “Is checkout page” flag on. It resolves the product from the URL, 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, 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 flag is the single most common reason a checkout “doesn’t work.”

Prerequisites

  1. Enable “Is checkout page” on the page. Open Page ListEdit Page → toggle Is checkout page on → Save.
  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 be reached with ?p=<code> (or a cart).
“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 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=herpafend_6. 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 the checkout page 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 runtime falls back to cart-only mode (see Step 2). A page reached with neither ?p= nor cart items 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 2 — single product vs cart

The order-summary area renders one of two shapes, chosen by scope flags:
“Carts” here are Shopify-style carts — multi-item carts synced from Shopify, not an á-la-carte cart you build on the ElasticFunnels page. 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.
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 == 'davincitone-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:

Bumps are set by page events (and can be split-tested)

The bumps in checkout.bump_products are not hard-coded on the page. They are supplied by the funnel graph’s Set Checkout Bumps node (set_checkout_bumps), 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. Because bumps 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.

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:
setVariable("checkout_settings", {…}) and assigning the checkout_settings object are the same thing — the object is read by both the render (to build the rendered dropdowns/labels) and process-checkout (to enforce the same rules on submit). 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.