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): CheckoutHandleThe 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 | undefineduseOffering('monthly') — one offering for a slot, formatted in the active locale.
Resolution precedence:
- the slot’s real, assigned+resolved offering (a provider-resolved price) →
the
Offering(nosampleflag); - else the funnel’s
SampleOfferingfor that slot (declared indefineFunnel({ sampleOfferings })) → the SAMEOfferingshape flaggedsample: true, so a page renders identically while the slot is unassigned; - 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): ReactNodeThe 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 pagepayment (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): ReactNodeThe 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 triggeropens (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): ReactNodeThe 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.resume → POST /complete, bounded re-poll while
pending), and resolves it into the normal machinery:
settled→purchase.completefires with the settlement’s server-mintedeventId(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+ statuserror; anyonFailedmaps passed as props apply — product-less, so onlygo/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(): ReactNodeThe 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): ReactNodeThe 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 allWhen 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; }): CheckoutErrorBuild a CheckoutError (driver/test helper).
createMockDriver
function · commerce/checkout.tsx
createMockDriver(provider?: CheckoutProvider, options?: MockDriverOptions): CheckoutDriverA console/no-op driver — tests, preview, local dev. Succeeds unless configured to fail.
currencyExponent
function · commerce/money.ts
currencyExponent(currency: string): numberThe 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): CheckoutDriverWrap 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): MoneyFormat minor units of currency in locale (exponent-aware: 7900 JPY = ¥7,900).
formatOffering
function · commerce/money.ts
formatOffering(r: ResolvedOffering, locale: string): OfferingFormat a ResolvedOffering into a display Offering in locale.
formatSampleOffering
function · commerce/money.ts
formatSampleOffering(slot: string, sample: SampleOffering, locale: string): OfferingMaterialize 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
resolveOffering → formatOffering 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): booleanWhether 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): booleanisMerchantOfRecord
function · commerce/capabilities.ts
isMerchantOfRecord(provider: CheckoutProvider, variant?: ProviderVariant): booleanWhether 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): booleanWhether a provider routes across multiple processors (Primer, SolidGate).
isProviderHostedSurface
function · commerce/capabilities.ts
isProviderHostedSurface(surface: CheckoutSurface): booleanWhether 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): booleanA 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): ProviderProfileResolve (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): ResolvedOfferingResolve 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): PaymentRenderingResolve 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): CheckoutSurfaceResolve 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.
expresson a provider that can’t render it, is caught byvalidateCheckoutat 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): booleanWhether 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): ValidationResultValidate 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): ValidationResultValidate 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): ValidationResultValidate 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 PayOnly applies where the SDK mounts the method list — payment="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 &
TriggerAxisProps 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 & CheckoutInlinePropsProps 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:
canceledonly routes when mapped EXPLICITLY — a deliberately dismissed sheet/popup is not a failure to recover from, sodefaultnever catches it;already_purchasedlikewise: 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 throughdefaultis 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: seeSurfaceCapability.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 resolveOffering →
formatOffering 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' | CheckoutSurfaceWhat 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 &
TriggerAxisProps 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:
- 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. - 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 isconditionalbecause 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 setspayment_intent_data.setup_future_usage: 'off_session'and vaults to OUR Customer (services/checkout/stripe-hosted.ts), indistinguishable from the Elements path. Managed is …