Skip to Content

Commerce

Every commerce export of @appfunnel-dev/sdk, generated from the SDK source. Signatures and descriptions are exact; for how these fit together, see the guides.

useCheckout

hook · commerce/checkout.tsx

useCheckout(opts?: UseCheckoutOptions): CheckoutHandle

The headless checkout hook. Owns the state machine + analytics + post-charge navigation; delegates the charge to the injected CheckoutDriver.

useOffering

hook · commerce/catalog.ts

useOffering(id: string | undefined): Offering | undefined

useOffering('monthly') — one offering for a slot, formatted in the active locale.

Resolution precedence:

  1. the slot’s real, assigned+resolved offering (a provider-resolved price) → the Offering (no sample flag);
  2. else the funnel’s SampleOffering for that slot (declared in defineFunnel({ sampleOfferings })) → the SAME Offering shape flagged sample: true, so a page renders identically while the slot is unassigned;
  3. else undefined (unknown/unassigned slot with no sample).

A sample only ever surfaces for an UNASSIGNED slot, which the LIVE-publish gate blocks from promotion — so samples appear only in editor/preview + appfunnel dev, never to a paying visitor. <Checkout>/<UpsellButton> refuse to charge a sample offering (the server refuses an unassigned slot regardless).

useOfferings

hook · commerce/catalog.ts

useOfferings(): Offering[]

useOfferings() — every offering, formatted in the active locale, in order.

Checkout

component · commerce/checkout.tsx

Checkout(props: CheckoutProps): ReactNode

The purchase, rendered right here. The provider’s payment UI mounts where you put this tag — no button, no overlay, nothing to tap first.

<Checkout offering="plan" /> // the best complete payment UI for this provider <Checkout offering="plan" payment="card" /> // a card form <Checkout offering="plan" payment="full" /> // the provider's whole checkout, in the page

payment (PaymentMode) is the only decision, it defaults to 'auto', and 'auto' always resolves to a COMPLETE way to pay — a buyer who reaches this can always finish, whatever wallets they do or don’t have. To open the payment from a button instead, reach for CheckoutButton; that is the whole difference between the two, and it’s carried by which tag you write rather than by a prop.

CheckoutButton

component · commerce/checkout.tsx

CheckoutButton(props: CheckoutButtonProps): ReactNode

The purchase, opened from a button. Same charge, same payment axis; this one waits for a tap and then presents the payment somewhere else.

<CheckoutButton offering="plan">Get my plan</CheckoutButton> // our sheet <CheckoutButton offering="plan" opens="redirect">Continue</CheckoutButton> // the provider's page <CheckoutButton offering="plan" asChild><MyButton /></CheckoutButton> // your own trigger

opens (OpensIn) defaults to 'sheet' — the funnel’s own overlay, hosting whatever the provider renders, buyer never leaves the page. 'redirect' is the one to reach for inside the iOS in-app browsers Meta and TikTok open, where overlays can fail silently.

asChild clones YOUR element and gives it the click handler; the type requires exactly one element child when you use it.

CheckoutResume

component · commerce/checkout.tsx

CheckoutResume(options?: UseCheckoutOptions): ReactNode

The dangling settle leg of the redirect surface. The server appends af_checkout={correlationId} to hosted-checkout success URLs; when the buyer lands back in the funnel this component detects the param, has the server VERIFY the attempt (driver.resumePOST /complete, bounded re-poll while pending), and resolves it into the normal machinery:

  • settledpurchase.complete fires with the settlement’s server-minted eventId (pixel↔CAPI dedup), the flow advances, and the param is stripped (history.replaceState) so a reload can’t re-run the leg;
  • failed → the normal failure path (checkout.failed + status error; any onFailed maps passed as props apply — product-less, so only go/handler routes can act).

SSR-safe (effect-only) and runs once per correlationId per page load. Mounted automatically by createFunnelTree inside the nav context; needs a driver with resume (both platform drivers ship it; the mock resolves its result).

CheckoutSpinner

component · commerce/checkout.tsx

CheckoutSpinner(): ReactNode

The busy indicator the SDK shows while a charge is in flight — a circular track with a moving arc, drawn in currentColor so it takes the button’s own text colour wherever it lands.

SVG rather than a bordered div: a border-spinner inherits the button’s box sizing and breaks the moment someone restyles the button, and it can’t be recoloured by text colour alone. Marked aria-hidden because the state is already carried by the button’s disabled — a screen reader announcing a decorative circle adds nothing.

UpsellButton

component · commerce/checkout.tsx

UpsellButton(props: UpsellButtonProps): ReactNode

The upsell. One tap charges the card the buyer already gave you — off-session, no surface, no card re-entry. Nothing opens unless that charge fails.

<UpsellButton offering="addon">Add it</UpsellButton> <UpsellButton offering="addon" payment="wallets">Add it</UpsellButton> // recover with a wallet <UpsellButton offering="addon" recover={false}>Add it</UpsellButton> // don't recover at all

When it fails it RECOVERS on-session by default (UpsellCommonProps.recover): the SDK’s sheet re-collects and charges again, once, and the retry’s own failure routes through onFailed (e.g. prepaid_card → a recapture page) instead of dead-ending. payment and opens describe THAT — there is nothing else for them to describe, since the one-tap charge shows no UI at all.

managed (UseCheckoutOptions.managed) applies here as anywhere else — this is the checkout that decides, so the two upsell shapes can coexist in one funnel: a MANAGED upsell buys Merchant-of-Record coverage, while the default non-managed one keeps the one tap.

buildCatalog

function · commerce/money.ts

buildCatalog(inputs: OfferingInput[]): Map<string, ResolvedOffering>

Build the id → ResolvedOffering catalog. Formatting happens at read time.

checkoutError

function · commerce/capabilities.ts

checkoutError(category: CheckoutErrorCategory, message: string, extra?: { declineCode?: string; retryable?: boolean; }): CheckoutError

Build a CheckoutError (driver/test helper).

createMockDriver

function · commerce/checkout.tsx

createMockDriver(provider?: CheckoutProvider, options?: MockDriverOptions): CheckoutDriver

A console/no-op driver — tests, preview, local dev. Succeeds unless configured to fail.

currencyExponent

function · commerce/money.ts

currencyExponent(currency: string): number

The minor-unit exponent for a currency (USD→2, JPY→0, KWD/BHD→3), derived from Intl.NumberFormat(...).resolvedOptions().maximumFractionDigits with a fallback of 2 for unknown/invalid codes.

driverWithEmail

function · commerce/checkout.tsx

driverWithEmail(driver: CheckoutDriver, getEmail: () => string | undefined): CheckoutDriver

Wrap a driver so every attempt carries the buyer email the funnel has collected (user.email), read at ATTEMPT time — start() for trigger surfaces, the renderInline request for mounted surfaces. Applied by FunnelProvider around the injected driver, so useCheckout, <Checkout> inline branches and the checkout sheet all forward it without wiring; drivers pass it into the wire’s CheckoutSessionRequest.email. An explicit req.email always wins.

formatMoney

function · commerce/money.ts

formatMoney(minor: number, currency: string, locale: string): Money

Format minor units of currency in locale (exponent-aware: 7900 JPY = ¥7,900).

formatOffering

function · commerce/money.ts

formatOffering(r: ResolvedOffering, locale: string): Offering

Format a ResolvedOffering into a display Offering in locale.

formatSampleOffering

function · commerce/money.ts

formatSampleOffering(slot: string, sample: SampleOffering, locale: string): Offering

Materialize a SampleOffering for slot into the display Offering shape (flagged sample: true), in locale.

A sample IS a real offering’s input, so this just runs it through the exact same resolveOfferingformatOffering pipeline a real offering takes (the slot key supplies the id) and stamps sample: true. There is no sample-specific pricing or formatting logic — every priced field (price, perDay, period, …) is derived identically, so useOffering’s return is indistinguishable from a real offering apart from the flag.

isCompleteSurface

function · commerce/capabilities.ts

isCompleteSurface(provider: CheckoutProvider, surface: CheckoutSurface, variant?: ProviderVariant): boolean

Whether this surface is a COMPLETE way to pay on this provider — see *. An unsupported surface is false: there is no rendering at all, which is certainly not a complete one.

The gate on every 'auto' resolution, and deliberately NOT a gate on anything else: pinning payment="wallets" is an author choosing a partial UI on purpose (a wallet-only paywall above a separate “other ways to pay” link is a real pattern), so nothing here refuses it.

isInlineSurface

function · commerce/capabilities.ts

isInlineSurface(surface: CheckoutSurface): boolean

isMerchantOfRecord

function · commerce/capabilities.ts

isMerchantOfRecord(provider: CheckoutProvider, variant?: ProviderVariant): boolean

Whether a provider is a Merchant of Record (it owns tax/compliance). Variant-aware: plain Stripe is a PSP you settle through, but Stripe managed is MoR — the same account, a different answer per checkout, which is why the variant has to reach this question.

isOrchestrator

function · commerce/capabilities.ts

isOrchestrator(provider: CheckoutProvider): boolean

Whether a provider routes across multiple processors (Primer, SolidGate).

isProviderHostedSurface

function · commerce/capabilities.ts

isProviderHostedSurface(surface: CheckoutSurface): boolean

Whether the provider itself collects/confirms the card on this surface (see PROVIDER_HOSTED_SURFACES).

isRenderableSurface

function · commerce/capabilities.ts

isRenderableSurface(provider: CheckoutProvider, surface: CheckoutSurface, variant?: ProviderVariant): boolean

A surface the provider offers, can take a purchase on, AND our driver renders now (not 'planned').

profileFor

function · commerce/capabilities.ts

profileFor(provider: CheckoutProvider, variant?: ProviderVariant): ProviderProfile

Resolve (provider × variant) to the profile every other function in this file reads. THE seam for correction 4: a variant is not a flag threaded through the matrix, it selects a different matrix row, so validateCheckout/resolveSurface/trialShapesFor/… need no managed-specific logic — they ask for a profile and the answers narrow themselves.

managed: true on a provider with NO managed variant deliberately resolves to the BASE profile rather than throwing or returning undefined: this resolver is total and is called on hot paths (surface resolution runs per mount), and a silent capability expansion is the only dangerous failure — falling back to the base profile can only be more restrictive or equal. The rejection is not dropped, it’s MOVED: validateCheckout, validateUpsell, validateTrial and validateTrialSurface all fail an unoffered managed request closed, with a reason string an author can act on. Publish and the server run those, so an unsupported combination can never reach a buyer.

renderableSurfaces

function · commerce/capabilities.ts

renderableSurfaces(provider: CheckoutProvider, variant?: ProviderVariant): CheckoutSurface[]

The provider’s renderable purchase surfaces (drives the 'auto' fallbacks + the publish gate).

resolveOffering

function · commerce/money.ts

resolveOffering(input: OfferingInput): ResolvedOffering

Resolve an OfferingInput into amounts + period math (no formatting yet). Per-period amounts are integer minor units (each derived from the exact daily rate, then rounded — never round-then-multiply, so error doesn’t compound).

resolvePayment

function · commerce/capabilities.ts

resolvePayment(provider: CheckoutProvider, payment: PaymentMode, deviceClass: DeviceClass, variant?: ProviderVariant): PaymentRendering

Resolve a PaymentMode to the concrete rendering the driver will mount. PURE, and the same function the publish gate reaches through — the client resolves once per mount and puts the CONTAINER on the wire, exactly as resolveSurface has always done.

A concrete mode maps straight through PAYMENT_RENDERINGS — the author’s pin is never silently rewritten, and an impossible pin (a card form under managed) is refused rather than substituted. 'auto' walks AUTO_SURFACE_ORDER restricted to the renderings (a container can’t be a payment UI), taking the first that is renderable AND complete — so 'auto' cannot produce a wallet strip on Stripe, whatever the device says.

Total, like resolveSurface: a provider with nothing renderable falls back to 'element' so callers need no null branch, and validateCheckout then fails that closed at publish (and, in the browser, through the same refusal path a bad managed pin takes).

resolveSurface

function · commerce/capabilities.ts

resolveSurface(provider: CheckoutProvider, preference: SurfacePreference, deviceClass: DeviceClass, variant?: ProviderVariant): CheckoutSurface

Resolve a SurfacePreference to the concrete surface the runtime will actually mount, for a given provider and device class (§7 item 3). PURE — the client resolves once per mount and puts the result on the wire (the wire stays surface-literal; the server re-validates via *), and the publish gate resolves it the same way. Deterministic:

  • an EXPLICIT surface resolves to ITSELF — the author’s pin is never silently rewritten (an unsupported pin, e.g. express on a provider that can’t render it, is caught by validateCheckout at publish, exactly as today);
  • 'auto' → mobile picks the provider’s best wallet-first surface that is both RENDERABLE and a COMPLETE way to pay (Paddle’s wallet-first frame; on Stripe the wallet strip is skipped and the PaymentElement wins, wallets still at the top of it); desktop picks the card field / embedded frame. Only driver-ready surfaces are considered, so the result always renders, and only complete ones, so it always finishes.

Totality guard: a provider with NO driver-ready surface (every surface 'planned', or unknown provider) falls back to its first declared surface (unknown → a nominal default) so the function is total; downstream validateCheckout/flow-select then fail that closed. In practice 'auto' is only authored on a funnel’s ONE resolved (full-status) provider, which always ha…

supportsManaged

function · commerce/capabilities.ts

supportsManaged(provider: CheckoutProvider): boolean

Whether a provider offers a managed (provider-as-MoR) variant at all — Stripe only, today.

surfacesFor

function · commerce/capabilities.ts

surfacesFor(provider: CheckoutProvider, variant?: ProviderVariant): CheckoutSurface[]

Surfaces a provider supports, for the editor/AI to steer authors.

validateCheckout

function · commerce/capabilities.ts

validateCheckout(provider: CheckoutProvider, surface: CheckoutSurface, variant?: ProviderVariant): ValidationResult

Validate a (provider, surface) for a purchase: the surface must exist for the provider and accept a payment. Surface availability is the real constraint (e.g. Paddle has no element).

With a ProviderVariant the check runs against the variant’s profile, so authoring a managed checkout on an Elements surface fails here — and says WHY it’s unavailable under managed specifically, rather than the (untrue) “stripe has no ‘element’ surface”.

validateTrialSurface

function · commerce/capabilities.ts

validateTrialSurface(provider: CheckoutProvider, surface: CheckoutSurface, variant?: ProviderVariant): ValidationResult

Validate the SURFACE a trial will be sold on — the check that used to be a hard platform ban (“trials never run on hosted Checkout”, doc 09 §2.7) and is now a judgement the author gets to make, because Stripe managed trials have no other option (managed can’t create a subscription outside Checkout, so a managed trial IS a hosted trial).

Never fatal on the §2.7 grounds — the funnel charges correctly either way. What a non-managed hosted trial forfeits is FUTURE work: it pins the trial to Stripe’s legacy trial_end composition, so when Stripe Trial Offers reaches GA it won’t get the swap for free (doc 09 §2.6 designed that swap to be a driver change with no funnel/catalog/schema edit — but Trial Offers are incompatible with hosted Checkout, so a hosted funnel would need a surface migration first). That’s a note, not a reason: publish must not block on it.

Managed hosted trials get NO note. Hosted is their only surface, so there is nothing to warn about and nothing to migrate — their mechanism (*_session) was never on the Trial Offers path to begin with.

Hard failures still stand: an unoffered variant, a provider that can’t run trials at all, and a surface the provider/driver can’t render (delegated to validateCheckout).

validateUpsell

function · commerce/capabilities.ts

validateUpsell(provider: CheckoutProvider, paywallSurface: CheckoutSurface, kind?: UpsellKind, variant?: ProviderVariant): ValidationResult

Validate an off-session upsell of kind after a purchase made on paywallSurface. Combines provider support for the kind (Paddle can’t stack a 2nd subscription) with the PROFILE’s off-session reliability (Stripe managed checkout is conditional — returned as a note, not a hard block).

The surface still has to be passed and still has to exist, but only to establish that there WAS a paywall of that shape to upsell after; it no longer decides the reliability (see ProviderProfile.offSession). Base Stripe therefore stops emitting the conditional note on embedded/redirect, which was a false warning: a base hosted session vaults to our own Customer exactly as Elements does.

The optional ProviderVariant describes the PAYWALL — the purchase that left the saved method behind. A managed purchase still leaves one (the subscription’s card is attached to the Stripe Customer), so every upsell kind stays legal after it; only the reliability is conditional, which this already surfaced as a note before managed existed as a variant.

CheckoutAppearance

interface · commerce/checkout.tsx

export interface CheckoutAppearance { stripe?: StripeAppearance paddle?: Pick<PaddleCheckoutSettings, 'theme' | 'frameStyle' | 'frameInitialHeight'> }

Theming for the payment fields the PROVIDER renders.

Those fields live in a cross-origin iframe, so no amount of CSS in the funnel reaches them — the provider has to be told how to paint them. (The funnel’s own submit button is ordinary DOM and needs none of this; style it with the [data-checkout-submit] attribute.)

DELIBERATELY NOT NORMALIZED. Providers share almost nothing here: Stripe Elements takes ~100 design tokens plus per-selector rules, Paddle takes a light/dark flag and puts everything else in its dashboard, Whop exposes four tokens, SolidGate takes per-element CSS and no tokens at all. The true intersection is roughly { theme }, so a common shape would be either useless or a lie about what actually applies. Instead each provider gets its OWN key, typed straight from that provider’s package — nothing here to keep in sync as they evolve, and no silent dropping of a token the provider never supported.

<Checkout offering="plan" payment="card" appearance={{ stripe: { variables: { colorPrimary: '#2cad39', fontFamily: 'Inter, sans-serif' } }, }} />

Only the surfaces the SDK mounts itself can be themed this way — element, express, and the sheet that hosts them. embedded is the provider’s own hosted page (Stripe serves it from js.stripe.com), which takes its styling from that provider’s dashboard instead.

CheckoutCallbacks

interface · commerce/checkout.tsx

export interface CheckoutCallbacks { onSuccess: (result: CheckoutResult) => void onError: (error: CheckoutError) => void onStart?: () => void onPaymentAdded?: () => void }

CheckoutClassNames

interface · commerce/checkout.tsx

export interface CheckoutClassNames { submit?: string payAnotherWay?: string }

CheckoutCommonProps

interface · commerce/checkout.tsx

export interface CheckoutCommonProps extends Omit<UseCheckoutOptions, 'managed'> { offering: string className?: string appearance?: CheckoutAppearance methods?: CheckoutMethods }

Everything every checkout component takes, whatever it renders and wherever it renders it.

CheckoutDriver

interface · commerce/checkout.tsx

export interface CheckoutDriver { provider: CheckoutProvider start(req: CheckoutRequest): Promise<CheckoutResult> renderInline?(req: CheckoutRequest, cb: CheckoutCallbacks): ReactNode resume?(correlationId: string): Promise<CheckoutResult> }

The provider seam. The platform injects the real one (Stripe/Paddle/Whop/…); tests use createMockDriver.

Most providers ship their own React SDK (@stripe/react-stripe-js, @paddle/paddle-js, Primer’s React components, …). A driver’s renderInline returns exactly that: the provider’s React element mounted in our chrome (inline, or inside the <Sheet>). Those provider SDKs live in the driver/platform package, not our core funnel SDK — which is why the driver is injected: the core stays lean and provider-agnostic, and you only pull the SDK for the provider you use.

CheckoutDriverContext

interface · commerce/checkout.tsx

export interface CheckoutDriverContext { apiBase: string campaignId?: string | null preview?: { buildId: string token: string } | null funnelId: string mode: 'test' | 'live' sessionId?: string | null visitorId?: string | null }

What a driver factory (stripeCheckoutDriver et al. from the @appfunnel-dev/sdk/driver-* entries) receives to construct a CheckoutDriver. Derived by createFunnelTree from the SAME renderer-injected mount data the platform tracker uses (tracking config + identity), so checkout wire calls land on the exact campaign/funnel/session the analytics do — there is no second config channel to drift.

Serializable on purpose, and factories must be construction-safe on the server too: a factory only closes over this ctx (no provider SDK, no window) — all provider work belongs in effects/event handlers, so SSR can render the driver’s stable inline placeholder and hydration matches.

CheckoutError

interface · commerce/capabilities.ts

export interface CheckoutError { category: CheckoutErrorCategory message: string declineCode?: string retryable: boolean }

CheckoutHandle

interface · commerce/checkout.tsx

export interface CheckoutHandle { open: ( product: string, surface?: CheckoutSurface ) => Promise<CheckoutResult> status: CheckoutStatus error: CheckoutError | null isLoading: boolean reset: () => void callbacksFor: ( product?: string, surface?: CheckoutSurface ) => CheckoutCallbacks & { onStart: () => void } }

CheckoutMethods

interface · commerce/checkout.tsx

export interface CheckoutMethods { layout?: 'accordion' | 'tabs' collapsed?: boolean order?: string[] payAnotherWay?: boolean }

How the list of payment methods is PRESENTED, in the surfaces the SDK mounts itself.

Normalized, unlike CheckoutAppearance — and the difference is real rather than an inconsistency. Appearance is a token bag whose true cross-provider intersection is roughly empty, so a shared shape there would be a lie. “Stack the methods and start them collapsed” is one idea with one meaning, so it gets one spelling.

The DEFAULT is { layout: 'accordion', collapsed: true }: the buyer sees the methods they can pay with — wallets included — and picks one before any field appears, instead of landing on an open card form with the wallet they’d have preferred hidden below it.

<Checkout offering="plan" /> // accordion, collapsed <Checkout offering="plan" methods={{ collapsed: false }} /> // first method open <Checkout offering="plan" methods={{ layout: 'tabs' }} /> // methods across the top <Checkout offering="plan" methods={{ order: ['apple_pay', 'card'] }} /> // lead with Apple Pay

Only applies where the SDK mounts the method listpayment="card", and the payment="auto" fallback when no wallet is enrolled, on Stripe. It is IGNORED, never an error, when the provider owns the whole UI: payment="full", anything under managed, and Paddle, which has no equivalent setting. Wallets also have to be able to …

CheckoutRequest

interface · commerce/checkout.tsx

export interface CheckoutRequest { product: string catalogKey?: string intent: CheckoutIntent surface?: CheckoutSurface rendering?: CheckoutSurface kind?: UpsellKind managed?: boolean email?: string appearance?: CheckoutAppearance methods?: CheckoutMethods classNames?: CheckoutClassNames }

CheckoutResult

interface · commerce/checkout.tsx

export interface CheckoutResult { ok: boolean error?: CheckoutError amountMinor?: number currency?: string eventId?: string }

FailureModalProps

interface · commerce/checkout.tsx

export interface FailureModalProps { error: CheckoutError product?: string go: (pageKey: string) => void retry: (surface?: CheckoutSurface) => void }

What a { modal } failure route hands the modal — the error AND the actions, because registry modals mount outside the flow’s nav context and a recovery modal that can’t act is just an error toast. Type your modal with this:

const Declined = defineModal<FailureModalProps>(({ error, go, retry }) => ( <Sheet> <p>{error.category === 'prepaid_card' ? 'Prepaid cards can't start a trial.' : 'Payment didn't go through.'}</p> <button onClick={() => retry('sheet')}>Try another card</button> <button onClick={() => go('downsell')}>See the lite plan</button> </Sheet> ))

A modal can also host a whole <Checkout offering="…" payment="card" /> for an inline downsell — the driver context is app-level, so checkout works inside modals as-is.

MockDriverOptions

interface · commerce/checkout.tsx

export interface MockDriverOptions { result?: CheckoutResult failOffSession?: CheckoutError | boolean }

Money

interface · commerce/money.ts

export interface Money { amount: number minor: number currency: string formatted: string }

A money value with a locale-formatted string.

Offering

interface · commerce/money.ts

export interface Offering { id: string catalogKey: string name: string displayName: string provider: string interval: Interval price: Money period: string periodly: string perDay: Money perWeek: Money perMonth: Money perYear: Money hasTrial: boolean trialDays: number trialPrice: Money | null sample?: boolean }

Display-ready offering the author reads (every amount locale-formatted).

OfferingInput

interface · commerce/money.ts

export interface OfferingInput { id: string catalogKey?: string name?: string displayName?: string amount: number currency: string interval?: Interval intervalCount?: number trialDays?: number trialAmount?: number provider?: string }

What the platform hands the funnel per offering (a single provider-resolved price).

ProviderProfile

interface · commerce/capabilities.ts

export interface ProviderProfile { isMoR: boolean offSessionUpsells: UpsellKind[] offSession: OffSessionReliability tokenModel: 'merchant' | 'provider' orchestrator: boolean status: 'full' | 'planned' trialShapes: TrialShape[] surfaces: Partial<Record<CheckoutSurface, SurfaceCapability>> }

ProviderVariant

interface · commerce/capabilities.ts

export interface ProviderVariant { managed?: boolean }

A per-checkout modifier that resolves the provider to a DIFFERENT ProviderProfile (see correction 4 above). Today that means exactly one thing — Stripe Managed Payments — but it’s an object rather than a boolean so a second variant (a different MoR mode, a regional entity) doesn’t re-break every signature that threads it.

Everything downstream takes it as an OPTIONAL TRAILING argument, so an absent variant is the plain provider and no existing call site changes.

ResolvedOffering

interface · commerce/money.ts

export interface ResolvedOffering { id: string catalogKey: string name: string displayName: string provider: string interval: Interval currency: string priceMinor: number perDayMinor: number perWeekMinor: number perMonthMinor: number perYearMinor: number period: string periodly: string hasTrial: boolean trialDays: number trialMinor: number }

The offering with its amounts resolved (currency + period math), before locale formatting — what the catalog stores. useOffering formats it.

SurfaceCapability

interface · commerce/capabilities.ts

export interface SurfaceCapability { purchase: boolean wallets: boolean complete: boolean mountable: boolean driver?: 'planned' }

TriggerState

interface · commerce/checkout.tsx

export interface TriggerState { loading: boolean disabled: boolean spinner: ReactNode }

What the trigger tells you about the attempt it is running, handed to a render-prop child.

loading is the charge actually being in flight — the window where a second tap would be a second attempt. disabled is what the SDK would put on its own button, and today they are the same fact; they are two names because “is something happening” and “should this be pressable” stop agreeing the moment anything else can disable a trigger.

UpsellCommonProps

interface · commerce/checkout.tsx

export interface UpsellCommonProps extends Omit<UseCheckoutOptions, 'intent' | 'managed'> { offering: string recover?: boolean | UpsellRecovery className?: string }

What UpsellButton takes beyond the shared axes (managed comes in via PaymentAxis).

UpsellRecovery

interface · commerce/checkout.tsx

export interface UpsellRecovery { payment?: PaymentRendering }

Recovery, described by what it should ask the buyer for — see UpsellButtonProps.recover.

UseCheckoutOptions

interface · commerce/checkout.tsx

export interface UseCheckoutOptions { classNames?: CheckoutClassNames intent?: CheckoutIntent kind?: UpsellKind managed?: boolean advanceOnSuccess?: boolean onSuccess?: (result: CheckoutResult) => void onError?: (error: CheckoutError) => void onFailed?: OnFailedMap recoverySurface?: CheckoutSurface }

ValidationResult

interface · commerce/capabilities.ts

export interface ValidationResult { ok: boolean reason?: string note?: string }

CheckoutButtonProps

type · commerce/checkout.tsx

export type CheckoutButtonProps = CheckoutCommonProps & PaymentAxis & OpensAxis & TriggerAxis

Props for CheckoutButton — a purchase the buyer opens from a button.

CheckoutErrorCategory

type · commerce/capabilities.ts

export type CheckoutErrorCategory = | 'card_declined' | 'insufficient_funds' | 'expired_card' | 'prepaid_card' // card funding is prepaid — trials/subscriptions commonly decline; route to a real-card recapture page | 'authentication_required' // SCA/3DS needed — off-session can't challenge | 'requires_payment_method' // no usable saved method | 'processing_error' | 'canceled' | 'already_purchased' | 'unknown'

Normalized failure category — the driver/platform maps each provider’s decline codes into one of these so funnels can route/recover by reason. authentication_ required (3DS/SCA) and requires_payment_method are the cases an off-session upsell can’t satisfy → recover on-session (re-collect a card).

Two members are not failures of the charge at all and the runtime treats them as such: canceled (the buyer dismissed the checkout) and already_purchased (they own it already).

CheckoutInlineProps

type · commerce/checkout.tsx

export type CheckoutInlineProps = PaymentAxis & { surface?: never opens?: never asChild?: never children?: never }

<Checkout>: the payment renders HERE, so there is no trigger and no children.

children?: never is the point of the split. <Checkout payment="card" asChild><button/></Checkout> used to compile and silently drop the button — the inline branch never read either prop — which is the class of mistake a conditional API produces and a structural one cannot.

CheckoutIntent

type · commerce/capabilities.ts

export type CheckoutIntent = 'purchase' | 'upsell'

CheckoutProps

type · commerce/checkout.tsx

export type CheckoutProps = CheckoutCommonProps & CheckoutInlineProps

Props for Checkout — the payment, rendered where the tag sits.

CheckoutProvider

type · commerce/capabilities.ts

export type CheckoutProvider = 'stripe' | 'paddle' | 'whop' | 'primer' | 'solidgate'

CheckoutStatus

type · commerce/checkout.tsx

export type CheckoutStatus = | 'idle' | 'loading' | 'requires_action' | 'success' | 'error'

CheckoutSurface

type · commerce/capabilities.ts

export type CheckoutSurface = | 'express' // wallet quick-buttons (Apple/Google Pay) — inline | 'element' // inline card form — inline | 'embedded' // provider-managed inline checkout — inline | 'sheet' // bottom-sheet popup — opens on click | 'popup' // provider-hosted overlay — opens on click | 'redirect'

The provider capability matrix + checkout error taxonomy — the PURE half of the checkout seam, extracted from checkout.tsx so it ships through the manifest entry (no React): the API’s publish validation, the editor, AI authoring, and local dev all run the SAME checks the runtime primitives use (phase-3 §3.1b — “the SDK ships this as data”). The React half (driver seam, useCheckout, <Checkout>/<UpsellButton>) stays in ./checkout, which re-exports everything here.

DeviceClass

type · commerce/capabilities.ts

export type DeviceClass = 'mobile' | 'desktop'

Device class the surface resolver picks against — mobile gets the wallet-first surface.

FailureRoute

type · commerce/checkout.tsx

export type FailureRoute = | { go: string } // navigate the flow to a page key (e.g. a real-card recapture page) | { modal: string | ComponentType<any> } // show a registry modal — it receives {@link FailureModalProps} | { fallback: CheckoutSurface } // re-collect a card ON-SESSION on this surface and retry (once) | ((error: CheckoutError) => void)

One declarative recovery route for a failure category.

HostedPaymentMode

type · commerce/capabilities.ts

export type HostedPaymentMode = Extract<PaymentMode, 'auto' | 'full'>

The payment modes that don’t mount card fields of OURS. The narrowed set wherever the provider insists on collecting the card itself: under managed (Stripe Managed Payments doesn’t support custom payment forms) and behind opens="popup"/"redirect" (the provider is painting a whole page of its own — there is nothing of ours in it to configure).

Exported because it is the type authors will see in an error message, and because the components build their managed/opens prop unions out of it — a compile error at the point of the mistake instead of a publish rejection or, worse, a provider 400 at the buyer’s paywall.

Interval

type · commerce/money.ts

export type Interval = 'day' | 'week' | 'month' | 'year' | 'one_time'

Pure catalog math — no React, no I/O. Split out of ./catalog so the server/tooling entry (@appfunnel-dev/sdk/manifest) can compile/resolve offerings without pulling react-dom or any component code into its graph. ./catalog re-exports everything here and adds the React wiring.

OffSessionReliability

type · commerce/capabilities.ts

export type OffSessionReliability = | 'reliable' // we hold the saved method (Stripe Elements / SolidGate) or the provider charges it (Paddle / Whop API) | 'conditional' // possible but may decline — Stripe **managed** checkout (vault charge, SCA not guaranteed) or an orchestrator's PSP-dependent routing (Primer) | 'none'

Reliability of an off-session upsell charge AFTER a purchase through a given profile.

OnFailedMap

type · commerce/checkout.tsx

export type OnFailedMap = Partial< Record<CheckoutErrorCategory | 'default', FailureRoute> >

Declarative failure recovery — route each failure category to what happens next, instead of hand-wiring onError:

<Checkout offering="monthly" payment="card" onFailed={{ prepaid_card: { go: 'real-card' }, insufficient_funds: { modal: DownsellModal }, default: { modal: TryAgainModal }, }} />

Resolution: exact category → default → nothing (the error state + checkout. failed event stand, as today). Four built-in judgments:

  • canceled only routes when mapped EXPLICITLY — a deliberately dismissed sheet/popup is not a failure to recover from, so default never catches it;
  • already_purchased likewise: unmapped, the buyer owns it and the flow ADVANCES as a success would, no purchase event (CheckoutErrorCategory);
  • a { fallback } recovery runs at most once per attempt chain (its own failure routes through the non-fallback routes), so declines can’t loop;
  • a { fallback } reached through default is skipped for a NON-retryable error — a card form can’t fix a rejection. Mapped to the CATEGORY it always runs.

checkout.failed fires with category + declineCode on every failure regardless of routing — recovery pages are measurable, and being ordinary flow pages they are A/B-able with page experiments like anything else.

OpensAxis

type · commerce/checkout.tsx

export type OpensAxis = | { opens?: 'sheet' | undefined } | { opens: Exclude<OpensIn, 'sheet'> payment?: HostedPaymentMode }

The opens × payment constraint. 'popup' and 'redirect' are the provider’s own overlay and the provider’s own page — whole checkouts they paint themselves — so there is nothing of ours inside to configure and payment narrows the same way managed narrows it. 'sheet' is OUR overlay hosting whatever the provider renders, so every mode stays available there.

OpensIn

type · commerce/capabilities.ts

export type OpensIn = 'sheet' | 'popup' | 'redirect'

WHERE a <CheckoutButton>/<UpsellButton> presents the payment once it’s tapped.

  • 'sheet' (the default) — the funnel’s own overlay, hosting whatever the provider renders. The buyer never leaves the page.
  • 'popup' — the provider’s own overlay, painted and positioned by them.
  • 'redirect' — a full-page navigation to the provider’s hosted checkout, and back afterwards.

'redirect' is kept reachable on purpose, and is not interchangeable with 'popup': inside the iOS in-app browsers that Meta and TikTok open, overlays can fail silently — which is precisely where paid-social funnels live. Forcing the full-page hand-off is the escape hatch, so it stays an author’s decision rather than something the provider “decides for you”.

PaymentAxis

type · commerce/checkout.tsx

export type PaymentAxis = | { managed?: false | undefined payment?: PaymentMode } | { managed: boolean payment?: HostedPaymentMode }

The managed × payment constraint, as a type rather than a runtime rejection.

Under Stripe Managed Payments the provider collects the card in its own frame — custom payment forms are unsupported — so the two modes that mount Elements components of ours ('card', 'wallets') cannot exist there. That has always been true; what changed is WHEN you find out. Before, <Checkout payment="card" managed> compiled, failed publish validation if the scanner caught it, and otherwise reached the browser to be refused on mount (see refuseSurface). Now the pair doesn’t type-check, and the author reads the constraint in their editor.

Three members rather than two because managed is often a variable (a funnel A/B-ing MoR against its own Stripe account writes managed={testing.managed}): a non-literal boolean lands on the second member and gets the CONSERVATIVE narrowing, which is the only safe answer when the value isn’t known until runtime.

PaymentMode

type · commerce/capabilities.ts

export type PaymentMode = 'auto' | 'wallets' | 'card' | 'full'

WHAT the buyer sees — the payment axis, and the only checkout question an author has to have an opinion about. Independent of where it is presented, so the same four words mean the same four things in the page, in the sheet, and in a recovery.

  • 'auto' (the default) — the best COMPLETE payment UI this provider can render on this device. Wallet-forward where the provider’s wallet-first rendering is a whole checkout, a card form with wallets on top of it otherwise. Never a partial UI: see SurfaceCapability.complete.
  • 'wallets' — the wallet quick-buttons ALONE (Apple Pay / Google Pay / Link). Deliberately the only way to ask for a UI a buyer might not be able to use.
  • 'card' — our own card form (the provider’s inline card element, mounted in our DOM).
  • 'full' — the provider’s COMPLETE checkout: its own summary, its own pay button, its own frame. What you want when the provider should own the whole purchase moment.

PaymentRendering

type · commerce/capabilities.ts

export type PaymentRendering = Extract<CheckoutSurface, 'express' | 'element' | 'embedded'>

A payment UI a provider can be asked to RENDER — the inline family, and only it. sheet, popup and redirect name containers, and a container is not something a provider renders.

This is what PaymentMode resolves to, and what travels on CheckoutRequest.rendering.

SampleOffering

type · commerce/money.ts

export type SampleOffering = Omit<OfferingInput, 'id'>

Per-slot sample pricing the funnel author declares in defineFunnel’s sampleOfferings block. It is DISPLAY-ONLY placeholder pricing that ../catalog!useOffering returns (in the Offering shape, flagged sample: true) for a slot that has no assigned/resolved offering yet, so a paywall/upsell page still renders real-looking prices while a slot is unwired.

A sample is exactly a real offering’s input — it carries the SAME fields the platform hands the funnel for a real offering (OfferingInput), minus id (the slot key supplies it). It resolves through the SAME resolveOfferingformatOffering pipeline, so there are no invented fields and no second code path: a sample can only ever contain what a real offering contains. Amounts are in minor units (e.g. 1949, not 19.49), and the currency is whatever the author picks — the SDK never converts (see the module header).

Why no preview-mode gate is needed: a sample can only surface for an UNASSIGNED slot, and the LIVE-publish (promote) gate already blocks promotion while any referenced slot is unassigned — so a sample can never reach a paying visitor on a live funnel. Samples appear only in the editor/preview and in local appfunnel dev. The <Checkout>/<UpsellButton> components additionally refuse to start a real charge for a sample (defence in depth); the server remains the aut…

SurfacePreference

type · commerce/capabilities.ts

export type SurfacePreference = 'auto' | CheckoutSurface

What an author writes on <Checkout surface>: either a concrete CheckoutSurface (pinned, validated as today) or 'auto' — a declarative “give me the best surface for this store’s provider and this device”. The platform resolves 'auto' to a concrete surface via resolveSurface; 'auto' never crosses the checkout wire (the client resolves first, the server re-validates the concrete surface through validateCheckout).

TriggerAxis

type · commerce/checkout.tsx

export type TriggerAxis = | { asChild?: false | undefined children?: ReactNode | ((state: TriggerState) => ReactElement) } | { asChild: true children: ReactElement | ((state: TriggerState) => ReactElement) }

UpsellButtonProps

type · commerce/checkout.tsx

export type UpsellButtonProps = UpsellCommonProps & PaymentAxis & OpensAxis & TriggerAxis

Props for UpsellButton. payment and opens describe the RECOVERY — the only thing an upsell ever presents — and are narrowed by managed/opens exactly as they are on the checkout components.

UpsellKind

type · commerce/capabilities.ts

export type UpsellKind = | 'subscription' // charge a NEW, 2nd concurrent subscription off-session (stacking) | 'one-time' // charge a one-time add-on off-session | 'upgrade'

The kind of off-session upsell (researched against Funnelfox’s changelog, which ships these as distinct features). The discriminating real-world constraint is stacking a 2nd concurrent subscription — Paddle can’t, SolidGate/Stripe can.

INLINE_SURFACES

const · commerce/capabilities.ts

INLINE_SURFACES: ReadonlySet<CheckoutSurface>

Surfaces that render inline (vs. those that open on click).

MANAGED_PROFILES

const · commerce/capabilities.ts

MANAGED_PROFILES: Partial<Record<CheckoutProvider, ProviderProfile>>

The MoR-shaped profile Stripe becomes under Managed Payments. Keyed by provider so “which providers offer a managed variant” is one fact in one place — profileFor looks up here, supportsManaged tests membership, and the validators build their rejection from it. Exported so tests can pin the shape (the profiles above are pinned the same way).

PAYMENT_RENDERINGS

const · commerce/capabilities.ts

PAYMENT_RENDERINGS: Record<"wallets" | "card" | "full", PaymentRendering>

Each concrete PaymentMode → the rendering it names. 'auto' isn’t here: it’s resolved.

PROVIDER_HOSTED_SURFACES

const · commerce/capabilities.ts

PROVIDER_HOSTED_SURFACES: ReadonlySet<CheckoutSurface>

Surfaces where the PROVIDER collects and confirms the card in its own UI (its hosted page or its iframe), as opposed to the Elements family (express/element) where OUR code mounts the provider’s JS and confirms the intent itself.

The distinction matters twice: Stripe Managed Payments can only COLLECT here (custom payment forms are unsupported under managed), and Stripe’s Trial Offers can only run outside here (they’re incompatible with hosted Checkout — doc 09 §2.7). Same line, opposite sides.

This set answers ONE question — who collects the card — and it is orthogonal to who owns the CONTAINER that rendering sits in, which is SurfaceCapability.mountable. embedded is the proof they’re two questions: the provider collects (it’s their iframe, their form) and we own the container (we hand them the div). Reading either answer off the other is the mistake that kept Paddle out of our own overlay for as long as this table existed.

sheet is deliberately absent from BOTH sides, because it isn’t a payment surface at all — it is our own overlay, and which side it belongs to depends on what the driver renders inside it (an Elements form on ordinary Stripe, the provider’s own frame under managed or on Paddle). Nothing reads this set for such a sheet today: its only consumer is *, whose note is scoped to the legacy-composed trial shap…

PROVIDER_PROFILES

const · commerce/capabilities.ts

PROVIDER_PROFILES: Record<CheckoutProvider, ProviderProfile>

Provider capabilities (researched 2026-06-23 against provider docs + Funnelfox; trial mechanics re-verified 2026-07-02, doc 09 §4.4; Stripe Managed Payments researched 2026-07-27 against Stripe’s managed-payments docs).

Four real-world corrections to the naive model:

  1. Off-session upsell support is per-provider by kind (offSessionUpsells): Paddle can’t stack a 2nd concurrent subscription (upgrade + one-time only); Stripe/SolidGate/Whop/Primer can do all kinds.
  2. Off-session reliability is a property of the PROFILE, not the surface (ProviderProfile.offSession — moved there 2026-07-29). It follows the token model: plain Stripe and SolidGate hold their own vault → reliable; Stripe managed charges against Stripe’s vault with no guaranteed SCA → conditional; Primer is conditional because the vault lives with whichever PSP it routed to. Recorded per surface it said two things that aren’t so — that the same Checkout Session upsells differently in a modal than in the page, and (on the BASE Stripe row, whose justifying comment was about Managed Payments) that base hosted checkout is conditional at all. It isn’t: a base hosted session sets payment_intent_data.setup_future_usage: 'off_session' and vaults to OUR Customer (services/checkout/stripe-hosted.ts), indistinguishable from the Elements path. Managed is …
Last updated on