@flopay/react
v1.8.2
Published
React bindings for the FloPay SDK. Provides a context provider, drop-in checkout form components, individual element components, and hooks.
Readme
@flopay/react
React bindings for the FloPay SDK. Provides a context provider, drop-in checkout form components, individual element components, and hooks.
Installation
pnpm add @flopay/react @flopay/js @flopay/shared react react-domPeer dependencies: react >= 18.0.0, react-dom >= 18.0.0.
Environment Setup
Configure which billing API the SDK uses. Choose one:
# Option A: Environment variable (recommended)
# .env.production
NEXT_PUBLIC_FLOPAY_ENV=production
# .env.development
NEXT_PUBLIC_FLOPAY_ENV=staging// Option B: Call at app startup
import { configureFlopay } from '@flopay/shared';
configureFlopay({ environment: 'production' });If neither is set, the SDK defaults to staging.
Quick Start
Recommended: FloPayCheckout
The simplest integration — one component, one prop:
import { FloPayCheckout } from '@flopay/react';
function CheckoutPage() {
return (
<FloPayCheckout
sessionId="sess_abc123"
onComplete={(result) => {
window.location.href = '/success';
}}
onError={(err) => console.error(err)}
/>
);
}FloPayCheckout automatically fetches the session, initializes the correct payment providers, and renders a SplitCardForm with the hosted vault card widget plus the session's supported wallets, APMs, and PayPal. Customer data (email, userId, name) is injected from the session.
Merchant instrument feed
Use onInstrument on FloPayCheckout or FloPayProvider to forward a stable,
privacy-safe checkout funnel to your own analytics. The callback receives the
exported FloInstrumentEvent discriminated union; schemaVersion is always 1,
and gateway is included as stripe or paypal only when the event is
attributable to that payment provider. Hosted-vault events may omit gateway.
import type { FloInstrumentEvent } from '@flopay/react';
function forwardCheckoutInstrument(event: FloInstrumentEvent) {
window.analytics?.track(event.name, event);
}
<FloPayCheckout sessionId={sessionId} onInstrument={forwardCheckoutInstrument} />;Lifecycle names are checkout_mount, sdk_loaded, form_rendered,
card_expanded, tokenize, process_attempt, and 3ds_challenge.
checkout_mount, sdk_loaded, form_rendered, and card_expanded arrive at
most once per logical checkout. tokenize, process_attempt, and
3ds_challenge may repeat for each attempt.
checkout_error adds one of four phases: session_create, sdk_load,
process, or wallets.
The callback is an allowlisted projection with no card data, tokens, provider
object IDs, secrets, or raw PII. A throwing onInstrument consumer never
breaks checkout. This merchant-owned feed is independent of the Flo-owned
backend telemetry setting, so telemetry={false} does not disable it.
Checkout Modes
FloPayCheckout supports three checkout modes matching the billing API's checkoutMode field:
full(default) — Shows the hosted card widget plus supported non-card methods.confirm— Hides the payment form and shows a "Confirm Purchase" button. Uses a saved payment method on the backend. When the saved card needs re-authentication, the 3DS challenge runs in place and the same payment attempt completes — the button is not replaced by the card form.auto— Auto-submits with a saved payment method after the session loads. Falls back tofullmode on failure.
// Confirm mode — one-click purchase with saved card
<FloPayCheckout
sessionId="sess_abc123"
checkoutMode="confirm"
confirmLabel="Complete Purchase"
onComplete={(result) => router.push('/success')}
/>
// Auto mode — instant checkout, falls back to full form
<FloPayCheckout
sessionId="sess_abc123"
checkoutMode="auto"
onComplete={(result) => router.push('/success')}
onSessionCompleted={(successUrl) => router.push(successUrl)}
/>The mode can also be set on the session itself via the billing API's checkoutMode field. The checkoutMode prop overrides the session value.
Secure saved-card setup
Use FloPayCardSetup to let a customer add or verify a card without creating a
purchase or charge. Your trusted server must first call the
merchant-authenticated card-setup endpoint and pass only its opaque sessionId
and bound nonce to the browser. Never send Client Basic credentials, OAuth
tokens, or a customer identifier to this component.
import { FloPayCardSetup } from '@flopay/react';
function AddCard({ sessionId, nonce }: { sessionId: string; nonce: string }) {
return (
<FloPayCardSetup
sessionId={sessionId}
nonce={nonce}
onComplete={({ paymentMethod }) => {
// Display-only when supplied. Re-list from the server if absent during
// a mixed-version backend rollout.
console.log(paymentMethod?.brand, paymentMethod?.lastFour);
}}
onDecline={({ message }) => showCardError(message)}
onValidation={(message) => showCardValidation(message)}
onError={(error) => {
if (error.retryable) showRetry();
}}
onCancel={() => closeAddCard()}
/>
);
}The component reads and validates the setup session before injecting FloPay's hosted PCI card widget. It refuses purchase sessions, zero-amount non-setup sessions, non-zero setup sessions, and setup sessions containing products. Bank authentication is shown through the existing hosted challenge and does not count as success while pending. Completion fires only after verification says the card is usable; decline, validation, retryable technical failure, and unmount cancellation remain separate outcomes.
Submission feedback matches checkout: the same processing overlay covers the
form from the buyer's submit ("SAVING CARD...") and flips to "CARD SAVED"
(held briefly before onComplete) or "CARD NOT SAVED" with the decline
message (cleared automatically so the buyer can retry in place).
Saved-card listing, setup-session creation, and deletion remain authenticated
merchant REST calls. Replacement is composition: complete setup for the new
card, then ask your server to delete the old card. FloPayCardSetup deliberately
has no customer-id, merchant-credential, list, delete, purchase, or Stripe
Elements prop.
Prefer importing it from the dedicated subpath on pages that never run a checkout:
import { FloPayCardSetup } from '@flopay/react/card-setup';The main entry re-exports the component for back-compat, but its module graph
eagerly loads Stripe.js (a deliberate checkout preload); the card-setup
entry's graph never reaches Stripe, so saved-card pages stay free of Stripe
network activity by construction.
Expired Sessions
When FloPayCheckout or FloPayAutomaticPaymentButton loads a checkout session whose status is expired, the component calls onError with a FloPayError so your app can route the customer into a recovery flow.
The error has:
message: 'Checkout session has expired.'type: 'api_error'code: 'checkout_session_expired'
Use this code to create or request a fresh checkout session, then send the customer back through checkout:
<FloPayCheckout
sessionId={sessionId}
onComplete={() => router.push('/success')}
onError={(error) => {
if (error.code === 'checkout_session_expired') {
router.push('/checkout/expired');
return;
}
console.error(error);
}}
/>Automatic Payment Buttons
Use FloPayAutomaticPaymentButton when you want a single reusable button that:
- creates an
autocheckout session inline or reuses an existingsessionId - lets the backend resolve and charge the customer's most recently vaulted payment method (across providers)
- shows the shared processing / success / failure modal
- emits the same success and decline events without rendering a full checkout form
The button accepts either:
sessionId- or the same session-creation props you would normally post to the backend:
clientId,items,subscriptions,account,successUrl,cancelUrl,couponCodes,tagsData,utmMetadata
It accepts the same theme prop as FloPayCheckout and SplitCardForm (see Theming below) — the value is forwarded to the fallback FloPayCheckout modal so the entire flow stays visually consistent. When the saved-payment charge can't complete silently, the button opens the standard FloPayCheckout inline with the same theme already applied.
Removed in this version: the legacy
paymentMethodIdandcheckoutMethodprops are now@deprecatedand silently ignored. The backend's auto-checkout (createSingle) looks up the customer's latest vaulted payment method viagetLatestByUserIdand rebinds the session's gateway to match, so the SDK no longer hand-picks a PM or a provider. Existing integrations that still pass these props continue to work — they have no effect.
import { FloPayAutomaticPaymentButton } from '@flopay/react';
function UpsellButton() {
return (
<FloPayAutomaticPaymentButton
clientId="your-client-id"
account={{ userId: 'user_1', email: '[email protected]' }}
items={[
{
providerItemId: 'upsell_1',
providerItemName: 'AI Supercharger Pack',
totalAmount: 249,
overrideAmount: 80,
currency: 'EUR',
quantity: 1,
},
]}
successUrl={`${window.location.origin}/success`}
cancelUrl={`${window.location.origin}/success`}
theme="bold-dark"
onClick={() => {
window.dataLayer?.push({ event: 'automatic_payment_button_click' });
}}
onSuccess={() => {
console.log('automatic payment succeeded');
}}
onError={(error) => {
console.error(error.message);
}}
onDecline={(decline) => {
console.log('automatic payment declined', decline);
}}
>
Purchase Item
</FloPayAutomaticPaymentButton>
);
}children render inside the button, so you can treat them as slot content. If omitted, the default buttons-layout card content is used.
Wallet Buttons & alternative payment methods
The payment-method list is now gateway-driven: the billing API ships
gateways.stripe.enabledPaymentMethods per session, derived from the Stripe
Payment Method Configurations enabled on the merchant account. The SDK
partitions that list into two regions inside SplitCardForm:
ExpressCheckoutElement—apple_pay,google_pay,paypal,link,amazon_pay,klarna(everything supported as a native big button).PaymentElement(accordion) — every other enabled method, e.g.cashapp,affirm,ideal,bancontact,sepa_debit.
card is a capability marker, not a Stripe-rendered method. It keeps the
Credit / Debit Card path available and tells SDK 1.4.9+ that the hosted
vault form can be requested lazily when session.vault is absent.
Enabling Cash App Pay (or any future Stripe method) in the Stripe dashboard is the entire integration change — no SDK redeploy or consumer code update is required. Wallets only render on supported devices regardless of dashboard state (Apple Pay on Safari/macOS/iOS, Google Pay on Chrome).
Payment-logo CDN and CSP: APM brand logos load from the versioned, FloPay-owned URL
https://cdn.flopay.com/sdk-logos/v1/. Plain absolute HTTPS URLs work in Vite with no SDK middleware oroptimizeDepsoverride, and retain the same behavior in webpack 5, Rollup, Next, and esbuild. Logos stay out of the package and JavaScript bundle; card-only and vault checkouts never request one. Merchants with a strict Content Security Policy must allow the host in the image directive, for exampleimg-src 'self' https://cdn.flopay.com;. A failed or CSP-blocked logo request falls back to the method's existing inline monogram, so the tile remains branded without another network request.
In layout="buttons", the Credit / Debit Card button always opens the
inline card form. External wallets and PayPal keep their own lifecycle:
buyer cancellation silently returns to the payment-method chooser with all
eligible options available, while post-click technical failures, popup
blocking, or a provider surface returning focus without a terminal callback
restore the chooser, focus a visible retry control for the failed method, show safe method-specific copy such as
We couldn't open Google Pay. Try again or choose another payment method., and
call onError with a structured FloPayError. Passive pre-click load or
eligibility failures still collapse only the unavailable method and do not show
a checkout error.
Toggle the whole Stripe region or the PayPal region independently with the two gateway-level props:
<FloPayCheckout
sessionId="sess_abc123"
showStripe={true} // default: true — Stripe wallets/APMs only
showPayPal={true} // default: true — DirectPayPal when gateway present,
// Stripe-rendered PayPal otherwise
onComplete={handleSuccess}
/>Setting both to false (with no PayPal gateway configured) is treated as a
bootstrap-time validation error — onError fires with a
FloPayError({ type: 'validation_error' }) so the misconfiguration surfaces
during integration instead of silently rendering an empty form.
Deprecated props
showApplePay, showGooglePay, and directPaypal are still accepted but
emit a one-time console.warn when supplied alongside the new
enabledPaymentMethods payload. They are no-ops once the backend ships the
field — Apple Pay / Google Pay are dashboard-controlled at Stripe, and direct
PayPal is auto-resolved from gateways.paypal on the session response. The
legacy props will be removed in 2.0.
Theming
FloPayCheckout, FloPayAutomaticPaymentButton, SplitCardForm, and FloPayCardSetup accept a single theme prop that maps to a coherent {appearance, buttonsLayout} bundle in @flopay/shared's THEMES map. (FloPayCardSetup styles only the hosted vault widget — there are no surrounding Stripe Elements on that surface — and unlike the checkout components it defaults to 'modern-light' rather than 'classic'.) One value styles non-card Stripe Elements, the React-rendered wrapper and AVS inputs, the hosted vault widget, and — for FloPayAutomaticPaymentButton — the fallback modal that opens when a saved-payment charge can't complete silently.
| theme | Aesthetic |
|---|---|
| 'classic' | Historic FloPay look (no bundle applied — #EDEDFF wrapper, indigo submit). |
| 'modern-light' / 'modern-dark' | Clean & airy, Inter, soft shadows, FloPay-blue accents. |
| 'bold-light' / 'bold-dark' | Saturated FloPay blue with gradient pill submit and heavy borders. |
| 'glass-light' / 'glass-dark' | Translucent surfaces with backdrop blur over a blue gradient. |
<FloPayCheckout
sessionId="sess_abc123"
theme="bold-dark"
onComplete={handleSuccess}
/>
<FloPayAutomaticPaymentButton
sessionId="sess_abc123"
theme="bold-dark" // applied to the button AND the fallback modal
onSuccess={handleSuccess}
/>appearance (Stripe-side overrides) and buttonsStyles (per-field wrapper overrides) still work — both win over the resolved bundle for the fields they touch, so you can layer customizations on top of a theme:
<FloPayCheckout
theme="modern-light"
buttonsStyles={{ submitButton: { backgroundColor: '#FF0099' } }} // wins
/>Buttons Layout
Switch from the default form layout to stacked payment buttons with an expandable card form:
<FloPayCheckout
sessionId="sess_abc123"
layout="buttons"
theme="bold-dark"
onButtonClick={(method) => console.log('clicked:', method)}
onComplete={handleSuccess}
/>layout can be changed at runtime. Switching a mounted FloPayCheckout from
the default layout to "buttons" keeps the resolved checkout session and its
gateway capabilities, so the Credit / Debit Card choice remains available
without recreating the session.
Deprecated: the legacy
buttonsThemeprop ('default'/'minimal'/'rounded'/'dark') still works but new code should usethemeso the same value drives both the buttons-layout wrapper and the auto-payment fallback. See ButtonsLayoutStyles reference for the underlying override fields.
Button Hooks
Use onButtonClick to track button interactions, onDecline to track declines/cancellations, and onBeforeButtonClick to enrich any buttons-layout payment method before it continues:
<FloPayCheckout
layout="buttons"
createSession={{
clientId: 'your-client-id',
currency: 'EUR',
items: [{ providerItemId: 'product-1', providerItemName: 'Widget', totalAmount: 29.99 }],
account: { userId: 'user_1', email: '[email protected]' },
successUrl: '/success',
cancelUrl: '/cancel',
}}
onBeforeButtonClick={async ({ method, createSession }) => {
if (method !== 'card') {
return {
tagsData: { sessionId: `checkout_${method}_clicked` },
};
}
const email = await openEmailCaptureModal({
initialEmail: createSession?.account.email ?? '',
});
if (!email) return false;
return {
account: { email },
tagsData: { sessionId: 'checkout_email_captured' },
};
}}
onButtonClick={(method) => {
window.dataLayer?.push({ event: 'checkout_button_click', payment_method: method });
}}
onDecline={(decline) => {
window.dataLayer?.push({ event: 'checkout_decline', ...decline });
}}
onComplete={handleSuccess}
/>onBeforeButtonClick runs for every payment button in layout="buttons". Branch on method if only some flows need extra work. For PayPal, Apple Pay, and Google Pay, keep the hook fast because it runs during the provider button handshake before the wallet sheet or PayPal window opens.
Inline Session Creation
Skip the backend API route — create the session directly in the component:
<FloPayCheckout
layout="buttons"
createSession={{
clientId: 'your-client-id',
currency: 'EUR',
items: [{ providerItemId: 'product-1', providerItemName: 'Widget', totalAmount: 29.99 }],
account: { userId: 'user_1', email: '[email protected]', firstName: 'Jane', lastName: 'Doe' },
successUrl: '/success',
cancelUrl: '/cancel',
}}
onComplete={(result) => window.location.href = '/success'}
/>The component POSTs to the billing API, gets the full session back, and renders the form — zero backend code needed.
Authorisation-only checkout
Set captureMethod: 'manual' in the same createSession draft for an eligible
item-only card checkout:
<FloPayCheckout
createSession={{
clientId: 'your-client-id',
captureMethod: 'manual',
currency: 'EUR',
items: [{ code: 'order_123', totalAmount: 29.99 }],
account: { userId: 'user_1', email: '[email protected]' },
successUrl: '/success',
cancelUrl: '/cancel',
}}
onComplete={(result) => {
if (result.status === 'authorized') {
saveAuthorisation({
paymentId: result.paymentId,
sessionId: result.sessionId,
expiresAt: result.authorizationExpiresAt,
});
}
}}
onSessionCompleted={(successUrl) => router.push(successUrl)}
/>The component still releases the buyer to the success experience, but its
overlay says PAYMENT AUTHORISED. authorized means funds are held, not paid.
Capture remains a merchant-authenticated REST operation and is never available
to browser code. Manual capture is rejected for subscription carts. See the
repository's pre-authorisation guide.
That create POST sends a stable Idempotency-Key header automatically whenever a
secure RNG is available. FloPayCheckout resolves the key once per logical
checkout, stores it with the inline-session cache in sessionStorage, and reuses
it across transport retries, effect reruns, and component remounts. Concurrent
mounts (StrictMode double-mount, Suspense remount, or navigation flicker) share
both the session create and the auto-mode payment attempt, so one purchase cannot
fan out into multiple creates or charges.
Logical checkout identity is based on the merchant, buyer, cart/pricing, and
coupons. Checkout-mode changes, refreshed payment tokens, and analytics/UTM prop
churn do not rotate the key while that purchase is active. A different cart or
buyer gets a different key; an email-less buyer is separated by account.userId.
To control the key yourself — for example to keep it stable across your own
server retries — pass idempotencyKey on createSession:
<FloPayCheckout
createSession={{
/* … */
idempotencyKey: `checkout:${orderId}`, // one logical checkout; never reuse for a new purchase
}}
/>The key must be non-empty and at most 255 characters; an invalid value throws a
FloPayError('validation_error') before any request. Omit it and the SDK
generates a fresh cryptographically random key per logical create when a secure
RNG is available; without one it omits the header, and a merchant-supplied
idempotencyKey stays the way to remain idempotent. See the
@flopay/js idempotency docs
for the full contract.
Session-level currency is now required. The backend (#760) enforces @IsNotEmpty on the field; the SDK pre-validates and throws FloPayError({ type: 'validation_error', code: 'CurrencyRequired' }) before issuing the request when neither the session nor any item/subscription/product carries a currency. Pass currency at the top of createSession (preferred), or rely on the legacy fallback to the first item/subscription/product currency.
When you use onBeforeButtonClick with createSession, any returned InlineSessionPatch is merged into the draft session params before the selected buttons-layout flow continues. That lets you add tracking data or update account fields just in time without pre-creating a separate backend session.
Detached session creation (default)
createSession checkouts are created in two phases (TeamFloPay/backend#1099):
a lightweight session shell that skips catalog validation and the
buyer-identity advisory locks, then a background claim that attaches buyer
identity, address, products and coupons.
FloPayCheckout renders from the shell, so the hosted card form mounts and
becomes interactive without waiting for either the claim or Stripe.js — Stripe
wallets, APMs and PayPal load in parallel with the claim and appear when it
lands. The card widget's submit button stays gated for that window, because the
billing API rejects a charge against an unclaimed session; if a buyer clicks
during it, the widget cancels the click and the form asks them to press pay
again. In practice the claim resolves while the card is still being filled in.
Nothing else changes for you: the same session, the same callbacks, the same results. Catalog and coupon errors still surface as a checkout load error, just from the claim rather than the create.
Opt out per session to restore the original single-request create:
<FloPayCheckout
createSession={{
/* … */
deferDataAttachment: false, // one-shot create
}}
/>Detached creation applies only to checkoutMode: 'full' (the billing API
rejects it for auto / confirm) and is skipped when tokenizedData is
supplied. There is no fallback to the one-shot create — it requires a
billing API that exposes PATCH /v1/checkouts/sessions/{id}/claim.
Advanced: Manual Provider Setup
Use SplitCardForm when you need to compose the provider and session yourself.
Card checkout uses the backend-hosted vault widget; Stripe remains limited to
wallets/APMs and saved-payment authentication. The session can supply the
widget eagerly in session.vault, or explicitly advertise card in
gateways.stripe.enabledPaymentMethods so the SDK recovers it on demand.
import { FloPayProvider, SplitCardForm } from '@flopay/react';
import { loadFloPay } from '@flopay/js';
const floPayPromise = loadFloPay('pk_test_...');
function CheckoutPage() {
return (
<FloPayProvider
flopay={floPayPromise}
options={{
amount: 2999,
currency: 'usd',
}}
>
<SplitCardForm
sessionId="session_uuid"
nonce={sessionNonce}
session={session}
billingApiUrl="https://billing.example.com"
email="[email protected]"
userId="user_1"
totalAmount={2999} // cents
currency="usd"
enabledPaymentMethods={session.gateways?.stripe?.enabledPaymentMethods}
onComplete={(result) => {
if (result.status === 'succeeded') {
window.location.href = '/success';
}
}}
onError={(err) => console.error(err)}
onFirstNameChange={(name) => setFirstName(name)}
onLastNameChange={(name) => setLastName(name)}
/>
</FloPayProvider>
);
}The resulting surfaces are:
- Wallet buttons and supported APM tiles from the session gateway capability.
- Direct or Stripe-hosted PayPal, selected from the session gateways.
- The hosted vault widget for sessions with embedded vault HTML or an explicit
Stripe
cardcapability.
Wallets, APMs, and PayPal create intents through
POST /v1/checkouts/sessions/{id}/intents. The discriminated request separates
provider, payment-method category/type/id, and intent kind. Direct-card intent
requests are unsupported. Client-observed non-card failures use the
nonce-protected session decline endpoint and contain no payment tokens,
provider object IDs, card data, credentials, or PII.
For SDK 1.4.9+, an explicit Stripe card capability keeps card checkout
visible even when the backend omits the vault block. In layout="buttons",
POST /v1/checkouts/sessions/{id}/vault/capture is deferred until the buyer
selects Card. An embedded block remains the preferred fast path and causes no
recovery POST. Sessions that advertise neither an embedded block nor card
do not call the recovery endpoint; their non-card methods remain usable, and
if none remain, onError receives UnsupportedBackendVaultCapability.
AVS postcode validation
When AVS collection is enabled (enableAVS) and the postcode field is shown,
SplitCardForm validates the postcode format against the live selected
country before the hosted vault captures the card.
It reuses @flopay/shared's country-aware
postcode helpers (the same
validator rules the billing API applies), so client and server agree.
- Supported country + malformed postcode → the vault widget's submit is blocked and an inline, country-specific message shows the expected format (e.g. "Enter a valid ZIP Code (e.g. 12345 or 12345-6789)"). On the vault path the inline message appears once the field is blurred, since the disabled submit means it can't be reached by a submit attempt.
- Supported country + empty postcode → the same submit gate applies, and once the field is blurred (or a submit is attempted) an inline required message shows (e.g. "ZIP Code is required") rather than the expected-format copy.
- Country with no postal system (or a locale
validatordoesn't recognise) → the postcode field is shown but optional: empty and format checks are both skipped, so these buyers are never blocked.
On the vault card path the widget's submit button is gated synchronously
on the client-side AVS check above. Because the client postcode rules mirror the
backend validator ([email protected]) verbatim, a postcode the server's
PATCH /v1/checkouts/sessions/{id}/account would reject is already blocked here —
before the button ever enables — with no network round-trip in the critical path.
The gate is never held pending an async request: the hosted widget silently
swallows a click that lands while the button is disabled and offers no way to
auto-resubmit on release, so an async gate would strand any buyer (or single-click
preview) who clicked before it resolved — the checkout would write no attempt and
time out (sdk#124).
When the buyer submits, SplitCardForm persists the billing snapshot
best-effort in parallel with the widget's charge:
- Backend rejects the address (a
4xx) — a residual, non-postcode drift, since the postcode is already client-validated — the specific message (e.g. "Postal/ZIP code is not valid for the selected country…") shows inline and firesonErrorwith the parsedFloPayError, and the processing overlay clears so the buyer can correct and retry. The charge cannot be cancelled once submitted, so this surfaces for the buyer's next attempt rather than stopping the in-flight one. - Transient failure (
5xx/ network) → best-effort and buyer-silent: the backend listener falls back to the session's baseline address while the widget completes the charge. - Internal 10-second timeout → the same functional fallback applies, and
successful widget completion still reaches
onComplete. Telemetry records the privacy-safe lifecycle eventoperation.fallbackwithprocessing/account_snapshot/timeoutdimensions instead of an alert-level technical error. Direct and blocking timeout paths remain errors.
Vault PCI card form
The card path renders a backend-served, self-contained hosted vault widget
(VaultCardFields) (TeamFloPay/backend#823, Model A). The billing API may
return it eagerly in session.vault, or advertise card under
gateways.stripe.enabledPaymentMethods and let SDK 1.4.9+ request it lazily.
It is fully server-driven — there is no consumer prop to toggle it. The
widget owns the card fields, its own submit button, card tokenization, the
PaymentIntent (created and confirmed server-side), 3DS, and the result,
so no Stripe.js runs on the card path and PAN / CVC never enter the SDK
runtime. Wallets / PayPal / APMs render exactly as before.
Because the widget owns the form, SplitCardForm exposes no SDK card-entry or
card-submit controls. Host-collected AVS fields remain outside the PCI
widget and gate its submit. The flow is:
session embeds session.vault or advertises gateways.stripe.enabledPaymentMethods: ['card']
→ SDK uses embedded HTML, or requests POST /vault/capture when Card is needed
→ concurrent renders/remounts share that active request
→ buyer pays inside the widget (tokenize → charge → 3DS, all backend-owned)
→ widget postMessages its outcome → SDK fires onComplete / onDecline / onError- Embedded
session.vault.htmlis the preferred fast path during rollout and never triggers a recovery request. - Deferred capture uses
POST /v1/checkouts/sessions/{id}/vault/capture, with a ten-second timeout, bounded transient-network retry, and one shared active request per base URL/session/nonce. - Returning customers with a card on file (
session.providerPaymentMethodId) are charged by the backend's auto-checkout cascade. - 3DS is handled inside the widget — there is no client-side
confirmCardPayment.
Processing feedback: after the hosted widget emits submitting, the SDK's
full-checkout processing overlay remains visible continuously until the widget
reports complete, decline, or error. Intermediate status polling and
late card-field validation mutations cannot expose the completed form while a
charge is still in flight. A synchronous card-validation rejection still
returns the buyer to the form immediately, as does a 4xx account-snapshot
validation failure.
Failure modes: onDecline / onError receive the mapped reason + message
so consumers can react, and the SDK leaves an inline retry message after its
terminal failure overlay clears.
Backend dependencies: the hosted widget must emit the
flopay-vaultpostMessageoutcome the SDK listens for (otherwise it falls back to its own success redirect), handle the 3DS step internally, and use the session account snapshot for AVS. The SDK collects and validates those address fields outside the PCI widget and persists them through the session account endpoint; the widget owns only sensitive card-data capture. Because the widget is injected same-window, every terminal outcome must be bound to the session id, and — to fully defend against same-window forgery — the backend should mint a per-sessionmessageTokenon the vault block and echo it in eachpostMessage; the SDK threads it through and rejects outcomes that omit or mismatch it (TeamFloPay/backend#823).
PayPal Handling
The SDK supports two PayPal paths, selected per-session based on what the billing API advertises under gateways.*:
- Direct PayPal (preferred where available — works inside Facebook, Instagram, and other in-app browsers): when the session exposes
gateways.paypal.publishableKey, the SDK renders PayPal via the official PayPal JS SDK using<DirectPayPalButton>. The PayPal client ID andenvironment('sandbox'/'live') come directly from the backend. - Stripe-rendered PayPal: when only
gateways.stripeis configured, PayPal renders through Stripe'sExpressCheckoutElementin its own Elements wrapper. This path can't render in Facebook/Instagram in-app browsers.
Renderer selection is mutually exclusive per session — direct PayPal takes priority over Stripe-rendered PayPal. Consumer props such as showPayPal continue to gate visibility on the client side.
PayPal-only sessions: When the backend advertises only gateways.paypal (no gateways.stripe), FloPayCheckout skips Stripe Elements entirely and renders <DirectPayPalButton> as the sole payment surface. Sessions that advertise no supported gateway at all throw a validation_error explaining the expected shape.
Flo-owned subscription continuations: gateways.paypal.providerObjectType
selects the direct PayPal operation. 'order' loads the PayPal SDK with
intent=capture and invokes createOrder, even when the checkout session mode
is subscription. 'subscription' preserves the provider-managed
intent=subscription / createSubscription flow. 'setup_token' invokes
createVaultSetupToken; approval submits the returned vaultSetupToken to the
nonce-bound session /process route for backend exchange and activation. An
advertised 'order'/'setup_token' opts the intent request and tokenized
process body into the paypal_vaulted payment-method channel; the intent
response must match the advertised object type. When the field is absent,
legacy sessions continue to derive Order versus Subscription from
isSubscription and keep the released paypal requests byte-identical. See
the full channel-selection and cutover contract.
Direct PayPal initialization recovery: PayPal's own cross-window bridge owns
its 10-second postMessage init() acknowledgement deadline; FloPay does not
change that upstream timeout. If the exact acknowledgement timeout is reported,
or if Buttons.render() is still unsettled after 11 seconds, the SDK hides
Direct PayPal, waits one second, and makes exactly one background render retry.
The retry uses a fresh render generation, and callbacks or promise settlements
from the superseded generation are ignored.
In a mixed checkout, card, wallets, APMs, and any other eligible methods remain
interactive throughout recovery. If both Direct PayPal attempts fail, PayPal
stays hidden for that component configuration; the SDK emits one sanitized
console diagnostic and calls neither onError nor onDecline. Deterministic
configuration failures and isEligible() === false are not automatically
retried.
In a PayPal-only checkout, FloPayCheckout shows an accessible retrying status
instead of a blank surface. After automatic recovery is exhausted, it shows safe
generic unavailable copy and a Retry PayPal button. onError fires once with
a FloPayError whose type is api_error and whose stable code is
paypal_init_timeout; onDecline does not fire and the raw provider message is
never rendered. Manual retry reuses the current checkout session and performs
one fresh render attempt without adding another automatic retry.
The SDK exposes the relevant pieces in three ways:
SplitCardForm/FloPayCheckout: pick the renderer automatically based ongateways.*. No additional configuration needed.FloPayProvider(manual composition): accepts an optionalpaypalFlopayprop used to drive Stripe's PayPal redirect leg. Load a secondFloPayinstance yourself only if you need to render PayPal manually.usePayPalFloPay()exposes it to descendants.DirectPayPalButton(standalone): can be rendered outsideSplitCardFormwhen you only need the PayPal button. PassclientId,currency,environment,isSubscription, and the backend-advertisedproviderObjectTypewhen present.isSubscriptionremains the compatibility fallback when the field is absent.
Using Hooks
import { useFloPay, useCheckout } from '@flopay/react';
function PaymentStatus() {
const flopay = useFloPay(); // FloPay | null
const checkout = useCheckout(); // { session, loading, error, claimPending }
if (!flopay) return <div>Loading SDK...</div>;
// ...
}API Reference
Components
| Component | Description |
|-----------|-------------|
| FloPayProvider | Context provider. Accepts flopay (instance or promise), optional paypalFlopay, options?, and children. |
| FloPayCheckout | Recommended self-contained session checkout. Resolves gateways, mounts hosted-vault cards, and preserves wallets/APMs/PayPal/saved-payment flows. |
| FloPayCardSetup | Browser-only no-charge card verification for a merchant-server-created setup session. Requires sessionId + nonce; reports ready, verified completion, decline, validation, retryable technical error, and cancellation outcomes. |
| SplitCardForm | Advanced checkout surface combining hosted-vault cards with wallets, APMs, and PayPal. Supports ref for imperative next-action handling. |
| VaultCardFields | Hosted vault PCI card fields. Used internally by SplitCardForm on the vault path; consumes a CardCaptureAdapter from useFloPay().cardCapture(). |
| DirectPayPalButton | Standalone direct PayPal Order, provider-managed Subscription, or setup-token button using the session-scoped intent contract. |
Hooks
| Hook | Returns | Description |
|------|---------|-------------|
| useFloPay() | FloPay \| null | Current FloPay instance from context. null while loading. |
| useCheckout() | CheckoutState | { session, loading, error, claimPending } from CheckoutContext. claimPending remains true while a detached shell is not safe to charge. |
FloPayProviderProps
| Prop | Type | Description |
|------|------|-------------|
| flopay | Promise<FloPay> \| FloPay | SDK instance or promise from loadFloPay() |
| paypalFlopay | Promise<FloPay> \| FloPay \| null | Optional Stripe instance used for the Stripe-rendered PayPal redirect leg. |
| options.billingApiUrl | string? | Billing API base URL exposed to child components. |
| onInstrument | (event: FloInstrumentEvent) => void | Versioned, privacy-safe checkout funnel and phased error feed for merchant analytics. |
FloPayCardSetupProps
| Prop | Type | Description |
|------|------|-------------|
| sessionId | string | Opaque id returned by the merchant-authenticated setup-session endpoint. |
| nonce | string | Required session-bound read/capture token. |
| billingApiUrl | string? | Billing API base URL; defaults through normal FloPay environment resolution. |
| telemetry | boolean? | Privacy-safe operational telemetry; set false to opt out. |
| theme | ThemeId \| VaultCardThemeColors? | High-level theme id (same values as FloPayCheckout, resolved through the same bundle) or raw hosted-widget colors. Defaults to 'modern-light'; 'classic' keeps the historic FloPay look. |
| containerStyle | React.CSSProperties? | Inline styles for the hosted-widget container. |
| loading | React.ReactNode? | Content shown while the session and hosted widget load. |
| onReady | () => void | Hosted widget is mounted and accepting input. |
| onComplete | (event: FloPayCardSetupCompleteEvent) => void | Verified usable card. Includes sessionId and optional display-only paymentMethod; never amount, charge, or payment-success data. |
| onDecline | (event: FloPayCardSetupDeclineEvent) => void | Terminal card-verification decline. |
| onValidation | (message: string \| null) => void | Live hosted-field validation; not a technical error. |
| onError | (error: FloPayCardSetupError) => void | Validation/technical failure with stable code and explicit retryable. |
| onCancel | (event: FloPayCardSetupCancelEvent) => void | Fires once when the component unmounts before a terminal outcome. |
Privacy-safe operational telemetry
FloPayCheckout emits the same closed Flo-owned lifecycle/error/performance
contract as @flopay/js, including React render/interactivity and provider
readiness milestones. Collection is best-effort; merchant callbacks remain
available with their existing arguments and invocation order. Synchronous
throws and rejected callback promises are contained and logged only to the
merchant console; they are not uploaded as CALLBACK_FAILED. Set the single
telemetry={false} prop to opt out:
<FloPayCheckout sessionId={sessionId} nonce={nonce} telemetry={false} />No endpoint, custom tag, user context, message, stack, or metadata can be
configured. See docs/TELEMETRY.md for the complete
privacy and retention contract.
If the React host has its own Sentry client, use the re-exported pure filter as
the host's beforeSend callback:
import * as Sentry from '@sentry/browser';
import { dropThirdPartyOnlyError } from '@flopay/react';
Sentry.init({ beforeSend: dropThirdPartyOnlyError });No Sentry package or configuration is bundled by FloPay. The predicate drops
only complete exception stacks with no app or @flopay/* frame and at least
one recognised browser-extension URL or Stripe dahlia/stripe.js path/module
marker. Anonymous frames may accompany that marker. Mixed, unrelated,
incomplete, and anonymous-only errors pass through unchanged. The in_app
flag is ignored because host rewriting may set it to true on third-party
frames.
Inline session creation classifies bounded failures without inspecting raw
errors: network/CORS/timeouts emit transport_error, 5xx responses emit
server_error, and malformed response discriminants emit invalid_response.
Stripe and PayPal SDK load/runtime failures emit provider_runtime. Inline
4xx validation rejections and ordinary payment declines remain non-error
validation_rejected / payment_declined outcomes and never carry
failureCategory.
The same classification applies to React-owned saved-payment processing in
auto and confirm modes. An ordinary saved-payment 400 decline, a
structured-field 400, or a 422 still falls back to the usable full checkout
and emits operation.fallback, but reports only the matching
payment_declined or validation_rejected outcome. Genuine server,
transport, malformed-response, and provider-runtime failures remain
categorized technical errors. This does not change fallback rendering or
merchant callback behavior, and no response body, provider payload, session
value, payment data, or raw error message is added to telemetry.
SplitCardFormProps
Key payment-surface props include:
| Prop | Type | Description |
|------|------|-------------|
| sessionId | string | Checkout session UUID. |
| nonce | string | Session-bound token forwarded on intent, decline, account, and process calls. |
| session | CheckoutSession? | Session capability data, including the optional hosted vault fast path and gateways. |
| billingApiUrl | string | Billing API base URL. |
| showStripe | boolean | Show Stripe-backed wallets/APMs and the hosted card path when the session embeds vault or advertises card. |
| showPayPal | boolean | Show PayPal. Renderer chosen by gateways.paypal presence (DirectPayPal JS SDK when present; Stripe-rendered PayPal otherwise). Default: true. |
| enabledPaymentMethods | string[]? | Per-session list of Stripe method type identifiers (card, apple_pay, google_pay, cashapp, klarna, link, amazon_pay, sepa_debit, affirm, ideal, …). Normally threaded automatically from gateways.stripe.enabledPaymentMethods by FloPayCheckout. The SDK partitions non-card methods between ExpressCheckoutElement and PaymentElement; card advertises the hosted-vault path and is never rendered by Stripe. |
| showApplePay | boolean | Deprecated. Apple Pay availability is dashboard-controlled at Stripe and surfaces through enabledPaymentMethods. Emits a one-time console.warn when supplied alongside enabledPaymentMethods and is otherwise ignored. Removed in 2.0. |
| showGooglePay | boolean | Deprecated. See showApplePay. |
| directPaypal | { clientId: string; environment?: GatewayEnvironment }? | Deprecated input on FloPayCheckout — auto-resolved from gateways.paypal on the session response. Still accepted on SplitCardForm for advanced consumers wiring providers manually. |
| totalAmount | number | Amount in cents for PayPal / wallet config |
| currency | string | Currency code for PayPal / wallet config (default: 'usd') |
| onFirstNameChange | (value: string) => void | First name change callback |
| onLastNameChange | (value: string) => void | Last name change callback |
| onComplete / onDecline / onError | callbacks | Observable checkout outcomes. |
Deferred-vault rollout boundary
SDK 1.4.9 is the compatibility boundary for omitting embedded capture
credentials. Keep the backend's current
link session → capture → complete idempotency → 201 ordering for requests
from older or unknown SDK versions, and until the deployed @flopay/react
population has crossed the adoption threshold chosen by the backend rollout.
The SDK advertises its version in x-flo-sdk-version on session create/read.
After that threshold, the backend may omit capture credentials from
create/read/idempotent-replay responses for requests advertising SDK >=1.4.9,
provided the session explicitly includes card in
gateways.stripe.enabledPaymentMethods and the nonce-protected
POST /v1/checkouts/sessions/{id}/vault/capture route remains available.
Backend rollout verification should confirm that create/read/replay no longer
invoke PCIVault and that capture issuance occurs only through the deferred
endpoint when a buyer enters the card path.
Types
| Type | Description |
|------|-------------|
| FloPayProviderProps | Props for FloPayProvider |
| FloPayCardSetupProps | Props for FloPayCardSetup |
| FloPayCardSetupCompleteEvent | Verified setup completion with optional display-only paymentMethod |
| FloPayCardSetupDeclineEvent | Terminal card-verification decline |
| FloPayCardSetupCancelEvent | Unmount-before-terminal cancellation |
| FloPayCardSetupError | Structured setup failure with stable code and retryable classification |
| SplitCardFormProps | Props for SplitCardForm |
| CheckoutState | { session, loading, error, claimPending } |
| FloInstrumentEvent | Versioned merchant instrument union with seven lifecycle names, four checkout_error phases, and an optional gateway. |
