Offerings & Checkout
This guide covers reading offerings in a page, formatting prices, taking payment, one-tap upsells, and declarative failure recovery. Full signatures are in the components reference and hooks reference.
A design axiom first: Appfunnel does not manage currency. The payment provider resolves the visitor’s price; the SDK receives one { amount, currency } per offering and only displays it, formatted in the active locale. There’s no FX and no currency picker on our side.
Reading an offering
useOffering(slot) takes a slot id from funnel.ts offerings and returns a display-ready Offering, or undefined if the slot is unassigned or not found. useOfferings() returns all offerings in order.
import { useOffering, useOfferings } from '@appfunnel-dev/sdk'
const offering = useOffering('plan') // Offering | undefined
const all = useOfferings() // Offering[]useOffering('plan') returns undefined until the plan slot is assigned to an offering in the editor’s Offerings view. offerings: { plan: null } in funnel.ts is a declared-but-unassigned slot. Always guard the render on the offering existing — the template shows the “add an offering” hint when it’s missing. See Offerings & Catalog.
import { definePage, useOffering, Checkout } from '@appfunnel-dev/sdk'
export default definePage(function Paywall() {
const offering = useOffering('plan')
if (!offering) {
return <p>Assign an offering to the "plan" slot to turn on checkout.</p>
}
return (
<section>
<h1 className="text-3xl font-bold">{offering.name}</h1>
<p className="mt-2 text-xl font-semibold">
{offering.price.formatted}
{offering.period !== 'one-time' ? `/${offering.period}` : ''}
</p>
<Checkout offering="plan" className="mt-6" />
</section>
)
})The Offering shape
Offering is display-ready — prices are already formatted in the active locale.
| Field | Type | Notes |
|---|---|---|
id | string | The slot / display identity. |
catalogKey | string | The offering actually charged (charge identity). |
name / displayName | string | |
price | Money | The list price. price.formatted → $9.99, €9,99, kr 79,00. |
period | string | month, quarter, 2 weeks, one-time, … |
periodly | string | monthly, quarterly, biweekly, one-time, … |
perDay / perWeek / perMonth / perYear | Money | Period-normalized prices for “just $0.27/day” copy. |
hasTrial | boolean | true if there’s a trial. |
trialDays | number | |
trialPrice | Money | null | null when there’s no trial. |
Money is { amount, minor, currency, formatted } — amount is major units (79), minor is minor units (7900), and formatted is the locale-formatted string you render.
catalogKey is the neutral charge-identity key — the provider-neutral name of what actually gets billed. It maps to the offering’s key; it’s not a Stripe/Paddle price id and it isn’t the funnel slot. You rarely read it directly.
The one-time hazard
period for a one-time offering is the literal string 'one-time'. So the naive template pattern:
{
offering.price.formatted
}
{
offering.period ? `/${offering.period}` : ''
}renders $49/one-time — because 'one-time' is a truthy string. Guard on the value, not on existence:
{
offering.price.formatted
}
{
offering.period !== 'one-time' ? `/${offering.period}` : ''
}Checkout
Three components take money, and the one you reach for answers where the payment happens:
import { Checkout, CheckoutButton, UpsellButton } from '@appfunnel-dev/sdk'
<Checkout offering="plan" /> // payment renders right here
<CheckoutButton offering="plan">Get my plan</CheckoutButton> // a button opens it
<UpsellButton offering="addon">Add it</UpsellButton> // one tap, card already savedEach takes the offering slot id, and that’s the only required prop. The SDK handles the purchase analytics (checkout.start, purchase.complete) and advances the funnel after the charge; the card entry and the charge itself run through the store the funnel is published to.
<Checkout> — payment on the page
<Checkout> mounts the payment where you put the tag. There is no button to tap first and no label to write, so it takes no children.
<Checkout offering="plan" className="mt-6" />By default it renders the best complete way to pay for the store this funnel sells through — every visitor who reaches it can finish, whatever wallets they do or don’t have set up.
<CheckoutButton> — payment behind a tap
Same purchase, opened from a button. Default label: “Continue”. Pass asChild with exactly one element to use your own button instead:
<CheckoutButton offering="plan" asChild>
<button className="rounded-lg bg-black px-6 py-3 text-white">
Start my trial
</button>
</CheckoutButton>By default the payment opens in the funnel’s own sheet — the visitor never leaves the page.
<UpsellButton> — one tap, no card re-entry
<UpsellButton> charges the payment method the visitor already saved, off-session. Nothing opens: one tap and it’s bought. It’s the post-purchase upsell primitive, for pages declared type: 'upsell'. Default label: “Add to my plan”.
<UpsellButton offering="addon">Add annual coaching</UpsellButton>If that charge fails, it recovers on-session: the funnel’s sheet asks for a payment method and charges once more, in place, without losing the visitor’s position in the sequence. That’s on by default — write recover={false} to opt out and just report the failure through onError / the checkout.failed event.
Portable by default. None of the three components asks you which provider the funnel sells through — the payment is resolved against whichever store the funnel is published to, so the same paywall works on a Stripe funnel and a Paddle one.
Every option, and what happens if you don’t set it
Only offering is required. Write nothing else and you get a complete, working checkout — this is what each option does when left alone.
| Option | On | Left unset |
|---|---|---|
offering | all three | Required. The slot id from funnel.ts. |
payment | all three | auto — the best way to pay every visitor can finish in. |
methods | <Checkout>, <CheckoutButton> | Accordion, all methods collapsed. |
opens | the two buttons | sheet — the funnel’s own sheet, visitor stays on the page. |
managed | all three | Off — you are merchant of record, through your own provider account. |
recover | <UpsellButton> | true — a failed one-tap charge asks for a payment method in the sheet and retries once. |
appearance | all three | The provider’s own default look for its fields. |
intent | all three | purchase. <UpsellButton> is always an upsell and takes no intent. |
kind | upsells | Taken from the offering itself — a recurring offering charges as a subscription, a one-off as a one-time charge. Set it only to override that. |
advanceOnSuccess | all three | true — a successful charge advances to the next page. |
onFailed | all three | Failures report through onError and the flow stays put. |
Refining what the visitor sees — payment
payment picks the payment UI. It’s on all three components and means the same thing on each; it defaults to auto.
payment | What the visitor sees |
|---|---|
auto | Default. The best way to pay that every visitor can finish in. On phones that’s the real wallet button — Apple Pay / Google Pay / Link — with the card form one tap away. On desktop it’s the method list. |
wallets | The wallet button, for every visitor rather than only on phones. |
card | The method list — card, and whichever wallets the visitor can use, as selectable rows. See How the methods are listed. |
full | The provider’s own complete checkout: its summary, its pay button, its frame. |
The wallet button and the wallet row are not the same thing. wallets — and auto on a phone — gives you Stripe’s own black Apple Pay button: one tap, authorize, done. card lists “Apple Pay” as a row the visitor selects before pressing your pay button. Same payment method, more steps between the visitor and the purchase.
Leading with the wallet never traps anyone. A “Pay another way” control sits under the button and reveals the card form in place — the wallet button stays where it is — and a visitor with no wallet enrolled goes straight to the card form without seeing it at all.
Styling the SDK’s buttons
Two buttons are the SDK’s own rather than the provider’s — the pay button and “Pay another way” — and both ship styled, in your brand colour, with nothing to configure.
Out of the box the pay button is a solid near-black bar at the size and weight a paywall CTA wants, and “Pay another way” is a quiet control beneath it. Write nothing and the checkout looks finished — there’s no setup step here.
When you do want them in your own brand, pass classNames and write Tailwind exactly as you would anywhere else:
<Checkout
offering="plan"
classNames={{
submit: 'w-full rounded-xl bg-emerald-600 py-4 text-lg font-bold text-white',
payAnotherWay: 'mt-3 w-full rounded-xl bg-neutral-100 py-3 font-semibold',
}}
/>Your classes win outright. The defaults are written at zero specificity, so a single Tailwind class beats them — no !important, no fighting inline styles, and no need to know which stylesheet loaded first. The same is true of a plain CSS rule on [data-checkout-submit] or [data-checkout-another-way] if you’d rather keep it in a stylesheet.
The provider’s own card fields are a different question — they render inside its frame, where no class of yours reaches. Theme those with appearance.
Wallet-first paywall. wallets is the one setting that can show a visitor a UI they can’t use — someone with no wallet enrolled has nothing to tap — so give them a second way through:
<section>
<Checkout offering="plan" payment="wallets" />
<CheckoutButton offering="plan" payment="card" className="mt-3 underline">
Pay with a card instead
</CheckoutButton>
</section>Let the provider run the whole purchase. payment="full" hands over the entire moment — the provider draws its own order summary and its own pay button, and you draw nothing:
<Checkout offering="plan" payment="full" />How the methods are listed — methods
Wherever the SDK draws the payment UI itself — payment="card", and payment="auto" where it resolves to the list — methods shapes that list. It works the same on <Checkout> and <CheckoutButton>, including the payment a button opens in the sheet.
By default the methods are an accordion with the first one open. A collapsed list only earns its extra tap when there’s a real choice to make — and where card is the only method, which is common now that wallets lead as their own button, collapsing gives the visitor a row reading “Card” they must press to reach fields they already knew were coming.
<Checkout offering="plan" /> // first method open
<Checkout offering="plan" methods={{ collapsed: true }} /> // all closed, visitor picks
<Checkout offering="plan" methods={{ layout: 'tabs' }} /> // methods across the top
<Checkout offering="plan" methods={{ order: ['apple_pay', 'card'] }} /> // lead with Apple Pay| Field | Default | What it does |
|---|---|---|
layout | 'accordion' | 'accordion' stacks the methods as rows; 'tabs' runs them across the top. |
collapsed | false | Open the first method. true starts them all closed, so the visitor picks first. |
order | unset | Force the leading methods. Left unset, the provider decides the order. |
payAnotherWay | true | Offer “Pay another way” under the wallet button. false makes it wallet-only. |
Wallet-only checkout. payAnotherWay: false gives a visitor with Apple Pay that button and nothing else — no card form to drop into:
<Checkout offering="plan" methods={{ payAnotherWay: false }} />It doesn’t strand anyone who has no wallet: with no wallet to show, there’s no wallet button to stand alone, and they get the card form exactly as they would otherwise. The setting only decides what someone who has a wallet is offered beside it. Be deliberate about it though — a visitor whose card fails inside the wallet sheet has nowhere else to go on that screen.
Below a wallet button, the revealed list leads with card unless you set order yourself — the wallets are already offered above, and opening one as the first row would show its mandate text instead of the card fields the visitor just asked for.
methods is ignored, never an error, wherever the provider owns the whole
UI: payment="full", anything under managed, and Paddle, which has no
equivalent setting. Setting it there does nothing at all.
A wallet only shows up in that list if the visitor has one enrolled and
your funnel’s domain is registered with Stripe as a payment method domain.
Without that, the accordion shows card on its own no matter what order says
— and each custom domain has to be registered separately.
Refining where it opens — opens
opens is on the two button components (<Checkout> has nowhere else to open — it is the payment). It defaults to sheet.
opens | Where the payment appears |
|---|---|
sheet | Default. The funnel’s own sheet. The visitor stays on the page. |
popup | The provider’s own overlay. |
redirect | A full-page hand-off to the provider’s checkout, and back again when it’s paid. |
The in-app-browser escape hatch. Paid social sends traffic through the in-app browsers Meta and TikTok open, and overlays can fail silently in them. opens="redirect" gives up the overlay for a full-page hand-off that always works:
<CheckoutButton offering="plan" opens="redirect">
Continue to payment
</CheckoutButton>The return trip is handled for you — see Redirect-return settlement.
opens="popup" and opens="redirect" hand the provider the whole purchase,
so the only payment values that mean anything there are auto and full.
The types say so — the other two won’t compile alongside them.
On <UpsellButton>, payment and opens describe the recovery — the only thing an upsell ever presents. payment="wallets" re-collects with a wallet button instead of a card form; opens="redirect" recovers on the provider’s page instead of in the funnel’s sheet.
Merchant of record — managed
managed puts Stripe on the hook as merchant of record for that one charge: Stripe owns the tax, the fraud checks and the disputes. It’s decided per checkout, so a funnel can sell one offering managed and another not.
<Checkout offering="plan" managed />Under managed the provider collects the card in its own frame, so payment narrows to auto and full — asking for card or wallets alongside it is a compile error rather than something you find out at publish. Your Stripe account also has to be onboarded to managed payments; the charge is refused if it isn’t.
Paddle checkout is in alpha. The product itself labels it that way and recommends contacting support before using it in production. Treat Stripe as the stable path.
Custom CTAs — useCheckout
Reach for asChild first — it gives your own element the click handler and keeps every default. When you need more than a trigger (your own loading and error states, a charge fired from something that isn’t a button), useCheckout(options) is the headless hook underneath all three components.
import { useCheckout } from '@appfunnel-dev/sdk'
function BuyButton() {
const checkout = useCheckout({ intent: 'purchase' })
return (
<button
onClick={() => checkout.open('plan', 'sheet')}
disabled={checkout.isLoading}
>
{checkout.isLoading ? 'Processing…' : 'Buy now'}
</button>
)
}useCheckout returns { open, status, error, isLoading, reset, ... }. status moves through 'idle' → 'loading' → 'requires_action' → 'success' | 'error'. The options — intent, kind, managed, advanceOnSuccess, onSuccess, onError, onFailed — are the same ones the components accept. advanceOnSuccess defaults to true, so a successful charge advances the funnel automatically.
open’s first argument is the offering slot id. open('plan', 'sheet') opens the funnel’s sheet; open('plan') with no second argument charges off-session, which is what an upsell does.
Declarative failure recovery
Instead of writing branching error logic, map failure categories to recovery routes declaratively. <Checkout> accepts an onFailed map:
<Checkout
offering="plan"
onFailed={{
prepaid_card: { go: 'real-card' }, // route to a page
insufficient_funds: { modal: DownsellModal }, // show a modal
default: { modal: TryAgainModal }, // catch-all
}}
/>Each entry is a FailureRoute, one of:
{ go: pageKey }— navigate to a page.{ modal: name | Component }— show a modal. It receivesFailureModalProps—{ error, product?, go(pageKey), retry() }— because modals mount outside the nav context, those closures are how a recovery modal navigates or retries.{ fallback: 'sheet' }— ask for a payment method again in the funnel’s sheet, and retry once.(error) => void— a callback for full control.
The category keys you can map: card_declined, insufficient_funds, expired_card, prepaid_card, authentication_required, requires_payment_method, processing_error, canceled, already_purchased, unknown, plus the synthetic default. canceled only routes when you map it explicitly, and so does already_purchased — left alone, the visitor already owns it and the flow advances without charging again.
onFailed runs after onError, so you can log in onError and recover in onFailed.
Redirect-return settlement
With opens="redirect" (or opens="popup") the visitor leaves to pay and comes back. When they return, the purchase is verified and resolves into the normal success or failure path automatically — you don’t place or configure anything for it.
Testing with test-mode stores
Test and Live are separate environments in the catalog: an offering binds a Live price and a Test price independently. When you run a funnel in test mode, useOffering resolves the Test binding, and checkout uses the test store — so you can complete a full purchase with test cards without real charges. An offering with no Test binding can’t be QA’d in test mode; the catalog warns you when a Test price is missing. See Offerings & Catalog for binding prices per environment.
Entitlements are granted on the price, and Test and Live prices carry their grants independently. A completed test-mode purchase only unlocks the entitlements you assigned to the Test price — assign them there too if you want to QA the unlocked experience. See Entitlements.
Do it with an agent: “Show the annual plan’s price as a per-month value
with the trial highlighted.” The assistant reads the Offering shape and
wires offering.perMonth.formatted and the trial fields into your paywall
copy.