# @flopay/react

React bindings for the FloPay SDK: a drop-in `FloPayCheckout`, a no-charge `FloPayCardSetup`, a one-click `FloPayAutomaticPaymentButton`, composable checkout components, and hooks. Cards are collected by the billing API's hosted PCI card form; wallets, alternative payment methods, and PayPal render beside it from the session's gateways.

## Installation

```bash
pnpm add @flopay/react @flopay/js @flopay/shared react react-dom
```

Peer dependencies: `react >= 18.0.0` and `react-dom >= 18.0.0`. Install the same version of every `@flopay/*` package.

Configure which billing API the SDK uses, once. Either set an environment variable:

```bash
# .env.production
NEXT_PUBLIC_FLOPAY_ENV=production

# .env.development
NEXT_PUBLIC_FLOPAY_ENV=staging
```

or call `configureFlopay()` at startup:

```ts
import { configureFlopay } from '@flopay/shared';

configureFlopay({ environment: 'production' });
```

If neither is set, the SDK uses the staging billing API.

**Payment-method logos and CSP.** 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 or `optimizeDeps` override, and behave the same in webpack 5, Rollup, Next, and esbuild. Logos stay out of the package and your JavaScript bundle, and card-only and hosted-card checkouts request none. With a strict Content Security Policy, allow the host in your image directive, for example `img-src 'self' https://cdn.flopay.com;`. A failed or CSP-blocked logo request falls back to the method's inline monogram, so the tile stays branded without another request.

## Usage

### FloPayCheckout

`FloPayCheckout` is the recommended integration. Give it a session and it fetches the session, initializes the right payment providers, and renders the hosted card form plus the session's supported wallets, alternative payment methods, and PayPal. Customer data (email, user id, name) comes from the session.

```tsx
<FloPayCheckout
  sessionId="sess_abc123"
  nonce={sessionNonce}
  onComplete={(result) => router.push('/success')}
  onError={(error) => console.error(error)}
/>
```

Pass `nonce`, the session-bound checkout token returned when the session was created, whenever you pass an existing `sessionId`.

#### Checkout modes

`checkoutMode` matches the billing API's `checkoutMode` and overrides the session's value:

- **`full`** (default): the hosted card form plus supported non-card methods.
- **`confirm`**: hides the payment form and shows a confirm button that pays with the buyer's saved payment method. If the saved card needs re-authentication, the 3DS challenge runs in place and the same attempt completes. `confirmLabel` labels that button.
- **`auto`**: pays with the saved payment method as soon as the session loads, falling back to `full` if that cannot complete.

#### Buyer identity collection

For inline session creation, `createSession.account.userId` is required but `email`, `firstName`, and `lastName` may be omitted. The checkout then collects identity at the right point for each method:

- The hosted card checkout shows a required email field above name, AVS, and the card form. Submit stays gated until the email passes `isValidEmail()` and a name is present.
- Apple Pay, Google Pay, other Stripe wallets, and PayPal use the email and name the provider returns. Their provider UI opens without a FloPay identity step first.
- A method that does not return the missing fields opens one required fallback pane after the provider returns (or before submission, for methods that cannot return identity).

The SDK never attaches a blank email and never replaces your `userId` with provider data. A buyer name you or the session supplied stays authoritative over a different wallet name, except the placeholder `Card Holder Name`, which an authorised wallet name can replace. First and last name always come from one source.

#### Inline session creation

Skip your own session-creation route and create the session inside the component with `createSession`:

```tsx
<FloPayCheckout
  createSession={{
    clientId: 'your-client-id',
    currency: 'EUR',
    items: [{ providerItemId: 'product-1', providerItemName: 'Widget', totalAmount: 29.99 }],
    account: { userId: 'user_1' }, // email and name are collected in checkout
    successUrl: '/success',
    cancelUrl: '/cancel',
    checkoutMetadata: { merchantOrderId: 'order-123' },
  }}
  onComplete={() => router.push('/success')}
/>
```

- **Currency is required.** Pass `currency` on `createSession`, or on the first item, subscription, or product. Without one the SDK throws a `CurrencyRequired` validation error before sending anything.
- **Detached creation (default).** The checkout creates a lightweight session *shell* first, then attaches the catalog in a background claim. The hosted card form mounts from the shell without waiting for the claim or Stripe.js; wallets, alternative payment methods, and PayPal load in parallel and appear when the claim lands. The card form's submit button stays gated until the claim settles, because the billing API refuses to charge an unclaimed session; a buyer who clicks during that window is asked to press pay again. Catalog and coupon errors surface as a checkout load error. `createSession.timeoutMs` applies separately to the shell and the claim (12 seconds each by default). Detached creation requires billing API v1.7.12 or newer and `checkoutMode: 'full'`, and is skipped when `tokenizedData` is supplied; pass `deferDataAttachment: false` to force a single request.
- **Late buyer identity.** Any still-anonymous session the checkout renders (including an existing session passed as `sessionId` and `nonce` with `buyerIdentified: false`) attaches the buyer email the checkout collects, or a wallet or PayPal approval supplies, through the session claim before payment.
- **Checkout metadata.** `checkoutMetadata` is sent unchanged on the initial request and never repeated in a claim. Omitting it keeps the request shape; an explicit `null` and `{}` stay distinct. Returned metadata is on the normalized session and its `data.session`. The billing API alone enforces size limits.
- **Idempotency.** The create request sends a stable `Idempotency-Key` whenever a secure random source is available. `FloPayCheckout` resolves the key once per logical checkout, stores it with the inline-session cache in `sessionStorage`, and reuses it across retries, effect reruns, and remounts, so StrictMode double-mounts or navigation flicker cannot create two sessions or two charges. A logical checkout is identified by merchant, buyer, cart and pricing, coupons, and checkout metadata (keys canonicalized, raw metadata not stored); checkout-mode, token, and analytics prop changes do not rotate it. Pass `createSession.idempotencyKey` to control the key yourself; it must be non-empty, at most 255 characters, and never reused for a new purchase.
- **Just-in-time updates.** With `layout="buttons"`, an `InlineSessionPatch` returned from `onBeforeButtonClick` is merged into the draft before the selected flow continues. A returned `checkoutMetadata` replaces the previous map; return `null` to clear it.

#### Authorisation-only checkout

Set `captureMethod: 'manual'` in `createSession` for an eligible item-only card checkout. The buyer still reaches the success experience, but the overlay reads `PAYMENT AUTHORISED` and `onComplete` receives `status: 'authorized'` with `paymentId`, `sessionId`, and `authorizationExpiresAt`. `authorized` means funds are held, not paid. Capture is a merchant-authenticated REST operation that browser code can never perform; see the [payments and authorisations reference](https://flopay.com/docs/technical-reference/rest/payments). Manual capture is rejected for subscription carts.

#### Hosted card submit label

In full checkout, the button that pays by card lives inside the hosted card form. Set `submitLabel` on `FloPayCheckout` or `SplitCardForm` to replace its wording, for example with the amount the buyer is about to pay.

- **Your wording.** Pass final, already-localized plain text from your own order summary. The SDK does not calculate, format, or translate amounts.
- **Live updates.** Changing the prop updates the button in place without remounting the card form, so entered card details, focus, and caret are kept. The SDK re-sends the label whenever the hosted form becomes ready again.
- **Defaults and reset.** Omit the prop (or pass `null`) to keep the hosted default, `CONFIRM PAYMENT`. Clearing it after a label was shown restores that default, and so does blank text: the hosted form trims the label, allows up to 64 characters, rejects control and hidden formatting characters, and shows the default for anything it rejects. Card setup (`FloPayCardSetup`) keeps its own `ADD CARD` wording.
- **Ownership.** `submitLabel` changes only the hosted card button. `confirmLabel` labels the saved-payment button in `checkoutMode="confirm"`, `cardButtonContent` renders the buttons-layout card tile, and wallet, PayPal, processing, and retry wording is unchanged.
- **Custom layouts.** `FloPayCheckout` injects `submitLabel`, like `sessionId` and `nonce`, into its direct custom-layout children; a child's own `submitLabel` wins.
- **Compatibility.** Custom labels need billing API v1.9.35 or newer, whose card form carries the `messageToken` and `expectedOrigin` that authenticate the label; the SDK sends it only to that exact origin. Older billing APIs keep the default wording.

#### Wallets and alternative payment methods

The payment-method list is gateway-driven: the billing API sends `gateways.stripe.enabledPaymentMethods` for each session, derived from the payment methods enabled on the merchant's Stripe account. Apple Pay, Google Pay, PayPal, Link, Amazon Pay, and Klarna render as express buttons; every other enabled method (for example Cash App Pay, Affirm, iDEAL, Bancontact, SEPA Debit) renders in an accordion. Enabling a new method in Stripe needs no SDK or application change. Wallets render only on supported devices.

`card` in that list is a capability marker, not a Stripe-rendered method: it keeps **Credit / Debit Card** available and lets the SDK fetch the hosted card form on demand when the session has no embedded `vault` block.

- `showStripe` (default `true`) toggles Stripe wallets and alternative methods; `showPayPal` (default `true`) toggles PayPal. Turning both off with no PayPal gateway is reported through `onError` as a `validation_error` instead of rendering an empty form.
- In `layout="buttons"`, **Credit / Debit Card** opens the inline card form. Cancelling a wallet or PayPal silently returns to the chooser. A technical failure after the click (including popup blocking, or a provider closing without a result) restores the chooser, focuses a retry control for that method, shows safe method-specific copy such as `We couldn't open Google Pay. Try again or choose another payment method.`, and calls `onError` with a `FloPayError`. A method that is unavailable before the click just disappears.
- Wallet confirmation has one 25-second SDK budget inside the provider's 30-second callback deadline. If a wallet omits required buyer identity, the authorization is failed promptly, the SDK collects the missing email, and the buyer reopens the wallet to continue.
- **Confirmation recovery.** Google Pay and Apple Pay authentication can outlive the SDK's observation window. The SDK never cancels that provider promise: it reads the original intent's status and settles once. A successful intent continues to payment exactly once; a cancelled or failed intent offers a wallet retry; an intent still needing action, or a status that cannot be read, keeps payment locked and offers only **Check payment status**. A retry after a lost intent-create response reuses the same authorization attempt id, so no second intent can be created.
- `showApplePay` and `showGooglePay` are deprecated: wallet availability comes from the session's `enabledPaymentMethods`, and supplying them alongside it emits a one-time `console.warn`. Direct PayPal is resolved from `gateways.paypal`; the `SplitCardForm` `directPaypal` prop remains only for wiring PayPal manually.

#### PayPal

The SDK renders PayPal one of two ways, chosen per session from its gateways:

- **Direct PayPal** (preferred, and works inside Facebook, Instagram, and other in-app browsers): when the session has `gateways.paypal`, PayPal renders through the official PayPal JS SDK with the client id and `environment` (`sandbox` or `live`) from the billing API. If only PayPal is advertised, `FloPayCheckout` skips Stripe entirely.
- **Stripe-rendered PayPal**: when only `gateways.stripe` is configured, PayPal renders through Stripe's express checkout. It cannot render in Facebook or Instagram in-app browsers.

Direct PayPal never hides its button behind a FloPay identity step: after approval the SDK reads the payer's email and name, and asks once for anything missing. `gateways.paypal.providerObjectType` selects the operation: `order` (a one-time Order, even for a subscription-mode session), `subscription` (a PayPal-managed subscription), or `setup_token` (vault the account; the approval token is exchanged by the billing API). `approvalPresentation` selects the bounded `app_switch` mode only for a one-time Order in an in-app browser, where it enables PayPal's app switch; ordinary browsers, subscriptions, and setup tokens keep the popup flow. It is a mode, never a URL. If an in-app popup is blocked, `onTechnicalFailure` on `DirectPayPalButton` receives `popupBlocked: true` with guidance to open the page in the system browser, and PayPal stays visible for retry.

**Initialization recovery.** A Direct PayPal startup failure hides PayPal, waits one second, and remounts once, without creating a session, intent, or charge; other payment methods stay usable throughout. If Stripe-rendered PayPal is also available, it takes over during recovery. If both attempts fail in a mixed checkout, PayPal stays hidden and neither `onError` nor `onDecline` fires. In a PayPal-only checkout the component shows an accessible retrying status, then a **Retry PayPal** button, and calls `onError` once with `paypal_init_timeout`.

#### Server-side card gateways (Worldpay)

The billing API can route a checkout's cards to an approved Worldpay Corporate Gateway, processed entirely server-side behind the same hosted card form. Your checkout code does not change:

- **No opt-in.** The billing API decides from the merchant's configured gateways whether a session can use Worldpay. The SDK sends no capability token or gateway hint, so routing is the same for every SDK version and create style. Auto, Confirm, and pre-tokenized sessions stay on the released routing because the billing API keeps them there, not because the SDK asks.
- **Same card surface.** No Stripe runtime, Worldpay SDK, or browser credential is involved. An open session that advertises `gateways.worldpayCorporateGateway` but omits the embedded card form recovers it on demand; a completed or authorized session is never shown the card form again.
- **Same callbacks.** Success reaches `onComplete` as `status: 'succeeded'`, and a manual-capture hold as `status: 'authorized'` with `paymentId`, `sessionId`, and `authorizationExpiresAt`; `paymentIntentId` is omitted because the processor's reference is not a Stripe object. A refusal reaches `onDecline` with `authorization_declined`. A payment still reconciling when the form stops waiting reaches `onError`, never `onComplete`, and a later session read reports the settled status.
- **Same authentication.** When the buyer's bank asks for 3DS, the SDK renders the provider-hosted challenge in a modal dialog over your page (`role="dialog"`, `aria-modal`, focus moved into the challenge and restored when it closes) and reports only the terminal outcome through the callbacks above. The challenge URL never reaches a callback or the instrument feed; the only signal is the opaque `3ds_challenge` instrument. Reloading a session still waiting on authentication re-presents the open challenge without reloading it and without charging the card again, and closing it takes the processing state down immediately and hands the buyer back to the card form, with its submit held until that attempt reports an outcome so a closed challenge can never start a second authorization — a server-resolved outcome that lands afterwards (an out-of-band bank approval, say) still reaches your callbacks.
- The merchant instrument feed's `card_expanded` event omits `gateway` for these sessions, because the billing API chooses the card's gateway.

#### Themes and layouts

`FloPayCheckout`, `FloPayAutomaticPaymentButton`, `SplitCardForm`, and `FloPayCardSetup` accept one `theme` prop, a `ThemeId`:

| Theme | Aesthetic |
|-------|-----------|
| `classic` | The historic FloPay look (no bundle applied); the default for checkout components |
| `modern-light` / `modern-dark` | Clean and airy: Inter, soft shadows, FloPay-blue accents |
| `bold-light` / `bold-dark` | Saturated FloPay blue with a gradient pill submit and heavy borders |
| `glass-light` / `glass-dark` | Translucent surfaces with backdrop blur over a blue gradient |

One value styles non-card Stripe Elements, the SDK-rendered wrapper and AVS fields, the hosted card form, and the automatic payment button's fallback checkout. `appearance` (Stripe-side overrides) and `buttonsStyles` (wrapper overrides) still win over the bundle for the fields they touch. The legacy `buttonsTheme` prop is deprecated in favor of `theme`; see the [ButtonsLayoutStyles reference](https://flopay.com/docs/technical-reference/react/flopay-checkout#buttonslayoutstyles-reference) for every override field.

`layout="buttons"` stacks the payment methods as buttons with an expandable card form, and can change at runtime without recreating the session. In that layout, `onButtonClick` reports every method click, `onDecline` reports declines and cancellations, and `onBeforeButtonClick` runs before a method continues: return an `InlineSessionPatch` to update the inline draft, or `false` to stop. Keep it fast for PayPal, Apple Pay, and Google Pay, because it runs during the provider's button handshake.

#### Merchant instrument feed

`onInstrument` on `FloPayCheckout` or `FloPayProvider` forwards a stable, privacy-safe checkout funnel to your own analytics as `FloInstrumentEvent` values. `schemaVersion` is always `1`, and `gateway` (`stripe` or `paypal`) is included only when an event is attributable to that provider.

- 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, and `telemetry={false}` does not disable this merchant-owned feed.

#### Privacy-safe operational telemetry

`FloPayCheckout` reports the same closed FloPay-owned lifecycle, error, and performance contract as `@flopay/js`, including render, interactivity, and provider readiness milestones. No endpoint, custom tag, user context, message, stack, or metadata can be configured, and callback exceptions are only logged to your console. Opt out with `telemetry={false}`.

If Stripe.js cannot initialize after the session resolves, a session that can use the hosted card form still shows it, and Direct PayPal still renders when advertised; only Stripe wallets and alternative methods are omitted. One **Retry payment methods** action retries provider initialization alone, never session creation, claims, intents, or payments. When neither the hosted card form nor Direct PayPal can render, `onError` and the `error` render prop receive `CheckoutInitializationFailed` with fixed buyer-safe copy.

Hosts with their own Sentry client can use the re-exported filter as `beforeSend`:

```ts
import * as Sentry from '@sentry/browser';
import { dropThirdPartyOnlyError } from '@flopay/react';

Sentry.init({ beforeSend: dropThirdPartyOnlyError });
```

It drops only complete exception stacks with no app or `@flopay/*` frame and at least one recognized browser-extension or Stripe controller frame; every other error passes through unchanged.

### FloPayCardSetup

`FloPayCardSetup` lets a customer add or verify a card without a purchase or charge. Your trusted server first calls the merchant-authenticated card-setup endpoint and passes only its opaque `sessionId` and bound `nonce` to the browser; never send API credentials or a customer identifier to this component. See the [payment methods reference](https://flopay.com/docs/technical-reference/rest/payment-methods) for listing, setup, and deletion.

- The component reads and validates the setup session before injecting the hosted card form. It refuses purchase sessions, non-zero or product-bearing setup sessions, and zero-amount sessions that are not setup sessions.
- Bank authentication is shown in the hosted challenge and does not count as success while pending. `onComplete` fires only once verification says the card is usable; decline, validation, retryable technical failure, and unmount cancellation are separate callbacks.
- Submission feedback matches checkout: a processing overlay reads `SAVING CARD...`, then `CARD SAVED` before `onComplete`, or `CARD NOT SAVED` with the decline message, cleared automatically so the buyer can retry.
- Replacing a card is composition: complete setup for the new card, then ask your server to delete the old one. The component has no customer-id, credential, list, delete, purchase, or Stripe Elements prop.
- Import it from `@flopay/react/card-setup` on pages that never run a checkout. The main entry re-exports it, but its module graph eagerly loads Stripe.js; the `card-setup` entry never reaches Stripe.

**Theming.** `theme` accepts a `ThemeId` (default `modern-light`) or raw `VaultCardThemeColors`. A named preset styles the container surface, the hosted form (including its complete submit-button snapshot, billing API v1.9.6+), and the processing, success, and error overlay coherently. `classic` keeps the historic appearance, and raw colors are forwarded to the hosted form unchanged with no surface treatment. Changing `theme` re-skins everything in place without remounting the form.

**`containerStyle` precedence.** Each `containerStyle` property wins over the preset's value for the same property, applied preset-first so a longhand refines a preset shorthand (for example `borderColor` keeps the preset border's width). Any `background*` member replaces the preset background entirely. With `classic` or raw colors, `containerStyle` applies alone.

### FloPayAutomaticPaymentButton

`FloPayAutomaticPaymentButton` is a single reusable button that creates an `auto` checkout session inline (or reuses a `sessionId`), lets the billing API charge the customer's most recently saved payment method across providers, and shows the shared processing, success, and failure modal. It accepts either `sessionId` and `nonce`, or the session-creation props `clientId`, `items`, `subscriptions`, `products`, `account`, `successUrl`, `cancelUrl`, `couponCodes`, `tagsData`, and `utmMetadata`. `children` render inside the button.

When the saved payment method cannot complete silently, the button opens a `FloPayCheckout` with the same `theme`. The `paymentMethodId` and `checkoutMethod` props are deprecated and ignored: the billing API selects the payment method and gateway.

### Composing your own checkout

`FloPayProvider`, `SplitCardForm`, and the hooks let you compose the provider and session yourself. Card checkout still uses the hosted card form: supply the session (with its `vault` block) and nonce, or a session whose `gateways.stripe.enabledPaymentMethods` includes `card` so the form can be fetched on demand. A session with neither never calls the recovery endpoint; its other methods remain usable, and if none remain `onError` receives `UnsupportedBackendVaultCapability`. `FloPayCheckout` and `SplitCardForm` never fall back to Stripe card fields.

- Wallets, alternative methods, and PayPal create intents through the nonce-protected session intent route, and client-observed failures use the session decline route without tokens, provider object ids, card data, or personal data.
- **Hosted card form.** The widget owns the card fields, its own submit button, tokenization, the charge, 3DS, and the result, so no Stripe.js runs on the card path. `SplitCardForm` renders no card fields or submit button of its own, and Host-collected AVS fields remain outside the PCI widget and gate its submit. After the widget reports it is submitting, the processing overlay stays up until `complete`, `decline`, `error`, or the buyer closing the authentication challenge — which takes the overlay down but keeps the form's submit held until that attempt reports an outcome. In `layout="buttons"`, the recovery request for the form waits until the buyer selects Card, and concurrent renders share one request.
- **AVS.** With `enableAVS`, the SDK collects the billing address outside the PCI form and gates the form's submit synchronously on it. The postcode is validated against the selected country with the same rules as the billing API: a supported country blocks an empty or malformed postcode with an inline message, and a country without postcodes makes the field optional. Email and street, city, and state fields gate submit the same way. On submit, the address is saved in parallel with the charge: a rejected address (`4xx`) shows inline and reaches `onError` for the next attempt, while a transient failure or a 10-second timeout falls back to the session's saved address and still completes.
- `useCheckout()` exposes `claimPending`, which stays true while a detached session shell is not yet safe to charge.

## Examples

A complete checkout page for an existing session:

```tsx
import { FloPayCheckout } from '@flopay/react';

export function CheckoutPage({ sessionId, nonce }: { sessionId: string; nonce: string }) {
  return (
    <FloPayCheckout
      sessionId={sessionId}
      nonce={nonce}
      theme="modern-light"
      submitLabel="Pay $10"
      onComplete={(result) => {
        if (result.status === 'authorized') {
          console.log('Funds held for payment', result.paymentId, 'until', result.authorizationExpiresAt);
        }
        window.location.assign('/success');
      }}
      onDecline={(decline) => console.warn(`${decline.method} declined: ${decline.message}`)}
      onError={(error) => {
        if (error.code === 'checkout_session_expired') {
          window.location.assign('/checkout/expired');
          return;
        }
        console.error(error.message);
      }}
      onSessionCompleted={(successUrl) => window.location.assign(successUrl)}
    />
  );
}
```

Create the session inline, with the buttons layout, a just-in-time email step for cards, and analytics:

```tsx
import { type FloInstrumentEvent, FloPayCheckout } from '@flopay/react';

function trackInstrument(event: FloInstrumentEvent) {
  console.log('checkout instrument', event.name, event);
}

export function InlineCheckout() {
  return (
    <FloPayCheckout
      layout="buttons"
      theme="bold-dark"
      createSession={{
        clientId: 'your-client-id',
        currency: 'EUR',
        items: [{ providerItemId: 'product-1', providerItemName: 'Widget', totalAmount: 29.99 }],
        account: { userId: 'user_1' },
        successUrl: '/success',
        cancelUrl: '/cancel',
        checkoutMetadata: { merchantOrderId: 'order-123' },
        idempotencyKey: 'checkout:order-123',
      }}
      onBeforeButtonClick={async ({ method, createSession }) => {
        if (method !== 'card') {
          return { checkoutMetadata: { merchantOrderId: 'order-123', selectedMethod: method } };
        }
        const email = window.prompt('Email for your receipt', createSession?.account.email ?? '');
        return email ? { account: { email } } : false;
      }}
      onButtonClick={(method) => console.log('clicked', method)}
      onInstrument={trackInstrument}
      onComplete={() => window.location.assign('/success')}
    />
  );
}
```

Authorise a one-time card payment now and capture it later from your server:

```tsx
import { FloPayCheckout } from '@flopay/react';

export function AuthorisationCheckout() {
  return (
    <FloPayCheckout
      createSession={{
        clientId: 'your-client-id',
        captureMethod: 'manual',
        currency: 'EUR',
        items: [{ providerItemId: 'order_123', totalAmount: 29.99 }],
        account: { userId: 'user_1', email: 'user@example.com' },
        successUrl: '/success',
        cancelUrl: '/cancel',
      }}
      onComplete={(result) => {
        if (result.status === 'authorized') {
          void fetch('/api/authorisations', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              paymentId: result.paymentId,
              sessionId: result.sessionId,
              expiresAt: result.authorizationExpiresAt,
            }),
          });
        }
      }}
      onSessionCompleted={(successUrl) => window.location.assign(successUrl)}
    />
  );
}
```

Add a card without charging it:

```tsx
import { FloPayCardSetup } from '@flopay/react/card-setup';

export function AddCard({ sessionId, nonce }: { sessionId: string; nonce: string }) {
  return (
    <FloPayCardSetup
      sessionId={sessionId}
      nonce={nonce}
      theme="glass-dark"
      containerStyle={{ borderColor: '#60A5FA', padding: 16 }}
      onComplete={({ paymentMethod }) => {
        // Display-only when present; re-list cards from your server when it is absent.
        console.log(paymentMethod?.brand, paymentMethod?.lastFour);
      }}
      onDecline={({ message }) => console.warn(message)}
      onValidation={(message) => console.log(message)}
      onError={(error) => console.error(error.code, error.retryable ? 'retry' : 'do not retry')}
      onCancel={() => console.log('Card setup closed before it finished')}
    />
  );
}
```

Charge a saved payment method with one button:

```tsx
import { FloPayAutomaticPaymentButton } from '@flopay/react';

export function UpsellButton() {
  return (
    <FloPayAutomaticPaymentButton
      clientId="your-client-id"
      account={{ userId: 'user_1', email: 'user@example.com' }}
      items={[
        {
          providerItemId: 'upsell_1',
          providerItemName: 'AI Supercharger Pack',
          totalAmount: 249,
          overrideAmount: 80,
          currency: 'EUR',
          quantity: 1,
        },
      ]}
      successUrl="https://shop.example.com/success"
      cancelUrl="https://shop.example.com/cancel"
      theme="bold-dark"
      onClick={() => console.log('automatic payment button clicked')}
      onSuccess={({ sessionId, autoCompleted }) => console.log('paid', sessionId, autoCompleted)}
      onDecline={(decline) => console.warn('declined', decline.message)}
      onError={(error) => console.error(error.message)}
    >
      Purchase Item
    </FloPayAutomaticPaymentButton>
  );
}
```

Compose the provider, a checkout surface, and the hooks yourself:

```tsx
import { loadFloPay } from '@flopay/js';
import type { CheckoutSession } from '@flopay/shared';
import { FloPayProvider, SplitCardForm, useCheckout, useFloPay } from '@flopay/react';

const floPayPromise = loadFloPay('pk_test_...');

function PaymentStatus() {
  const flopay = useFloPay();
  const { loading, error, claimPending } = useCheckout();
  if (!flopay || loading) return <p>Loading payment methods…</p>;
  if (error) return <p role="alert">{error.message}</p>;
  return claimPending ? <p>Preparing your order…</p> : null;
}

export function ComposedCheckout({ session, nonce }: { session: CheckoutSession; nonce: string }) {
  return (
    <FloPayProvider flopay={floPayPromise} options={{ billingApiUrl: 'https://billing.example.com' }}>
      <PaymentStatus />
      <SplitCardForm
        sessionId={session.id}
        nonce={nonce}
        session={session}
        email="user@example.com"
        userId="user_1"
        totalAmount={2999}
        currency="usd"
        enableAVS
        enabledPaymentMethods={session.gateways?.stripe?.enabledPaymentMethods}
        onComplete={(result) => {
          if (result.status === 'succeeded') window.location.assign('/success');
        }}
        onError={(error) => console.error(error.message)}
      />
    </FloPayProvider>
  );
}
```

## Public API

### Components

| Component | Description |
|-----------|-------------|
| `FloPayCheckout` | Recommended self-contained checkout. Resolves gateways, mounts the hosted card form, and renders wallets, alternative methods, PayPal, and saved-payment flows. |
| `FloPayCardSetup` | No-charge card verification for a merchant-server-created setup session. Also exported from `@flopay/react/card-setup`. |
| `FloPayAutomaticPaymentButton` | One-click button that charges the customer's saved payment method, falling back to `FloPayCheckout` |
| `FloPayProvider` | Context provider for composed checkouts |
| `SplitCardForm` | Checkout surface combining the hosted card form with wallets, alternative methods, and PayPal |
| `VaultCardFields` | The hosted PCI card form. `SplitCardForm` uses it internally; it consumes a `CardCaptureAdapter` from `useFloPay().cardCapture()`. |
| `DirectPayPalButton` | Standalone direct PayPal Order, PayPal-managed subscription, or setup-token button using the session intent contract |

### Hooks

| Hook | Returns | Description |
|------|---------|-------------|
| `useFloPay()` | `FloPay \| null` | The current FloPay instance, or `null` while it loads |
| `useCheckout()` | `CheckoutState` | `{ session, loading, error, claimPending }` for the surrounding checkout |
| `usePayPalFloPay()` | `FloPay \| null` | The optional FloPay instance used for Stripe-rendered PayPal |

### Other exports

| Export | Description |
|--------|-------------|
| `FloPayCardSetupError` | Card-setup error class: a `FloPayError` with an explicit `retryable` flag |
| `dropThirdPartyOnlyError(event)` | Sentry `beforeSend` predicate re-exported from `@flopay/shared` |

### FloPayCheckoutProps

| Prop | Type | Description |
|------|------|-------------|
| `sessionId` | `string?` | Existing checkout session id. Required unless `createSession` is provided. |
| `nonce` | `string?` | Session-bound checkout token for `sessionId` |
| `createSession` | `InlineSessionDraft?` | Creates the session inline instead of using `sessionId` |
| `checkoutMode` | `CheckoutMode?` | Overrides the session's checkout mode |
| `billingApiUrl` | `string?` | Billing API base URL; defaults to the configured environment |
| `layout` | `'default' \| 'buttons'` | Payment form layout |
| `theme` | `ThemeId?` | Theme bundle for the whole checkout |
| `appearance` | `FloPayAppearance?` | Stripe-side appearance overrides |
| `buttonsStyles` | `ButtonsLayoutStyles?` | Wrapper style overrides layered on the theme |
| `locale` | `string?` | Locale for payment elements |
| `submitLabel` | `string \| null` | Live wording for the hosted card submit button |
| `confirmLabel` | `string?` | Saved-payment button label in confirm mode |
| `renderConfirmButton` | `function?` | Custom confirm-mode button renderer |
| `showStripe` | `boolean?` | Show Stripe wallets and alternative methods (default `true`) |
| `showPayPal` | `boolean?` | Show PayPal (default `true`) |
| `enabledPaymentMethods` | `string[]?` | Overrides the session's advertised Stripe methods |
| `enableAVS` | `boolean \| AVSFieldConfig` | Collect and validate the billing address |
| `avsLayout` | `'row' \| 'column'` | AVS field layout |
| `cardFieldOrder` | `VaultCardFieldKey[]?` | Order of the hosted card rows |
| `cardPreFormSlot` | `ReactNode?` | Content rendered above the hosted card form |
| `cardButtonContent` | `ReactNode?` | Card tile content in `layout="buttons"` |
| `cardBackButtonContent` | `ReactNode?` | Back-button label in `layout="buttons"` |
| `cardTitleContent` | `ReactNode?` | Card-form title |
| `className` | `string?` | Extra wrapper class |
| `loading` | `ReactNode?` | Custom loading UI |
| `error` | `(error: FloPayError) => ReactNode` | Custom error UI |
| `initialErrorMessage` | `string \| null` | Seeds an initial error message |
| `children` | `ReactNode?` | Custom layout rendered instead of the default `SplitCardForm` |
| `telemetry` | `boolean?` | Set `false` to opt out of operational telemetry |
| `onComplete` | `(result: PaymentResult) => void` | Payment succeeded or was authorized |
| `onDecline` | `(decline: DeclineEvent) => void` | A payment was declined or authentication failed |
| `onError` | `(error: FloPayError) => void` | A checkout error occurred |
| `onSessionCompleted` | `(successUrl: string) => void` | The session was already completed |
| `onButtonClick` | `(method: CheckoutButtonMethod) => void` | A payment method button was clicked |
| `onBeforeButtonClick` | `(event: BeforeButtonClickEvent) => ...` | Runs before a buttons-layout method continues; return a patch or `false` |
| `onInstrument` | `(event: FloInstrumentEvent) => void` | Merchant instrument feed |
| `onCountryChange` / `onZipChange` | `(value: string) => void` | AVS country or postcode changed |
| `showApplePay` / `showGooglePay` / `buttonsTheme` | deprecated | Superseded by the session's methods and `theme` |

### FloPayProviderProps

| Prop | Type | Description |
|------|------|-------------|
| `flopay` | `FloPay \| Promise<FloPay> \| null` | A FloPay instance or the promise from `loadFloPay()` |
| `paypalFlopay` | `FloPay \| Promise<FloPay> \| null` | Optional instance for the Stripe-rendered PayPal leg |
| `options.billingApiUrl` | `string?` | Billing API base URL exposed to child components |
| `onInstrument` | `(event: FloInstrumentEvent) => void` | Merchant instrument feed |
| `children` | `ReactNode` | Checkout content |

### FloPayCardSetupProps

| Prop | Type | Description |
|------|------|-------------|
| `sessionId` | `string` | Opaque id from the merchant-authenticated setup-session endpoint |
| `nonce` | `string` | Session-bound token returned with `sessionId` |
| `billingApiUrl` | `string?` | Billing API base URL; defaults to the configured environment |
| `telemetry` | `boolean?` | Set `false` to opt out of operational telemetry |
| `theme` | `ThemeId \| VaultCardThemeColors` | Preset (default `modern-light`) or raw hosted-form colors |
| `containerStyle` | `CSSProperties?` | Container styles merged over the preset surface |
| `loading` | `ReactNode?` | Content shown while the session and form load |
| `onReady` | `() => void` | The hosted form is mounted and accepting input |
| `onComplete` | `(event: FloPayCardSetupCompleteEvent) => void` | The card is verified and usable |
| `onDecline` | `(event: FloPayCardSetupDeclineEvent) => void` | Verification was declined |
| `onValidation` | `(message: string \| null) => void` | Live hosted-field validation, not an error |
| `onError` | `(error: FloPayCardSetupError) => void` | Validation or technical failure with `code` and `retryable` |
| `onCancel` | `(event: FloPayCardSetupCancelEvent) => void` | Unmounted before a terminal outcome |

### FloPayAutomaticPaymentButtonProps

Also accepts every standard `button` attribute, such as `onClick`, `disabled`, and `className`.

| Prop | Type | Description |
|------|------|-------------|
| `sessionId` / `nonce` | `string?` | Reuse an existing auto-mode session |
| `createSession` | `InlineSessionDraft?` | Inline draft for the session to create |
| `clientId` | `string?` | Merchant client id for inline creation |
| `items` / `subscriptions` / `products` | arrays | Cart for inline creation |
| `account` | `object?` | Buyer account; `userId` required, `email` optional |
| `successUrl` / `cancelUrl` | `string?` | Redirect targets |
| `couponCodes` | `string[]?` | Coupons to apply |
| `tagsData` / `utmMetadata` | objects | Analytics context |
| `billingApiUrl` | `string?` | Billing API base URL |
| `locale` | `string?` | Locale for the fallback checkout |
| `theme` | `ThemeId?` | Theme for the button and its fallback checkout |
| `appearance` / `buttonsStyles` | objects | Appearance overrides |
| `onSuccess` | `(event: FloPayAutomaticPaymentSuccessEvent) => void` | Payment completed |
| `onDecline` | `(decline: DeclineEvent) => void` | Payment declined |
| `onError` | `(error: FloPayError) => void` | Payment or setup error |
| `children` | `ReactNode?` | Button content |

### SplitCardFormProps

| Prop | Type | Description |
|------|------|-------------|
| `sessionId` | `string` | Checkout session id |
| `nonce` | `string?` | Session-bound token forwarded on intent, decline, account, and process calls |
| `session` | `CheckoutSession \| null` | Session capability data, including the optional `vault` block and gateways |
| `billingApiUrl` | `string?` | Billing API base URL |
| `email` / `userId` / `firstName` / `lastName` | `string?` | Buyer details |
| `totalAmount` | `number?` | Amount in the smallest currency unit, for wallet and PayPal configuration |
| `currency` | `string?` | Currency code for wallet and PayPal configuration |
| `layout` | `'default' \| 'buttons'` | Payment form layout |
| `theme` | `ThemeId?` | Theme bundle |
| `submitLabel` | `(string \| null)?` | Live wording for the hosted card submit button; omitted, `null`, or blank shows the hosted default `CONFIRM PAYMENT` |
| `showStripe` / `showPayPal` | `boolean?` | Toggle Stripe methods and PayPal |
| `enabledPaymentMethods` | `string[]?` | Stripe method identifiers; `card` advertises the hosted card form |
| `enableAVS` | `boolean \| AVSFieldConfig` | Collect and validate the billing address |
| `cardFieldOrder` | `VaultCardFieldKey[]?` | Order of the hosted card rows |
| `onBuyerIdentityReady` | `(identity) => MaybePromise<void>` | Attach a buyer identity collected by the form; `FloPayCheckout` wires its late-buyer claim here |
| `onTokenizedBody` | `(body: TokenizedBody) => void` | Take over non-card backend submission yourself |
| `onComplete` / `onDecline` / `onError` | callbacks | Checkout outcomes |
| `directPaypal` | `object?` | Direct PayPal client configuration when wiring PayPal manually; `FloPayCheckout` resolves it from `gateways.paypal` |
| `showApplePay` / `showGooglePay` | deprecated | Superseded by the session's `enabledPaymentMethods` |

### DirectPayPalButtonProps

| Prop | Type | Description |
|------|------|-------------|
| `sessionId` / `nonce` | `string` | Checkout session and its token |
| `billingApiUrl` | `string` | Billing API base URL |
| `clientId` | `string` | PayPal client id (`gateways.paypal.publishableKey`) |
| `environment` | `GatewayEnvironment?` | `sandbox` or `live` |
| `currency` | `string` | ISO 4217 currency code |
| `isSubscription` | `boolean` | Fallback flow selection when the session advertises no `providerObjectType` |
| `approvalPresentation` | `PayPalApprovalPresentation?` | Billing-API-advertised approval mode |
| `session` | `CheckoutSession \| null` | Backing session; its advertised values are authoritative |
| `onComplete` / `onDecline` | callbacks | Payment outcomes |
| `onTechnicalFailure` | callback | Post-click technical failures, including `popupBlocked` |

### Types

| Type | Description |
|------|-------------|
| `FloPayCheckoutProps` | Props for `FloPayCheckout` |
| `FloPayProviderProps` | Props for `FloPayProvider` |
| `FloPayCardSetupProps` | Props for `FloPayCardSetup` |
| `FloPayCardSetupCompleteEvent` | Verified setup: `status`, `sessionId`, and optional display-only `paymentMethod` |
| `FloPayCardSetupDeclineEvent` | Declined setup: `status`, `sessionId`, `reason?`, `message?` |
| `FloPayCardSetupCancelEvent` | Unmounted before a terminal outcome: `status`, `sessionId` |
| `FloPayAutomaticPaymentButtonProps` | Props for `FloPayAutomaticPaymentButton` |
| `FloPayAutomaticPaymentSuccessEvent` | `result`, `session`, `sessionId`, and `autoCompleted` |
| `SplitCardFormProps` | Props for `SplitCardForm` |
| `DirectPayPalButtonProps` | Props for `DirectPayPalButton` |
| `VaultCardFieldsProps` | Props for `VaultCardFields` |
| `CheckoutState` | `session`, `loading`, `error`, `claimPending?` |
| `DeclineEvent` | `method`, `message`, `code?`, `declineCode?` |
| `BeforeButtonClickEvent` | `method`, `sessionId?`, `createSession?` |
| `CheckoutButtonMethod` | The payment method a button represents |
| `InlineSessionDraft` | Draft accepted by `FloPayCheckoutProps.createSession` |
| `InlineSessionPatch` | Partial draft returned from `FloPayCheckoutProps.onBeforeButtonClick` |
| `FloInstrumentEvent` | Versioned merchant instrument union |

## Errors

Checkout errors reach `onError` (and the `error` render prop) as `FloPayError` values from `@flopay/shared`. Branch on `type` and `code`; raw provider messages are never shown to buyers or passed to callbacks. Declines are not errors: they reach `onDecline` with a `DeclineEvent`.

| Code | Type | When |
|------|------|------|
| `checkout_session_expired` | `api_error` | `FloPayCheckout` or `FloPayAutomaticPaymentButton` loaded an expired session. Create a fresh session and send the buyer back through checkout. |
| `CurrencyRequired` | `validation_error` | `createSession` has no resolvable currency. Thrown before any request. |
| `InvalidIdempotencyKey` | `validation_error` | `createSession.idempotencyKey` is blank or longer than 255 characters. |
| `CaptureMethodUnsupportedForSubscription` | `validation_error` | Manual capture was requested for a cart with a subscription. |
| `BuyerEmailCollectionRequired` | `validation_error` | A buyer email supplied to the checkout is not a valid email address. |
| `UnsupportedBackendVaultCapability` | `api_error` | The session offers no hosted card form or card capability and no other usable payment method. |
| `CheckoutInitializationFailed` | `api_error` | Neither the hosted card form nor Direct PayPal could render after Stripe.js failed to initialize. |
| `paypal_init_timeout` | `api_error` | A PayPal-only checkout could not start PayPal after automatic recovery. The **Retry PayPal** button tries again. |
| `external_payment_method_failed` | `api_error` | A wallet, PayPal, or alternative method failed technically after the buyer clicked it. The chooser offers a retry. |
| `InvalidCardSetupSurface` | `validation_error` | `FloPayCheckout` was given a card-setup session; use `FloPayCardSetup`. |
| `three_ds_aborted` / `three_ds_timeout` | `api_error` | The buyer cancelled, or did not finish, the authentication challenge for a saved card in confirm or auto mode. |
| `InvalidPaymentProcessResponse` | `api_error` | Saved-payment processing returned a response the SDK could not use. |

A `validation_error` is also reported when both `showStripe` and `showPayPal` are false with no PayPal gateway, or when a session advertises no supported gateway. When saving the AVS billing address is rejected with a `4xx`, `onError` receives the parsed billing API error while the message shows inline; the buyer corrects it for the next attempt.

`FloPayCardSetup` reports failures to `onError` as `FloPayCardSetupError`, which adds `retryable`. Network, rate-limit, `5xx`, card-form timeout, and unexpected failures are retryable; validation failures are not.

| Code | When |
|------|------|
| `MissingCheckoutSessionId` | Rendered without `sessionId` |
| `MissingCheckoutSessionToken` | Rendered without `nonce` |
| `InvalidCardSetupSession` | The session is not a zero-amount, product-free setup session |
| `UnsupportedBackendVaultCapability` | The setup session has no hosted card capability |
| `card_setup_widget_failed` | The hosted card form failed at runtime (retryable) |
| `card_setup_load_failed` | Loading the session or card form failed for another reason |
