@flopay/react 1.9.15 → 1.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +466 -1104
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @flopay/react
2
2
 
3
- React bindings for the FloPay SDK. Provides a context provider, drop-in checkout form components, individual element components, and hooks.
3
+ 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.
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,14 +8,11 @@ React bindings for the FloPay SDK. Provides a context provider, drop-in checkout
8
8
  pnpm add @flopay/react @flopay/js @flopay/shared react react-dom
9
9
  ```
10
10
 
11
- Peer dependencies: `react >= 18.0.0`, `react-dom >= 18.0.0`.
11
+ Peer dependencies: `react >= 18.0.0` and `react-dom >= 18.0.0`. Install the same version of every `@flopay/*` package.
12
12
 
13
- ## Environment Setup
14
-
15
- Configure which billing API the SDK uses. Choose one:
13
+ Configure which billing API the SDK uses, once. Either set an environment variable:
16
14
 
17
15
  ```bash
18
- # Option A: Environment variable (recommended)
19
16
  # .env.production
20
17
  NEXT_PUBLIC_FLOPAY_ENV=production
21
18
 
@@ -23,1253 +20,618 @@ NEXT_PUBLIC_FLOPAY_ENV=production
23
20
  NEXT_PUBLIC_FLOPAY_ENV=staging
24
21
  ```
25
22
 
23
+ or call `configureFlopay()` at startup:
24
+
26
25
  ```ts
27
- // Option B: Call at app startup
28
26
  import { configureFlopay } from '@flopay/shared';
27
+
29
28
  configureFlopay({ environment: 'production' });
30
29
  ```
31
30
 
32
- If neither is set, the SDK defaults to staging.
31
+ If neither is set, the SDK uses the staging billing API.
33
32
 
34
- ## Quick Start
33
+ **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.
35
34
 
36
- ### Recommended: FloPayCheckout
35
+ ## Usage
37
36
 
38
- The simplest integration — one component, one prop:
37
+ ### FloPayCheckout
39
38
 
40
- ```tsx
41
- import { FloPayCheckout } from '@flopay/react';
39
+ `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.
42
40
 
43
- function CheckoutPage() {
44
- return (
45
- <FloPayCheckout
46
- sessionId="sess_abc123"
47
- onComplete={(result) => {
48
- window.location.href = '/success';
49
- }}
50
- onError={(err) => console.error(err)}
51
- />
52
- );
53
- }
41
+ ```tsx
42
+ <FloPayCheckout
43
+ sessionId="sess_abc123"
44
+ nonce={sessionNonce}
45
+ onComplete={(result) => router.push('/success')}
46
+ onError={(error) => console.error(error)}
47
+ />
54
48
  ```
55
49
 
56
- `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.
57
-
58
- #### Buyer identity collection
50
+ Pass `nonce`, the session-bound checkout token returned when the session was created, whenever you pass an existing `sessionId`.
59
51
 
60
- For inline checkout creation, `createSession.account.userId` remains required,
61
- but `email`, `firstName`, and `lastName` may be omitted. `FloPayCheckout`
62
- creates a mountable session shell first, immediately attaches the known catalog,
63
- and collects buyer identity at the method-appropriate point:
64
-
65
- - Hosted card checkout renders a required email field first, above name, AVS,
66
- and the hosted card widget. Submit stays gated until the email passes the
67
- shared `isValidEmail()` validator and a name is present.
68
- - Apple Pay, Google Pay, other Stripe wallets, and PayPal use provider-returned
69
- email/name automatically when available. Their provider UI starts without a
70
- Flo identity preflight.
71
- - A wallet, PayPal, or APM that does not return the missing fields opens a
72
- single required fallback pane after that provider miss (or before submission
73
- for a method that cannot return identity).
74
-
75
- The SDK never attaches a blank email. The merchant-supplied `userId` is not
76
- replaced by provider data. A legitimate merchant- or session-supplied buyer
77
- name remains authoritative over a different wallet name. The exact generic
78
- placeholder `Card Holder Name` is instead treated as missing after case and
79
- whitespace normalization, so an authorised wallet name can replace it. Name
80
- selection is atomic: first and last name always come from one source, and are
81
- omitted when neither source is usable. Email precedence is unchanged.
82
-
83
- #### Wallet confirmation recovery
84
-
85
- Google Pay and Apple Pay authentication may legitimately outlive the SDK's
86
- provider observation threshold. The SDK does not cancel that provider promise.
87
- Instead, it retrieves the original PaymentIntent or SetupIntent with the same
88
- client secret and routes the provider promise and status read through one
89
- settlement gate:
90
-
91
- - an authoritative success continues to `/process` exactly once;
92
- - `canceled` or `requires_payment_method` is treated as authoritative,
93
- non-chargeable terminal evidence and exposes wallet retry;
94
- - `requires_action`, an unavailable/mismatched status, or a failed/bounded-out
95
- status read keeps payment submission locked and exposes only **Check payment
96
- status**.
97
-
98
- Rejected confirmations, returned technical errors, and malformed fulfilled
99
- results use this same recovery contract. A lost intent-create response also
100
- reuses the authorization-attempt UUID on deliberate retry, so provider
101
- idempotency cannot mint a second intent. A bounded pending-confirmation handoff
102
- keeps teardown/remount from exposing another wallet surface while the first
103
- intent may still complete. Existing component props and merchant callbacks are
104
- unchanged.
52
+ #### Checkout modes
105
53
 
106
- #### Merchant instrument feed
54
+ `checkoutMode` matches the billing API's `checkoutMode` and overrides the session's value:
107
55
 
108
- Use `onInstrument` on `FloPayCheckout` or `FloPayProvider` to forward a stable,
109
- privacy-safe checkout funnel to your own analytics. The callback receives the
110
- exported `FloInstrumentEvent` discriminated union; `schemaVersion` is always `1`,
111
- and `gateway` is included as `stripe` or `paypal` only when the event is
112
- attributable to that payment provider. Hosted-vault events may omit `gateway`,
113
- and `card_expanded` always does when the session can route cards to a
114
- [server-side card gateway](#server-side-card-gateways-worldpay).
56
+ - **`full`** (default): the hosted card form plus supported non-card methods.
57
+ - **`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.
58
+ - **`auto`**: pays with the saved payment method as soon as the session loads, falling back to `full` if that cannot complete.
115
59
 
116
- ```tsx
117
- import type { FloInstrumentEvent } from '@flopay/react';
118
-
119
- function forwardCheckoutInstrument(event: FloInstrumentEvent) {
120
- window.analytics?.track(event.name, event);
121
- }
122
-
123
- <FloPayCheckout sessionId={sessionId} onInstrument={forwardCheckoutInstrument} />;
124
- ```
60
+ #### Buyer identity collection
125
61
 
126
- Lifecycle names are `checkout_mount`, `sdk_loaded`, `form_rendered`,
127
- `card_expanded`, `tokenize`, `process_attempt`, and `3ds_challenge`.
128
- `checkout_mount`, `sdk_loaded`, `form_rendered`, and `card_expanded` arrive at
129
- most once per logical checkout. `tokenize`, `process_attempt`, and
130
- `3ds_challenge` may repeat for each attempt.
131
- `checkout_error` adds one of four phases: `session_create`, `sdk_load`,
132
- `process`, or `wallets`.
62
+ 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:
133
63
 
134
- The callback is an allowlisted projection with no card data, tokens, provider
135
- object IDs, secrets, or raw PII. A throwing `onInstrument` consumer never
136
- breaks checkout. This merchant-owned feed is independent of the Flo-owned
137
- backend telemetry setting, so `telemetry={false}` does not disable it.
64
+ - 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.
65
+ - 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.
66
+ - 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).
138
67
 
139
- #### Checkout Modes
68
+ 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.
140
69
 
141
- `FloPayCheckout` supports three checkout modes matching the billing API's `checkoutMode` field:
70
+ #### Inline session creation
142
71
 
143
- - **`full`** (default) — Shows the hosted card widget plus supported non-card methods.
144
- - **`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.
145
- - **`auto`** — Auto-submits with a saved payment method after the session loads. Falls back to `full` mode on failure.
72
+ Skip your own session-creation route and create the session inside the component with `createSession`:
146
73
 
147
74
  ```tsx
148
- // Confirm mode — one-click purchase with saved card
149
- <FloPayCheckout
150
- sessionId="sess_abc123"
151
- checkoutMode="confirm"
152
- confirmLabel="Complete Purchase"
153
- onComplete={(result) => router.push('/success')}
154
- />
155
-
156
- // Auto mode — instant checkout, falls back to full form
157
75
  <FloPayCheckout
158
- sessionId="sess_abc123"
159
- checkoutMode="auto"
160
- onComplete={(result) => router.push('/success')}
161
- onSessionCompleted={(successUrl) => router.push(successUrl)}
76
+ createSession={{
77
+ clientId: 'your-client-id',
78
+ currency: 'EUR',
79
+ items: [{ providerItemId: 'product-1', providerItemName: 'Widget', totalAmount: 29.99 }],
80
+ account: { userId: 'user_1' }, // email and name are collected in checkout
81
+ successUrl: '/success',
82
+ cancelUrl: '/cancel',
83
+ checkoutMetadata: { merchantOrderId: 'order-123' },
84
+ }}
85
+ onComplete={() => router.push('/success')}
162
86
  />
163
87
  ```
164
88
 
165
- The mode can also be set on the session itself via the billing API's `checkoutMode` field. The `checkoutMode` prop overrides the session value.
89
+ - **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.
90
+ - **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.
91
+ - **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.
92
+ - **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.
93
+ - **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.
94
+ - **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.
95
+
96
+ #### Authorisation-only checkout
166
97
 
167
- `confirmLabel` only labels the saved-payment button in confirm mode. To change
168
- the full checkout's hosted card button, use
169
- [`submitLabel`](#hosted-card-submit-label).
98
+ 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.
170
99
 
171
100
  #### Hosted card submit label
172
101
 
173
- In full checkout, the button that pays by card lives inside the hosted vault
174
- widget. Set `submitLabel` on `FloPayCheckout` (or `SplitCardForm`) to replace
175
- its wording, for example with the amount the buyer is about to pay:
102
+ 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.
176
103
 
177
- ```tsx
178
- <FloPayCheckout
179
- sessionId="sess_abc123"
180
- submitLabel={`Pay ${orderSummary.formattedTotal}`} // e.g. "Pay $10"
181
- onComplete={handleSuccess}
182
- />
183
- ```
104
+ - **Your wording.** Pass final, already-localized plain text from your own order summary. The SDK does not calculate, format, or translate amounts.
105
+ - **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.
106
+ - **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.
107
+ - **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.
108
+ - **Custom layouts.** `FloPayCheckout` injects `submitLabel`, like `sessionId` and `nonce`, into its direct custom-layout children; a child's own `submitLabel` wins.
109
+ - **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.
184
110
 
185
- - **Merchant-owned wording.** Pass the final, already-localized plain text
186
- from your own order summary. The SDK does not calculate, format, or translate
187
- the amount; it sends your string unchanged.
188
- - **Live updates.** Changing the prop updates the hosted button in place,
189
- without remounting the card form, so the buyer's entered card details, focus,
190
- and caret are kept. The SDK re-sends the latest label whenever the hosted
191
- form becomes ready again.
192
- - **Defaults and reset.** Omit the prop (or pass `null`) to keep the hosted
193
- default, `CONFIRM PAYMENT`. Clearing it after a label was shown restores that
194
- default, and so does blank text: the hosted form trims the label, allows up to 64
195
- characters, rejects control and hidden formatting characters, and shows the
196
- default for anything it rejects. Card setup (`FloPayCardSetup`) keeps its own
197
- `ADD CARD` wording.
198
- - **Prop ownership.** `submitLabel` only changes the hosted card button.
199
- `confirmLabel` labels the saved-payment button in `checkoutMode="confirm"`,
200
- `cardButtonContent` renders the buttons-layout card tile, and wallet, PayPal,
201
- processing, and retry wording is unchanged.
202
- - **Custom layouts.** Like `sessionId` and `nonce`, `FloPayCheckout` injects
203
- `submitLabel` into its direct custom-layout children; a child's own
204
- `submitLabel` wins.
205
- - **Compatibility.** Custom labels need billing API v1.9.35+, whose vault block
206
- carries the `messageToken` and `expectedOrigin` that authenticate the label.
207
- The SDK sends the label only to that exact origin, bound to the checkout
208
- session. Against an older backend the hosted form keeps its default.
111
+ #### Wallets and alternative payment methods
209
112
 
210
- #### Server-side card gateways (Worldpay)
113
+ 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.
211
114
 
212
- The billing API can route a checkout's cards to an approved Worldpay Corporate
213
- Gateway (TeamFloPay/backend#1563). It processes those payments entirely
214
- server-side behind the same hosted vault card form, so your checkout code does
215
- not change and never needs to know which gateway took the card:
216
-
217
- - **Opt-in on create.** An inline Full-mode checkout (`createSession`, or a
218
- session created with `PaymentAPI.createAndFetchSession` /
219
- `createDetachedSession`) declares the `worldpay-corporate-gateway-v1`
220
- capability. Auto, Confirm, and pre-tokenized sessions do not, and neither do
221
- older SDKs, so their routing is unchanged. See the `@flopay/js` README for the
222
- wire contract.
223
- - **Same card surface.** `FloPayCheckout` and `SplitCardForm` mount the hosted
224
- form with no Stripe runtime and no Worldpay browser SDK or credentials. When
225
- a session read omitted the embedded vault block, an open session that
226
- advertises `gateways.worldpayCorporateGateway` recovers the form from
227
- `POST /v1/checkouts/sessions/{id}/vault/capture` instead of failing to mount.
228
- A completed or authorized session is never offered the card form again.
229
- - **Same callbacks.** A success reaches `onComplete` as
230
- `{ status: 'succeeded', checkoutMethod: 'card' }`, and a manual-capture hold
231
- as `status: 'authorized'` with `paymentId`, `sessionId`, and
232
- `authorizationExpiresAt`. `paymentIntentId` is omitted for these sessions
233
- because the processor's order reference is not a Stripe object. A refusal
234
- reaches `onDecline` with the stable `authorization_declined` code. A payment
235
- the billing API is still reconciling when the hosted form stops waiting
236
- reaches `onError`, never `onComplete`; a later session read reports the
237
- settled status through `onComplete` or `onSessionCompleted`.
238
- - **Unattributed card funnel.** `card_expanded` omits `gateway` for these
239
- sessions, because the billing API, not the browser, chooses the card's
240
- gateway.
241
-
242
- ### Secure saved-card setup
243
-
244
- Use `FloPayCardSetup` to let a customer add or verify a card without creating a
245
- purchase or charge. Your trusted server must first call the
246
- merchant-authenticated card-setup endpoint and pass only its opaque `sessionId`
247
- and bound `nonce` to the browser. Never send Client Basic credentials, OAuth
248
- tokens, or a customer identifier to this component.
115
+ `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.
249
116
 
250
- ```tsx
251
- import { FloPayCardSetup } from '@flopay/react';
117
+ - `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.
118
+ - 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.
119
+ - 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.
120
+ - **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.
121
+ - `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.
252
122
 
253
- function AddCard({ sessionId, nonce }: { sessionId: string; nonce: string }) {
254
- return (
255
- <FloPayCardSetup
256
- sessionId={sessionId}
257
- nonce={nonce}
258
- onComplete={({ paymentMethod }) => {
259
- // Display-only when supplied. Re-list from the server if absent during
260
- // a mixed-version backend rollout.
261
- console.log(paymentMethod?.brand, paymentMethod?.lastFour);
262
- }}
263
- onDecline={({ message }) => showCardError(message)}
264
- onValidation={(message) => showCardValidation(message)}
265
- onError={(error) => {
266
- if (error.retryable) showRetry();
267
- }}
268
- onCancel={() => closeAddCard()}
269
- />
270
- );
271
- }
272
- ```
123
+ #### PayPal
273
124
 
274
- The component reads and validates the setup session before injecting FloPay's
275
- hosted PCI card widget. It refuses purchase sessions, zero-amount non-setup
276
- sessions, non-zero setup sessions, and setup sessions containing products. Bank
277
- authentication is shown through the existing hosted challenge and does not
278
- count as success while pending. Completion fires only after verification says
279
- the card is usable; decline, validation, retryable technical failure, and
280
- unmount cancellation remain separate outcomes.
281
-
282
- Submission feedback matches checkout: the same processing overlay covers the
283
- form from the buyer's submit ("SAVING CARD...") and flips to "CARD SAVED"
284
- (held briefly before `onComplete`) or "CARD NOT SAVED" with the decline
285
- message (cleared automatically so the buyer can retry in place).
286
-
287
- #### Card-setup theming
288
-
289
- `theme` accepts the same `ThemeId` as `FloPayCheckout` (default
290
- `'modern-light'`) or raw `VaultCardThemeColors`. A named preset styles the
291
- whole card-setup experience coherently:
292
-
293
- - **Surface** — the container around the hosted form wears the preset's
294
- complete credit-card pane treatment (`cardFormContainer`: background or
295
- gradient, border, radius, shadow, padding), so `modern-dark` never places
296
- dark hosted fields on a light, unthemed box.
297
- - **Hosted form** — the widget receives the preset's field colors and
298
- typography plus the complete hosted submit snapshot
299
- (`VaultCardThemeColors.submitButton`, billing API v1.9.6+): label color,
300
- solid or gradient enabled/hover/disabled fills, typography, spacing, shadow,
301
- radius, disabled opacity, and blur. The legacy flat keys are always sent
302
- alongside it, so an older hosted asset keeps its solid approximation during a
303
- rolling deployment, and an older SDK against a newer asset keeps its
304
- historical flat-only behavior. `modern-dark`'s dark label stays at or above
305
- 4.5:1 in the enabled, hover, and disabled states, disabled compositing
306
- included.
307
- - **Processing / success / error** — the overlay reuses the pane surface and
308
- typography; processing wears the preset primary on its spinner, while success
309
- and error keep semantic green/red accents chosen per color scheme so they
310
- stay legible on every pane.
311
-
312
- `'classic'` keeps the historical card-setup appearance exactly: no derived
313
- surface, the neutral overlay, and no theme directive sent to a freshly mounted
314
- hosted form. Raw `VaultCardThemeColors` objects — including partial ones — are
315
- forwarded to the hosted form unchanged and add no surface or overlay treatment.
316
-
317
- Changing `theme` at runtime updates the SDK-owned surface and overlay in place
318
- and re-skins the mounted hosted form without remounting it, so entered card
319
- details, focus, and caret are preserved. Switching back to `'classic'` sends
320
- the hosted form's explicit reset (`theme: null`) so nothing from the previous
321
- preset lingers.
322
-
323
- ##### `containerStyle` precedence
324
-
325
- `containerStyle` is merged over the preset surface with deterministic rules:
326
-
327
- 1. Each `containerStyle` property wins over the preset value for the same
328
- property.
329
- 2. Declarations are applied preset-first, then `containerStyle` in your own key
330
- order, so a longhand refines a preset shorthand of the same family the way
331
- CSS cascades: `borderColor: 'red'` recolors the preset border and keeps its
332
- width and style; `paddingTop: 0` adjusts one side.
333
- 3. Background is the exception: any `background*` member in `containerStyle`
334
- replaces the preset's background treatment entirely, so a color never shows
335
- a glass preset's gradient through it.
336
- 4. With `'classic'` or a raw theme there is no preset surface: `containerStyle`
337
- applies alone, exactly as before.
125
+ The SDK renders PayPal one of two ways, chosen per session from its gateways:
338
126
 
339
- ```tsx
340
- <FloPayCardSetup
341
- sessionId={sessionId}
342
- nonce={nonce}
343
- theme="glass-dark"
344
- containerStyle={{ borderColor: '#60A5FA', padding: 16 }} // refines the glass-dark pane
345
- />
346
- ```
127
+ - **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.
128
+ - **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.
347
129
 
348
- Saved-card listing, setup-session creation, and deletion remain authenticated
349
- merchant REST calls. Replacement is composition: complete setup for the new
350
- card, then ask your server to delete the old card. `FloPayCardSetup` deliberately
351
- has no customer-id, merchant-credential, list, delete, purchase, or Stripe
352
- Elements prop.
130
+ 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.
353
131
 
354
- Prefer importing it from the dedicated subpath on pages that never run a
355
- checkout:
132
+ **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`.
356
133
 
357
- ```tsx
358
- import { FloPayCardSetup } from '@flopay/react/card-setup';
359
- ```
134
+ #### Server-side card gateways (Worldpay)
360
135
 
361
- The main entry re-exports the component for back-compat, but its module graph
362
- eagerly loads Stripe.js (a deliberate checkout preload); the `card-setup`
363
- entry's graph never reaches Stripe, so saved-card pages stay free of Stripe
364
- network activity by construction.
136
+ 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:
365
137
 
366
- #### Expired Sessions
138
+ - **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.
139
+ - **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.
140
+ - **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.
141
+ - The merchant instrument feed's `card_expanded` event omits `gateway` for these sessions, because the billing API chooses the card's gateway.
367
142
 
368
- 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.
143
+ #### Themes and layouts
369
144
 
370
- The error has:
145
+ `FloPayCheckout`, `FloPayAutomaticPaymentButton`, `SplitCardForm`, and `FloPayCardSetup` accept one `theme` prop, a `ThemeId`:
371
146
 
372
- - `message: 'Checkout session has expired.'`
373
- - `type: 'api_error'`
374
- - `code: 'checkout_session_expired'`
147
+ | Theme | Aesthetic |
148
+ |-------|-----------|
149
+ | `classic` | The historic FloPay look (no bundle applied); the default for checkout components |
150
+ | `modern-light` / `modern-dark` | Clean and airy: Inter, soft shadows, FloPay-blue accents |
151
+ | `bold-light` / `bold-dark` | Saturated FloPay blue with a gradient pill submit and heavy borders |
152
+ | `glass-light` / `glass-dark` | Translucent surfaces with backdrop blur over a blue gradient |
375
153
 
376
- Use this code to create or request a fresh checkout session, then send the customer back through checkout:
154
+ 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.
377
155
 
378
- ```tsx
379
- <FloPayCheckout
380
- sessionId={sessionId}
381
- onComplete={() => router.push('/success')}
382
- onError={(error) => {
383
- if (error.code === 'checkout_session_expired') {
384
- router.push('/checkout/expired');
385
- return;
386
- }
156
+ `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.
387
157
 
388
- console.error(error);
389
- }}
390
- />
391
- ```
158
+ #### Merchant instrument feed
392
159
 
393
- ### Automatic Payment Buttons
160
+ `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.
394
161
 
395
- Use `FloPayAutomaticPaymentButton` when you want a single reusable button that:
162
+ - 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.
163
+ - `checkout_error` adds one of four phases: `session_create`, `sdk_load`, `process`, or `wallets`.
396
164
 
397
- - creates an `auto` checkout session inline or reuses an existing `sessionId`
398
- - lets the backend resolve and charge the customer's most recently vaulted payment method (across providers)
399
- - shows the shared processing / success / failure modal
400
- - emits the same success and decline events without rendering a full checkout form
165
+ 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.
401
166
 
402
- The button accepts either:
167
+ #### Privacy-safe operational telemetry
403
168
 
404
- - `sessionId`
405
- - or the same session-creation props you would normally post to the backend: `clientId`, `items`, `subscriptions`, `account`, `successUrl`, `cancelUrl`, `couponCodes`, `tagsData`, `utmMetadata`
169
+ `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}`.
406
170
 
407
- It accepts the same `theme` prop as `FloPayCheckout` and `SplitCardForm` (see [Theming](#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.
171
+ 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.
408
172
 
409
- > **Removed in this version**: the legacy `paymentMethodId` and `checkoutMethod` props are now `@deprecated` and silently ignored. The backend's auto-checkout (`createSingle`) looks up the customer's latest vaulted payment method via `getLatestByUserId` and 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.
173
+ Hosts with their own Sentry client can use the re-exported filter as `beforeSend`:
410
174
 
411
- ```tsx
412
- import { FloPayAutomaticPaymentButton } from '@flopay/react';
175
+ ```ts
176
+ import * as Sentry from '@sentry/browser';
177
+ import { dropThirdPartyOnlyError } from '@flopay/react';
413
178
 
414
- function UpsellButton() {
415
- return (
416
- <FloPayAutomaticPaymentButton
417
- clientId="your-client-id"
418
- account={{ userId: 'user_1', email: 'user@example.com' }}
419
- items={[
420
- {
421
- providerItemId: 'upsell_1',
422
- providerItemName: 'AI Supercharger Pack',
423
- totalAmount: 249,
424
- overrideAmount: 80,
425
- currency: 'EUR',
426
- quantity: 1,
427
- },
428
- ]}
429
- successUrl={`${window.location.origin}/success`}
430
- cancelUrl={`${window.location.origin}/success`}
431
- theme="bold-dark"
432
- onClick={() => {
433
- window.dataLayer?.push({ event: 'automatic_payment_button_click' });
434
- }}
435
- onSuccess={() => {
436
- console.log('automatic payment succeeded');
437
- }}
438
- onError={(error) => {
439
- console.error(error.message);
440
- }}
441
- onDecline={(decline) => {
442
- console.log('automatic payment declined', decline);
443
- }}
444
- >
445
- Purchase Item
446
- </FloPayAutomaticPaymentButton>
447
- );
448
- }
179
+ Sentry.init({ beforeSend: dropThirdPartyOnlyError });
449
180
  ```
450
181
 
451
- `children` render inside the button, so you can treat them as slot content. If omitted, the default buttons-layout card content is used.
452
-
453
- #### Wallet Buttons & alternative payment methods
454
-
455
- The payment-method list is now **gateway-driven**: the billing API ships
456
- `gateways.stripe.enabledPaymentMethods` per session, derived from the Stripe
457
- Payment Method Configurations enabled on the merchant account. The SDK
458
- partitions that list into two regions inside `SplitCardForm`:
459
-
460
- - `ExpressCheckoutElement` — `apple_pay`, `google_pay`, `paypal`, `link`,
461
- `amazon_pay`, `klarna` (everything supported as a native big button).
462
- - `PaymentElement` (accordion) — every other enabled method, e.g. `cashapp`,
463
- `affirm`, `ideal`, `bancontact`, `sepa_debit`.
464
-
465
- `card` is a capability marker, not a Stripe-rendered method. It keeps the
466
- **Credit / Debit Card** path available and tells SDK 1.4.9+ that the hosted
467
- vault form can be requested lazily when `session.vault` is absent.
468
-
469
- Enabling Cash App Pay (or any future Stripe method) in the Stripe dashboard is
470
- the entire integration change — no SDK redeploy or consumer code update is
471
- required. Wallets only render on supported devices regardless of dashboard
472
- state (Apple Pay on Safari/macOS/iOS, Google Pay on Chrome).
473
-
474
- > **Payment-logo CDN and CSP:** APM brand logos load from the versioned,
475
- > FloPay-owned URL `https://cdn.flopay.com/sdk-logos/v1/`. Plain absolute HTTPS
476
- > URLs work in Vite with no SDK middleware or `optimizeDeps` override, and retain
477
- > the same behavior in webpack 5, Rollup, Next, and esbuild. Logos stay out of
478
- > the package and JavaScript bundle; card-only and vault checkouts never request
479
- > one. Merchants with a strict Content Security Policy must allow the host in
480
- > the image directive, for example `img-src 'self' https://cdn.flopay.com;`.
481
- > A failed or CSP-blocked logo request falls back to the method's existing
482
- > inline monogram, so the tile remains branded without another network request.
483
-
484
- In `layout="buttons"`, the **Credit / Debit Card** button always opens the
485
- inline card form. External wallets and PayPal keep their own lifecycle:
486
- buyer cancellation silently returns to the payment-method chooser with all
487
- eligible options available, while post-click technical failures, popup
488
- blocking, or a provider surface returning focus without a terminal callback
489
- restore the chooser, focus a visible retry control for the failed method, show safe method-specific copy such as
490
- `We couldn't open Google Pay. Try again or choose another payment method.`, and
491
- call `onError` with a structured `FloPayError`. Passive pre-click load or
492
- eligibility failures still collapse only the unavailable method and do not show
493
- a checkout error.
494
-
495
- Wallet confirmation has one 25-second SDK budget inside the provider's
496
- 30-second callback deadline. Flo-owned intent requests are aborted when that
497
- budget expires, and every unsuccessful authorization settles the provider
498
- callback once. Payment and setup wallets are confirmed through the active
499
- `ExpressCheckoutElement` Elements instance. If a wallet omits required buyer
500
- identity, the current authorization is failed promptly, the SDK collects the
501
- missing email outside the wallet callback, and the buyer then reopens the
502
- wallet to continue. The resolved identity is kept unchanged through the late
503
- buyer claim, confirmation checkpoint, retry/resume, and final `/process`
504
- request.
505
-
506
- Toggle the whole Stripe region or the PayPal region independently with the
507
- two gateway-level props:
182
+ 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.
508
183
 
509
- ```tsx
510
- <FloPayCheckout
511
- sessionId="sess_abc123"
512
- showStripe={true} // default: true — Stripe wallets/APMs only
513
- showPayPal={true} // default: true — DirectPayPal when gateway present,
514
- // Stripe-rendered PayPal otherwise
515
- onComplete={handleSuccess}
516
- />
517
- ```
184
+ ### FloPayCardSetup
518
185
 
519
- Setting both to `false` (with no PayPal gateway configured) is treated as a
520
- bootstrap-time validation error — `onError` fires with a
521
- `FloPayError({ type: 'validation_error' })` so the misconfiguration surfaces
522
- during integration instead of silently rendering an empty form.
186
+ `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.
523
187
 
524
- ##### Deprecated props
188
+ - 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.
189
+ - 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.
190
+ - 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.
191
+ - 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.
192
+ - 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.
525
193
 
526
- `showApplePay`, `showGooglePay`, and `directPaypal` are still accepted but
527
- emit a one-time `console.warn` when supplied alongside the new
528
- `enabledPaymentMethods` payload. They are no-ops once the backend ships the
529
- field — Apple Pay / Google Pay are dashboard-controlled at Stripe, and direct
530
- PayPal is auto-resolved from `gateways.paypal` on the session response. The
531
- legacy props will be removed in `2.0`.
194
+ **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.
532
195
 
533
- #### Theming
196
+ **`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.
534
197
 
535
- `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` has no surrounding Stripe Elements: the preset styles its container surface, the hosted vault widget, and its outcome overlay — see [Card-setup theming](#card-setup-theming) — 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.
198
+ ### FloPayAutomaticPaymentButton
536
199
 
537
- | `theme` | Aesthetic |
538
- |---|---|
539
- | `'classic'` | Historic FloPay look (no bundle applied — `#EDEDFF` wrapper, indigo submit). |
540
- | `'modern-light'` / `'modern-dark'` | Clean & airy, Inter, soft shadows, FloPay-blue accents. |
541
- | `'bold-light'` / `'bold-dark'` | Saturated FloPay blue with gradient pill submit and heavy borders. |
542
- | `'glass-light'` / `'glass-dark'` | Translucent surfaces with backdrop blur over a blue gradient. |
200
+ `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.
543
201
 
544
- ```tsx
545
- <FloPayCheckout
546
- sessionId="sess_abc123"
547
- theme="bold-dark"
548
- onComplete={handleSuccess}
549
- />
202
+ 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.
550
203
 
551
- <FloPayAutomaticPaymentButton
552
- sessionId="sess_abc123"
553
- theme="bold-dark" // applied to the button AND the fallback modal
554
- onSuccess={handleSuccess}
555
- />
556
- ```
204
+ ### Composing your own checkout
557
205
 
558
- `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:
206
+ `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.
559
207
 
560
- ```tsx
561
- <FloPayCheckout
562
- theme="modern-light"
563
- buttonsStyles={{ submitButton: { backgroundColor: '#FF0099' } }} // wins
564
- />
565
- ```
208
+ - 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.
209
+ - **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`, or `error`. In `layout="buttons"`, the recovery request for the form waits until the buyer selects Card, and concurrent renders share one request.
210
+ - **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.
211
+ - `useCheckout()` exposes `claimPending`, which stays true while a detached session shell is not yet safe to charge.
566
212
 
567
- #### Buttons Layout
213
+ ## Examples
568
214
 
569
- Switch from the default form layout to stacked payment buttons with an expandable card form:
215
+ A complete checkout page for an existing session:
570
216
 
571
217
  ```tsx
572
- <FloPayCheckout
573
- sessionId="sess_abc123"
574
- layout="buttons"
575
- theme="bold-dark"
576
- onButtonClick={(method) => console.log('clicked:', method)}
577
- onComplete={handleSuccess}
578
- />
579
- ```
580
-
581
- `layout` can be changed at runtime. Switching a mounted `FloPayCheckout` from
582
- the default layout to `"buttons"` keeps the resolved checkout session and its
583
- gateway capabilities, so the **Credit / Debit Card** choice remains available
584
- without recreating the session.
585
-
586
- > **Deprecated**: the legacy `buttonsTheme` prop (`'default'` / `'minimal'` / `'rounded'` / `'dark'`) still works but new code should use `theme` so the same value drives both the buttons-layout wrapper and the auto-payment fallback. See [ButtonsLayoutStyles reference](https://flopay.com/docs/api-reference/react/flopay-checkout#buttonslayoutstyles-reference) for the underlying override fields.
587
-
588
- #### Button Hooks
589
-
590
- Use `onButtonClick` to track button interactions, `onDecline` to track declines/cancellations, and `onBeforeButtonClick` to enrich any buttons-layout payment method before it continues:
218
+ import { FloPayCheckout } from '@flopay/react';
591
219
 
592
- ```tsx
593
- <FloPayCheckout
594
- layout="buttons"
595
- createSession={{
596
- clientId: 'your-client-id',
597
- currency: 'EUR',
598
- items: [{ providerItemId: 'product-1', providerItemName: 'Widget', totalAmount: 29.99 }],
599
- account: { userId: 'user_1', email: 'test@email.com' },
600
- successUrl: '/success',
601
- cancelUrl: '/cancel',
602
- }}
603
- onBeforeButtonClick={async ({ method, createSession }) => {
604
- if (method !== 'card') {
605
- return {
606
- tagsData: { sessionId: `checkout_${method}_clicked` },
607
- };
608
- }
609
-
610
- const email = await openEmailCaptureModal({
611
- initialEmail: createSession?.account.email ?? '',
612
- });
613
- if (!email) return false;
614
-
615
- return {
616
- account: { email },
617
- tagsData: { sessionId: 'checkout_email_captured' },
618
- };
619
- }}
620
- onButtonClick={(method) => {
621
- window.dataLayer?.push({ event: 'checkout_button_click', payment_method: method });
622
- }}
623
- onDecline={(decline) => {
624
- window.dataLayer?.push({ event: 'checkout_decline', ...decline });
625
- }}
626
- onComplete={handleSuccess}
627
- />
220
+ export function CheckoutPage({ sessionId, nonce }: { sessionId: string; nonce: string }) {
221
+ return (
222
+ <FloPayCheckout
223
+ sessionId={sessionId}
224
+ nonce={nonce}
225
+ theme="modern-light"
226
+ submitLabel="Pay $10"
227
+ onComplete={(result) => {
228
+ if (result.status === 'authorized') {
229
+ console.log('Funds held for payment', result.paymentId, 'until', result.authorizationExpiresAt);
230
+ }
231
+ window.location.assign('/success');
232
+ }}
233
+ onDecline={(decline) => console.warn(`${decline.method} declined: ${decline.message}`)}
234
+ onError={(error) => {
235
+ if (error.code === 'checkout_session_expired') {
236
+ window.location.assign('/checkout/expired');
237
+ return;
238
+ }
239
+ console.error(error.message);
240
+ }}
241
+ onSessionCompleted={(successUrl) => window.location.assign(successUrl)}
242
+ />
243
+ );
244
+ }
628
245
  ```
629
246
 
630
- `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.
631
-
632
- #### Inline Session Creation
633
-
634
- Skip the backend API route — create the session directly in the component:
247
+ Create the session inline, with the buttons layout, a just-in-time email step for cards, and analytics:
635
248
 
636
249
  ```tsx
637
- <FloPayCheckout
638
- layout="buttons"
639
- createSession={{
640
- clientId: 'your-client-id',
641
- currency: 'EUR',
642
- items: [{ providerItemId: 'product-1', providerItemName: 'Widget', totalAmount: 29.99 }],
643
- account: { userId: 'user_1' }, // email and name are collected in checkout
644
- successUrl: '/success',
645
- cancelUrl: '/cancel',
646
- checkoutMetadata: { merchantOrderId: 'order-123', campaign: 'summer' },
647
- }}
648
- onComplete={(result) => window.location.href = '/success'}
649
- />
650
- ```
250
+ import { type FloInstrumentEvent, FloPayCheckout } from '@flopay/react';
651
251
 
652
- The component creates a lightweight shell, attaches the catalog immediately,
653
- renders every otherwise-eligible method, and identifies the buyer later from
654
- the selected method or its fallback — zero merchant backend code needed.
655
-
656
- `checkoutMetadata?: Record<string, string> | null` is sent unchanged on that
657
- initial shell request and never repeated in the catalog or late-buyer claim.
658
- Omission keeps the previous request shape; explicit `null` and `{}` remain
659
- distinct wire values. Returned metadata is available on the normalized result
660
- and its `data.session`. Backend validation errors retain their structured
661
- `FloPayError` code, message, and HTTP status; React does not duplicate backend
662
- size or length limits.
252
+ function trackInstrument(event: FloInstrumentEvent) {
253
+ console.log('checkout instrument', event.name, event);
254
+ }
663
255
 
664
- #### Authorisation-only checkout
256
+ export function InlineCheckout() {
257
+ return (
258
+ <FloPayCheckout
259
+ layout="buttons"
260
+ theme="bold-dark"
261
+ createSession={{
262
+ clientId: 'your-client-id',
263
+ currency: 'EUR',
264
+ items: [{ providerItemId: 'product-1', providerItemName: 'Widget', totalAmount: 29.99 }],
265
+ account: { userId: 'user_1' },
266
+ successUrl: '/success',
267
+ cancelUrl: '/cancel',
268
+ checkoutMetadata: { merchantOrderId: 'order-123' },
269
+ idempotencyKey: 'checkout:order-123',
270
+ }}
271
+ onBeforeButtonClick={async ({ method, createSession }) => {
272
+ if (method !== 'card') {
273
+ return { checkoutMetadata: { merchantOrderId: 'order-123', selectedMethod: method } };
274
+ }
275
+ const email = window.prompt('Email for your receipt', createSession?.account.email ?? '');
276
+ return email ? { account: { email } } : false;
277
+ }}
278
+ onButtonClick={(method) => console.log('clicked', method)}
279
+ onInstrument={trackInstrument}
280
+ onComplete={() => window.location.assign('/success')}
281
+ />
282
+ );
283
+ }
284
+ ```
665
285
 
666
- Set `captureMethod: 'manual'` in the same `createSession` draft for an eligible
667
- item-only card checkout:
286
+ Authorise a one-time card payment now and capture it later from your server:
668
287
 
669
288
  ```tsx
670
- <FloPayCheckout
671
- createSession={{
672
- clientId: 'your-client-id',
673
- captureMethod: 'manual',
674
- currency: 'EUR',
675
- items: [{ code: 'order_123', totalAmount: 29.99 }],
676
- account: { userId: 'user_1', email: 'user@example.com' },
677
- successUrl: '/success',
678
- cancelUrl: '/cancel',
679
- }}
680
- onComplete={(result) => {
681
- if (result.status === 'authorized') {
682
- saveAuthorisation({
683
- paymentId: result.paymentId,
684
- sessionId: result.sessionId,
685
- expiresAt: result.authorizationExpiresAt,
686
- });
687
- }
688
- }}
689
- onSessionCompleted={(successUrl) => router.push(successUrl)}
690
- />
289
+ import { FloPayCheckout } from '@flopay/react';
290
+
291
+ export function AuthorisationCheckout() {
292
+ return (
293
+ <FloPayCheckout
294
+ createSession={{
295
+ clientId: 'your-client-id',
296
+ captureMethod: 'manual',
297
+ currency: 'EUR',
298
+ items: [{ providerItemId: 'order_123', totalAmount: 29.99 }],
299
+ account: { userId: 'user_1', email: 'user@example.com' },
300
+ successUrl: '/success',
301
+ cancelUrl: '/cancel',
302
+ }}
303
+ onComplete={(result) => {
304
+ if (result.status === 'authorized') {
305
+ void fetch('/api/authorisations', {
306
+ method: 'POST',
307
+ headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({
309
+ paymentId: result.paymentId,
310
+ sessionId: result.sessionId,
311
+ expiresAt: result.authorizationExpiresAt,
312
+ }),
313
+ });
314
+ }
315
+ }}
316
+ onSessionCompleted={(successUrl) => window.location.assign(successUrl)}
317
+ />
318
+ );
319
+ }
691
320
  ```
692
321
 
693
- The component still releases the buyer to the success experience, but its
694
- overlay says `PAYMENT AUTHORISED`. `authorized` means funds are held, not paid.
695
- Capture remains a merchant-authenticated REST operation and is never available
696
- to browser code. Manual capture is rejected for subscription carts. See the
697
- repository's [pre-authorisation guide](../../docs/PREAUTHORIZATION.md).
698
-
699
- That create POST sends a stable `Idempotency-Key` header automatically whenever a
700
- secure RNG is available. `FloPayCheckout` resolves the key once per logical
701
- checkout, stores it with the inline-session cache in `sessionStorage`, and reuses
702
- it across transport retries, effect reruns, and component remounts. Concurrent
703
- mounts (StrictMode double-mount, Suspense remount, or navigation flicker) share
704
- both the session create and the auto-mode payment attempt, so one purchase cannot
705
- fan out into multiple creates or charges.
706
-
707
- Logical checkout identity is based on the merchant, buyer, cart/pricing,
708
- coupons, and checkout metadata. Metadata keys are canonicalized for identity,
709
- so key-order-only changes reuse the current session while a material metadata
710
- change creates a new one. Raw metadata is not stored in the inline-session
711
- cache. Checkout-mode changes, refreshed payment tokens, and analytics/UTM prop
712
- churn do not rotate the key while that purchase is active. A different cart or
713
- buyer gets a different key; an email-less buyer is separated by `account.userId`.
714
-
715
- To control the key yourself — for example to keep it stable across your own
716
- server retries — pass `idempotencyKey` on `createSession`:
322
+ Add a card without charging it:
717
323
 
718
324
  ```tsx
719
- <FloPayCheckout
720
- createSession={{
721
- /* … */
722
- idempotencyKey: `checkout:${orderId}`, // one logical checkout; never reuse for a new purchase
723
- }}
724
- />
325
+ import { FloPayCardSetup } from '@flopay/react/card-setup';
326
+
327
+ export function AddCard({ sessionId, nonce }: { sessionId: string; nonce: string }) {
328
+ return (
329
+ <FloPayCardSetup
330
+ sessionId={sessionId}
331
+ nonce={nonce}
332
+ theme="glass-dark"
333
+ containerStyle={{ borderColor: '#60A5FA', padding: 16 }}
334
+ onComplete={({ paymentMethod }) => {
335
+ // Display-only when present; re-list cards from your server when it is absent.
336
+ console.log(paymentMethod?.brand, paymentMethod?.lastFour);
337
+ }}
338
+ onDecline={({ message }) => console.warn(message)}
339
+ onValidation={(message) => console.log(message)}
340
+ onError={(error) => console.error(error.code, error.retryable ? 'retry' : 'do not retry')}
341
+ onCancel={() => console.log('Card setup closed before it finished')}
342
+ />
343
+ );
344
+ }
725
345
  ```
726
346
 
727
- The key must be non-empty and at most 255 characters; an invalid value throws a
728
- `FloPayError('validation_error')` before any request. Omit it and the SDK
729
- generates a fresh cryptographically random key per logical create when a secure
730
- RNG is available; without one it omits the header, and a merchant-supplied
731
- `idempotencyKey` stays the way to remain idempotent. See the
732
- [`@flopay/js` idempotency docs](../js/README.md#idempotent-checkout-creation)
733
- for the full contract.
734
-
735
- 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`.
736
-
737
- When you use `onBeforeButtonClick` with `createSession`, any returned
738
- `InlineSessionPatch` is merged into the draft session params before the selected
739
- buttons-layout flow continues. That lets you add tracking data or update account
740
- fields just in time without pre-creating a separate backend session. A returned
741
- `checkoutMetadata` map replaces the prior map as a whole; return `null` to clear
742
- it before creation.
743
-
744
- ### Detached session creation (default)
745
-
746
- `createSession` checkouts are created in two phases (TeamFloPay/backend#1099):
747
- a lightweight session *shell* that skips catalog validation and the
748
- buyer-identity advisory locks, then a background **claim** that attaches the
749
- catalog. If the draft already contains an email, that claim also attaches the
750
- buyer. Otherwise it deliberately omits `accountData` and returns a complete,
751
- still-anonymous session (`buyerIdentified: false`).
752
-
753
- The optional `createSession.timeoutMs` value is applied independently to both
754
- phases (12 seconds each by default). The shell POST stops its deadline as soon
755
- as the shell is usable, and the initial catalogue claim PATCH starts with a
756
- fresh signal and the full budget. Opting out of detached creation keeps the
757
- one-shot request's single total deadline.
758
-
759
- `FloPayCheckout` renders from the shell, so the hosted card form mounts and
760
- becomes interactive without waiting for either the claim or Stripe.js — Stripe
761
- wallets, APMs and PayPal load in parallel with the catalog claim and appear
762
- from its full `enabledPaymentMethods` list when it lands. The card widget's
763
- submit button stays gated for that window, because the
764
- billing API rejects a charge against an unclaimed session; if a buyer clicks
765
- during it, the widget cancels the click and the form asks them to press pay
766
- again. In practice the claim resolves while the card is still being filled in.
767
-
768
- Nothing else changes for you: the same session, the same callbacks, the same
769
- results. Catalog and coupon errors still surface as a checkout load error, just
770
- from the claim rather than the create. Missing buyer identity never delays this
771
- catalog claim: provider-capable methods identify the buyer during payment, and
772
- `claim(account)` performs a separate buyer-only attachment when manual
773
- collection is needed.
774
-
775
- That buyer-only attachment is not specific to detached creation. Any
776
- still-anonymous session the checkout renders — a one-shot create without an
777
- email, or an existing session passed as `sessionId` + `nonce` that the billing
778
- API returns with `buyerIdentified: false` — gets the same late-buyer claim
779
- rebuilt from the session's nonce, so the email the SDK collects (or a wallet /
780
- PayPal approval supplies) reaches `PATCH /v1/checkouts/sessions/{id}/claim`
781
- before payment. The hosted vault widget's charge is refused server-side for a
782
- session with no bound buyer, so without that claim an email-less session loaded
783
- by id would never complete.
784
-
785
- Opt out per session to restore the original single-request create:
347
+ Charge a saved payment method with one button:
786
348
 
787
349
  ```tsx
788
- <FloPayCheckout
789
- createSession={{
790
- /* … */
791
- deferDataAttachment: false, // one-shot create
792
- }}
793
- />
794
- ```
795
-
796
- Detached creation applies only to `checkoutMode: 'full'` (the billing API
797
- rejects it for `auto` / `confirm`) and is skipped when `tokenizedData` is
798
- supplied. There is **no fallback** to the one-shot create — it requires a
799
- billing API `v1.7.12` or newer that exposes the independent catalog/buyer
800
- `PATCH /v1/checkouts/sessions/{id}/claim` contract.
350
+ import { FloPayAutomaticPaymentButton } from '@flopay/react';
801
351
 
802
- ### Advanced: Manual Provider Setup
352
+ export function UpsellButton() {
353
+ return (
354
+ <FloPayAutomaticPaymentButton
355
+ clientId="your-client-id"
356
+ account={{ userId: 'user_1', email: 'user@example.com' }}
357
+ items={[
358
+ {
359
+ providerItemId: 'upsell_1',
360
+ providerItemName: 'AI Supercharger Pack',
361
+ totalAmount: 249,
362
+ overrideAmount: 80,
363
+ currency: 'EUR',
364
+ quantity: 1,
365
+ },
366
+ ]}
367
+ successUrl="https://shop.example.com/success"
368
+ cancelUrl="https://shop.example.com/cancel"
369
+ theme="bold-dark"
370
+ onClick={() => console.log('automatic payment button clicked')}
371
+ onSuccess={({ sessionId, autoCompleted }) => console.log('paid', sessionId, autoCompleted)}
372
+ onDecline={(decline) => console.warn('declined', decline.message)}
373
+ onError={(error) => console.error(error.message)}
374
+ >
375
+ Purchase Item
376
+ </FloPayAutomaticPaymentButton>
377
+ );
378
+ }
379
+ ```
803
380
 
804
- Use `SplitCardForm` when you need to compose the provider and session yourself.
805
- Card checkout uses the backend-hosted vault widget; Stripe remains limited to
806
- wallets/APMs and saved-payment authentication. The session can supply the
807
- widget eagerly in `session.vault`, or explicitly advertise `card` in
808
- `gateways.stripe.enabledPaymentMethods` so the SDK recovers it on demand.
381
+ Compose the provider, a checkout surface, and the hooks yourself:
809
382
 
810
383
  ```tsx
811
- import { FloPayProvider, SplitCardForm } from '@flopay/react';
812
384
  import { loadFloPay } from '@flopay/js';
385
+ import type { CheckoutSession } from '@flopay/shared';
386
+ import { FloPayProvider, SplitCardForm, useCheckout, useFloPay } from '@flopay/react';
813
387
 
814
388
  const floPayPromise = loadFloPay('pk_test_...');
815
389
 
816
- function CheckoutPage() {
390
+ function PaymentStatus() {
391
+ const flopay = useFloPay();
392
+ const { loading, error, claimPending } = useCheckout();
393
+ if (!flopay || loading) return <p>Loading payment methods…</p>;
394
+ if (error) return <p role="alert">{error.message}</p>;
395
+ return claimPending ? <p>Preparing your order…</p> : null;
396
+ }
397
+
398
+ export function ComposedCheckout({ session, nonce }: { session: CheckoutSession; nonce: string }) {
817
399
  return (
818
- <FloPayProvider
819
- flopay={floPayPromise}
820
- options={{
821
- amount: 2999,
822
- currency: 'usd',
823
- }}
824
- >
400
+ <FloPayProvider flopay={floPayPromise} options={{ billingApiUrl: 'https://billing.example.com' }}>
401
+ <PaymentStatus />
825
402
  <SplitCardForm
826
- sessionId="session_uuid"
827
- nonce={sessionNonce}
403
+ sessionId={session.id}
404
+ nonce={nonce}
828
405
  session={session}
829
- billingApiUrl="https://billing.example.com"
830
406
  email="user@example.com"
831
407
  userId="user_1"
832
- totalAmount={2999} // cents
408
+ totalAmount={2999}
833
409
  currency="usd"
410
+ enableAVS
834
411
  enabledPaymentMethods={session.gateways?.stripe?.enabledPaymentMethods}
835
412
  onComplete={(result) => {
836
- if (result.status === 'succeeded') {
837
- window.location.href = '/success';
838
- }
413
+ if (result.status === 'succeeded') window.location.assign('/success');
839
414
  }}
840
- onError={(err) => console.error(err)}
841
- onFirstNameChange={(name) => setFirstName(name)}
842
- onLastNameChange={(name) => setLastName(name)}
415
+ onError={(error) => console.error(error.message)}
843
416
  />
844
417
  </FloPayProvider>
845
418
  );
846
419
  }
847
420
  ```
848
421
 
849
- The resulting surfaces are:
850
-
851
- 1. Wallet buttons and supported APM tiles from the session gateway capability.
852
- 2. Direct or Stripe-hosted PayPal, selected from the session gateways.
853
- 3. The hosted vault widget for sessions with embedded vault HTML or an explicit
854
- Stripe `card` capability.
855
-
856
- Wallets, APMs, and PayPal create intents through
857
- `POST /v1/checkouts/sessions/{id}/intents`. The discriminated request separates
858
- provider, payment-method category/type/id, and intent kind. Direct-card intent
859
- requests are unsupported. Client-observed non-card failures use the
860
- nonce-protected session decline endpoint and contain no payment tokens,
861
- provider object IDs, card data, credentials, or PII.
862
-
863
- For SDK 1.4.9+, an explicit Stripe `card` capability keeps card checkout
864
- visible even when the backend omits the vault block. In `layout="buttons"`,
865
- `POST /v1/checkouts/sessions/{id}/vault/capture` is deferred until the buyer
866
- selects Card. An embedded block remains the preferred fast path and causes no
867
- recovery POST. Sessions that advertise neither an embedded block nor `card`
868
- do not call the recovery endpoint; their non-card methods remain usable, and
869
- if none remain, `onError` receives `UnsupportedBackendVaultCapability`.
870
-
871
- #### AVS postcode validation
872
-
873
- When AVS collection is enabled (`enableAVS`) and the postcode field is shown,
874
- `SplitCardForm` validates the postcode **format against the live selected
875
- country before the hosted vault captures the card**.
876
- It reuses `@flopay/shared`'s country-aware
877
- [postcode helpers](../shared/README.md#postal-code-helpers) (the same
878
- `validator` rules the billing API applies), so client and server agree.
879
-
880
- - **Supported country + malformed postcode** → the vault widget's submit is blocked
881
- and an inline, country-specific message shows the expected format
882
- (e.g. *"Enter a valid ZIP Code (e.g. 12345 or 12345-6789)"*). On the vault
883
- path the inline message appears once the field is blurred, since the disabled
884
- submit means it can't be reached by a submit attempt.
885
- - **Supported country + empty postcode** → the same submit gate applies, and
886
- once the field is blurred (or a submit is attempted) an inline *required*
887
- message shows (e.g. *"ZIP Code is required"*) rather than the expected-format
888
- copy.
889
- - **Country with no postal system** (or a locale `validator` doesn't recognise)
890
- → the postcode field is shown but **optional**: empty and format checks are
891
- both skipped, so these buyers are never blocked.
892
-
893
- The collected email field and the AVS street, city and state fields follow the
894
- same pattern: while empty (or, for email, malformed) they hold the submit gate
895
- shut, and once the buyer leaves the field (or a submit is attempted) they show
896
- the same red outline and inline `role="alert"` message (e.g. *"Street address is
897
- required"*, *"Enter a valid email address"*).
898
-
899
- On the **vault card path** the widget's submit button is gated **synchronously**
900
- on the client-side AVS check above. Because the client postcode rules mirror the
901
- backend validator (`validator@13.15.35`) verbatim, a postcode the server's
902
- `PATCH /v1/checkouts/sessions/{id}/account` would reject is already blocked here —
903
- before the button ever enables — with no network round-trip in the critical path.
904
- The gate is **never** held pending an async request: the hosted widget silently
905
- swallows a click that lands while the button is disabled and offers no way to
906
- auto-resubmit on release, so an async gate would strand any buyer (or single-click
907
- preview) who clicked before it resolved — the checkout would write no attempt and
908
- time out (sdk#124).
909
-
910
- When the buyer submits, `SplitCardForm` persists the billing snapshot
911
- best-effort **in parallel** with the widget's charge:
912
-
913
- - **Backend rejects the address (a `4xx`)** — a residual, non-postcode drift, since
914
- the postcode is already client-validated — the specific message (e.g. *"Postal/ZIP
915
- code is not valid for the selected country…"*) shows inline **and** fires `onError`
916
- with the parsed `FloPayError`, and the processing overlay clears so the buyer can
917
- correct and retry. The charge cannot be cancelled once submitted, so this surfaces
918
- for the buyer's *next* attempt rather than stopping the in-flight one.
919
- - **Transient failure (`5xx` / network)** → best-effort and buyer-silent: the
920
- backend listener falls back to the session's baseline address while the
921
- widget completes the charge.
922
- - **Internal 10-second timeout** → the same functional fallback applies, and
923
- successful widget completion still reaches `onComplete`. Telemetry records
924
- the privacy-safe lifecycle event `operation.fallback` with
925
- `processing` / `account_snapshot` / `timeout` dimensions instead of an
926
- alert-level technical error. Direct and blocking timeout paths remain errors.
927
-
928
- ### Vault PCI card form
929
-
930
- The card path renders a **backend-served, self-contained hosted vault widget**
931
- (`VaultCardFields`) (TeamFloPay/backend#823, Model A). The billing API may
932
- return it eagerly in `session.vault`, or advertise `card` under
933
- `gateways.stripe.enabledPaymentMethods` and let SDK 1.4.9+ request it lazily.
934
- It is fully **server-driven** — there is no consumer prop to toggle it. The
935
- widget owns the card fields, its own submit button, card tokenization, the
936
- PaymentIntent (created **and** confirmed server-side), **3DS**, and the result,
937
- so **no Stripe.js runs on the card path** and PAN / CVC never enter the SDK
938
- runtime. Wallets / PayPal / APMs render exactly as before.
939
-
940
- Because the widget owns the form, `SplitCardForm` renders no SDK card-entry
941
- fields or submit button of its own; `submitLabel` only sets the wording of the
942
- widget's button (see [Hosted card submit label](#hosted-card-submit-label)).
943
- Host-collected AVS fields remain outside the PCI
944
- widget and gate its submit. The flow is:
945
-
946
- ```text
947
- session embeds session.vault or advertises gateways.stripe.enabledPaymentMethods: ['card']
948
- → SDK uses embedded HTML, or requests POST /vault/capture when Card is needed
949
- → concurrent renders/remounts share that active request
950
- → buyer pays inside the widget (tokenize → charge → 3DS, all backend-owned)
951
- → widget postMessages its outcome → SDK fires onComplete / onDecline / onError
952
- ```
953
-
954
- - Embedded `session.vault.html` is the preferred fast path during rollout and
955
- never triggers a recovery request.
956
- - Deferred capture uses
957
- `POST /v1/checkouts/sessions/{id}/vault/capture`, with a ten-second timeout,
958
- bounded transient-network retry, and one shared active request per
959
- base URL/session/nonce.
960
- - **Returning customers** with a card on file (`session.providerPaymentMethodId`)
961
- are charged by the backend's auto-checkout cascade.
962
- - **3DS** is handled inside the widget — there is no client-side `confirmCardPayment`.
963
-
964
- **Processing feedback:** after the hosted widget emits `submitting`, the SDK's
965
- full-checkout processing overlay remains visible continuously until the widget
966
- reports `complete`, `decline`, or `error`. Intermediate status polling and
967
- late card-field validation mutations cannot expose the completed form while a
968
- charge is still in flight. A synchronous card-validation rejection still
969
- returns the buyer to the form immediately, as does a `4xx` account-snapshot
970
- validation failure.
971
-
972
- **Failure modes:** `onDecline` / `onError` receive the mapped reason + message
973
- so consumers can react, and the SDK leaves an inline retry message after its
974
- terminal failure overlay clears.
975
-
976
- > **Backend dependencies:** the hosted widget must emit the `flopay-vault`
977
- > `postMessage` outcome the SDK listens for (otherwise it falls back to its own
978
- > success redirect), handle the 3DS step internally, and use the session account
979
- > snapshot for AVS. The SDK collects and validates those address fields outside
980
- > the PCI widget and persists them through the session account endpoint; the
981
- > widget owns only sensitive card-data capture. Because the widget is injected
982
- > **same-window**, every terminal outcome must be bound to the session id, and —
983
- > to fully defend against same-window forgery — the backend should mint a
984
- > per-session `messageToken` on the vault block and echo it in each
985
- > `postMessage`; the SDK threads it through and rejects outcomes that omit or
986
- > mismatch it (TeamFloPay/backend#823).
987
-
988
- ### PayPal Handling
989
-
990
- The SDK supports two PayPal paths, selected per-session based on what the billing API advertises under `gateways.*`:
991
-
992
- - **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 and `environment` (`'sandbox'`/`'live'`) come directly from the backend.
993
- - **Stripe-rendered PayPal**: when only `gateways.stripe` is configured, PayPal renders through Stripe's `ExpressCheckoutElement` in its own Elements wrapper. This path can't render in Facebook/Instagram in-app browsers.
994
-
995
- 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.
996
-
997
- **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.
998
-
999
- **Provider-first buyer identity:** Direct PayPal never hides its Smart Button
1000
- behind a Flo buyer-details preflight. An anonymous buyer starts PayPal normally;
1001
- after approval the SDK reads the payer email/name, attaches valid returned
1002
- details, and continues processing. If PayPal returns incomplete identity, the
1003
- SDK shows exactly one required fallback pane after approval and resumes with
1004
- the collected values. A checkout that supplied buyer details up front keeps its
1005
- existing request bodies and callbacks.
1006
-
1007
- **In-app presentation and recovery:** Direct PayPal classifies the browser once
1008
- per mount into a bounded app family; it never records the raw user agent.
1009
- `approvalPresentation` selects the bounded `app_switch` mode only for a
1010
- one-time Order in an in-app browser. When the backend advertises
1011
- `gateways.paypal.approvalPresentation: 'app_switch'`, that mode enables PayPal's
1012
- `appSwitchWhenAvailable` Buttons option.
1013
- Ordinary browsers, legacy sessions, subscriptions, and setup tokens preserve
1014
- the popup flow. The PayPal button stays visible in every environment as a
1015
- result of this choice. If an in-app popup is blocked and no supported non-popup
1016
- mode is available, `onTechnicalFailure` receives `popupBlocked: true` with
1017
- fixed guidance to open the page in the system browser; ordinary browsers keep
1018
- the existing allow-popups guidance, and PayPal remains visible for retry.
1019
-
1020
- `approvalPresentation` is a bounded mode, not a URL. Merchant code cannot
1021
- inject a return URL or resume token. Although the shared response type reserves
1022
- `'redirect'`, the classic Buttons adapter does not activate it: deterministic
1023
- redirect remains gated on the backend-issued signed resume contract and a
1024
- proven PayPal adapter that captures before merchant-success navigation. Until
1025
- both land, a redirect advertisement safely preserves popup behavior. This
1026
- instrumentation explains where approval attempts stop; it does not by itself
1027
- attribute the historical conversion drop to a particular browser or provider
1028
- failure.
1029
-
1030
- **Flo-owned subscription continuations:** `gateways.paypal.providerObjectType`
1031
- selects the direct PayPal operation. `'order'` loads the PayPal SDK with
1032
- `intent=capture` and invokes `createOrder`, even when the checkout session mode
1033
- is subscription. `'subscription'` preserves the provider-managed
1034
- `intent=subscription` / `createSubscription` flow. `'setup_token'` invokes
1035
- `createVaultSetupToken`; approval submits the returned `vaultSetupToken` to the
1036
- nonce-bound session `/process` route for backend exchange and activation. An
1037
- advertised `'order'`/`'setup_token'` opts the intent request and tokenized
1038
- process body into the `paypal_vaulted` payment-method channel; the intent
1039
- response must match the advertised object type. When the field is absent,
1040
- legacy sessions continue to derive Order versus Subscription from
1041
- `isSubscription` and keep the released `paypal` requests byte-identical. See
1042
- the full [channel-selection and cutover contract](../../docs/PAYPAL_FLO_SUBSCRIPTION_CONTINUATIONS.md).
1043
-
1044
- **Direct PayPal initialization recovery:** Any active-generation technical
1045
- failure before PayPal becomes ready — script rejection, a namespace without a
1046
- usable `Buttons` factory, an eligible pre-ready `onError`, a `render()`
1047
- rejection, or the SDK's 11-second render watchdog — hides Direct PayPal, waits
1048
- one second, and makes exactly one background remount. Recovery is based on
1049
- lifecycle state, not provider error text. The retry uses a fresh render
1050
- generation, and callbacks or promise settlements from the superseded generation
1051
- are ignored. Startup remount does not create a checkout session, intent, or
1052
- charge; the first recovered buyer click creates at most one intent under the
1053
- existing per-click idempotency contract.
1054
-
1055
- In a mixed checkout, card, wallets, APMs, and any other eligible methods remain
1056
- interactive throughout recovery. If Direct PayPal is configured alongside
1057
- Stripe-rendered PayPal, the Stripe-rendered path activates while Direct PayPal
1058
- is retrying or exhausted. If both Direct PayPal attempts fail, PayPal stays
1059
- hidden for that component configuration; the SDK emits one sanitized console
1060
- diagnostic and calls neither `onError` nor `onDecline`. Empty configuration,
1061
- `isEligible() === false`, cancellation, supersession, unmount, and recognized
1062
- lifecycle teardown are not automatically retried. Ineligibility still notifies
1063
- the parent that PayPal is unavailable so a fallback can appear, without
1064
- technical telemetry.
1065
-
1066
- In a PayPal-only checkout, `FloPayCheckout` shows an accessible retrying status
1067
- instead of a blank surface. After automatic recovery is exhausted, it shows safe
1068
- generic unavailable copy and a **Retry PayPal** button. `onError` fires once with
1069
- a `FloPayError` whose `type` is `api_error` and whose stable `code` is
1070
- `paypal_init_timeout`; `onDecline` does not fire and the raw provider message is
1071
- never rendered. Manual retry reuses the current checkout session and performs
1072
- one fresh render attempt without adding another automatic retry.
1073
-
1074
- The SDK exposes the relevant pieces in three ways:
1075
-
1076
- - **`SplitCardForm`** / **`FloPayCheckout`**: pick the renderer automatically based on `gateways.*`. No additional configuration needed.
1077
-
1078
- - **`FloPayProvider`** (manual composition): accepts an optional `paypalFlopay` prop used to drive Stripe's PayPal redirect leg. Load a second `FloPay` instance yourself only if you need to render PayPal manually. `usePayPalFloPay()` exposes it to descendants.
1079
-
1080
- - **`DirectPayPalButton`** (standalone): can be rendered outside `SplitCardForm` when you only need the PayPal button. Pass `clientId`, `currency`, `environment`, `isSubscription`, and the backend-advertised `providerObjectType` and `approvalPresentation` when present. The backing session is authoritative; `isSubscription` remains the compatibility fallback when the object type is absent.
1081
-
1082
- ### Using Hooks
1083
-
1084
- ```tsx
1085
- import { useFloPay, useCheckout } from '@flopay/react';
1086
-
1087
- function PaymentStatus() {
1088
- const flopay = useFloPay(); // FloPay | null
1089
- const checkout = useCheckout(); // { session, loading, error, claimPending }
1090
-
1091
- if (!flopay) return <div>Loading SDK...</div>;
1092
- // ...
1093
- }
1094
- ```
1095
-
1096
- ## API Reference
422
+ ## Public API
1097
423
 
1098
424
  ### Components
1099
425
 
1100
426
  | Component | Description |
1101
427
  |-----------|-------------|
1102
- | `FloPayProvider` | Context provider. Accepts `flopay` (instance or promise), optional `paypalFlopay`, `options?`, and `children`. |
1103
- | `FloPayCheckout` | Recommended self-contained session checkout. Resolves gateways, mounts hosted-vault cards, and preserves wallets/APMs/PayPal/saved-payment flows. |
1104
- | `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. |
1105
- | `SplitCardForm` | Advanced checkout surface combining hosted-vault cards with wallets, APMs, and PayPal. Supports `ref` for imperative next-action handling. |
1106
- | `VaultCardFields` | Hosted vault PCI card fields. Used internally by `SplitCardForm` on the vault path; consumes a `CardCaptureAdapter` from `useFloPay().cardCapture()`. |
1107
- | `DirectPayPalButton` | Standalone direct PayPal Order, provider-managed Subscription, or setup-token button using the session-scoped intent contract. |
428
+ | `FloPayCheckout` | Recommended self-contained checkout. Resolves gateways, mounts the hosted card form, and renders wallets, alternative methods, PayPal, and saved-payment flows. |
429
+ | `FloPayCardSetup` | No-charge card verification for a merchant-server-created setup session. Also exported from `@flopay/react/card-setup`. |
430
+ | `FloPayAutomaticPaymentButton` | One-click button that charges the customer's saved payment method, falling back to `FloPayCheckout` |
431
+ | `FloPayProvider` | Context provider for composed checkouts |
432
+ | `SplitCardForm` | Checkout surface combining the hosted card form with wallets, alternative methods, and PayPal |
433
+ | `VaultCardFields` | The hosted PCI card form. `SplitCardForm` uses it internally; it consumes a `CardCaptureAdapter` from `useFloPay().cardCapture()`. |
434
+ | `DirectPayPalButton` | Standalone direct PayPal Order, PayPal-managed subscription, or setup-token button using the session intent contract |
1108
435
 
1109
436
  ### Hooks
1110
437
 
1111
438
  | Hook | Returns | Description |
1112
439
  |------|---------|-------------|
1113
- | `useFloPay()` | `FloPay \| null` | Current FloPay instance from context. `null` while loading. |
1114
- | `useCheckout()` | `CheckoutState` | `{ session, loading, error, claimPending }` from CheckoutContext. `claimPending` remains true while a detached shell is not safe to charge. |
440
+ | `useFloPay()` | `FloPay \| null` | The current FloPay instance, or `null` while it loads |
441
+ | `useCheckout()` | `CheckoutState` | `{ session, loading, error, claimPending }` for the surrounding checkout |
442
+ | `usePayPalFloPay()` | `FloPay \| null` | The optional FloPay instance used for Stripe-rendered PayPal |
1115
443
 
1116
- ### FloPayProviderProps
444
+ ### Other exports
1117
445
 
1118
- | Prop | Type | Description |
1119
- |------|------|-------------|
1120
- | `flopay` | `Promise<FloPay> \| FloPay` | SDK instance or promise from `loadFloPay()` |
1121
- | `paypalFlopay` | `Promise<FloPay> \| FloPay \| null` | Optional Stripe instance used for the Stripe-rendered PayPal redirect leg. |
1122
- | `options.billingApiUrl` | `string?` | Billing API base URL exposed to child components. |
1123
- | `onInstrument` | `(event: FloInstrumentEvent) => void` | Versioned, privacy-safe checkout funnel and phased error feed for merchant analytics. |
446
+ | Export | Description |
447
+ |--------|-------------|
448
+ | `FloPayCardSetupError` | Card-setup error class: a `FloPayError` with an explicit `retryable` flag |
449
+ | `dropThirdPartyOnlyError(event)` | Sentry `beforeSend` predicate re-exported from `@flopay/shared` |
1124
450
 
1125
- ### FloPayCardSetupProps
451
+ ### FloPayCheckoutProps
1126
452
 
1127
453
  | Prop | Type | Description |
1128
454
  |------|------|-------------|
1129
- | `sessionId` | `string` | Opaque id returned by the merchant-authenticated setup-session endpoint. |
1130
- | `nonce` | `string` | Required session-bound read/capture token. |
1131
- | `billingApiUrl` | `string?` | Billing API base URL; defaults through normal FloPay environment resolution. |
1132
- | `telemetry` | `boolean?` | Privacy-safe operational telemetry; set `false` to opt out. |
1133
- | `theme` | `ThemeId \| VaultCardThemeColors?` | High-level theme id (same values as `FloPayCheckout`, resolved through the same bundle) or raw hosted-widget colors. A preset styles the surrounding surface, the hosted form (complete submit snapshot included), and the outcome overlay coherently; see [Card-setup theming](#card-setup-theming). Defaults to `'modern-light'`; `'classic'` keeps the historic FloPay look and raw objects are forwarded unchanged. |
1134
- | `containerStyle` | `React.CSSProperties?` | Inline styles for the hosted-widget container, merged over a preset's surface with deterministic precedence: each property wins over the preset's, a longhand refines a preset shorthand, any `background*` member replaces the preset background, and `'classic'` / raw themes apply it alone. |
1135
- | `loading` | `React.ReactNode?` | Content shown while the session and hosted widget load. |
1136
- | `onReady` | `() => void` | Hosted widget is mounted and accepting input. |
1137
- | `onComplete` | `(event: FloPayCardSetupCompleteEvent) => void` | Verified usable card. Includes `sessionId` and optional display-only `paymentMethod`; never amount, charge, or payment-success data. |
1138
- | `onDecline` | `(event: FloPayCardSetupDeclineEvent) => void` | Terminal card-verification decline. |
1139
- | `onValidation` | `(message: string \| null) => void` | Live hosted-field validation; not a technical error. |
1140
- | `onError` | `(error: FloPayCardSetupError) => void` | Validation/technical failure with stable `code` and explicit `retryable`. |
1141
- | `onCancel` | `(event: FloPayCardSetupCancelEvent) => void` | Fires once when the component unmounts before a terminal outcome. |
1142
-
1143
- ### Privacy-safe operational telemetry
1144
-
1145
- `FloPayCheckout` emits the same closed Flo-owned lifecycle/error/performance
1146
- contract as `@flopay/js`, including React render/interactivity and provider
1147
- readiness milestones. Collection is best-effort; merchant callbacks remain
1148
- available with their existing arguments and invocation order. Synchronous
1149
- throws and rejected callback promises are contained and logged only to the
1150
- merchant console; they are not uploaded as `CALLBACK_FAILED`. Set the single
1151
- `telemetry={false}` prop to opt out:
455
+ | `sessionId` | `string?` | Existing checkout session id. Required unless `createSession` is provided. |
456
+ | `nonce` | `string?` | Session-bound checkout token for `sessionId` |
457
+ | `createSession` | `InlineSessionDraft?` | Creates the session inline instead of using `sessionId` |
458
+ | `checkoutMode` | `CheckoutMode?` | Overrides the session's checkout mode |
459
+ | `billingApiUrl` | `string?` | Billing API base URL; defaults to the configured environment |
460
+ | `layout` | `'default' \| 'buttons'` | Payment form layout |
461
+ | `theme` | `ThemeId?` | Theme bundle for the whole checkout |
462
+ | `appearance` | `FloPayAppearance?` | Stripe-side appearance overrides |
463
+ | `buttonsStyles` | `ButtonsLayoutStyles?` | Wrapper style overrides layered on the theme |
464
+ | `locale` | `string?` | Locale for payment elements |
465
+ | `submitLabel` | `string \| null` | Live wording for the hosted card submit button |
466
+ | `confirmLabel` | `string?` | Saved-payment button label in confirm mode |
467
+ | `renderConfirmButton` | `function?` | Custom confirm-mode button renderer |
468
+ | `showStripe` | `boolean?` | Show Stripe wallets and alternative methods (default `true`) |
469
+ | `showPayPal` | `boolean?` | Show PayPal (default `true`) |
470
+ | `enabledPaymentMethods` | `string[]?` | Overrides the session's advertised Stripe methods |
471
+ | `enableAVS` | `boolean \| AVSFieldConfig` | Collect and validate the billing address |
472
+ | `avsLayout` | `'row' \| 'column'` | AVS field layout |
473
+ | `cardFieldOrder` | `VaultCardFieldKey[]?` | Order of the hosted card rows |
474
+ | `cardPreFormSlot` | `ReactNode?` | Content rendered above the hosted card form |
475
+ | `cardButtonContent` | `ReactNode?` | Card tile content in `layout="buttons"` |
476
+ | `cardBackButtonContent` | `ReactNode?` | Back-button label in `layout="buttons"` |
477
+ | `cardTitleContent` | `ReactNode?` | Card-form title |
478
+ | `className` | `string?` | Extra wrapper class |
479
+ | `loading` | `ReactNode?` | Custom loading UI |
480
+ | `error` | `(error: FloPayError) => ReactNode` | Custom error UI |
481
+ | `initialErrorMessage` | `string \| null` | Seeds an initial error message |
482
+ | `children` | `ReactNode?` | Custom layout rendered instead of the default `SplitCardForm` |
483
+ | `telemetry` | `boolean?` | Set `false` to opt out of operational telemetry |
484
+ | `onComplete` | `(result: PaymentResult) => void` | Payment succeeded or was authorized |
485
+ | `onDecline` | `(decline: DeclineEvent) => void` | A payment was declined or authentication failed |
486
+ | `onError` | `(error: FloPayError) => void` | A checkout error occurred |
487
+ | `onSessionCompleted` | `(successUrl: string) => void` | The session was already completed |
488
+ | `onButtonClick` | `(method: CheckoutButtonMethod) => void` | A payment method button was clicked |
489
+ | `onBeforeButtonClick` | `(event: BeforeButtonClickEvent) => ...` | Runs before a buttons-layout method continues; return a patch or `false` |
490
+ | `onInstrument` | `(event: FloInstrumentEvent) => void` | Merchant instrument feed |
491
+ | `onCountryChange` / `onZipChange` | `(value: string) => void` | AVS country or postcode changed |
492
+ | `showApplePay` / `showGooglePay` / `buttonsTheme` | deprecated | Superseded by the session's methods and `theme` |
1152
493
 
1153
- ```tsx
1154
- <FloPayCheckout sessionId={sessionId} nonce={nonce} telemetry={false} />
1155
- ```
1156
-
1157
- No endpoint, custom tag, user context, message, stack, or metadata can be
1158
- configured. See [`docs/TELEMETRY.md`](../../docs/TELEMETRY.md) for the complete
1159
- privacy and retention contract.
494
+ ### FloPayProviderProps
1160
495
 
1161
- If the React host has its own Sentry client, use the re-exported pure filter as
1162
- the host's `beforeSend` callback:
496
+ | Prop | Type | Description |
497
+ |------|------|-------------|
498
+ | `flopay` | `FloPay \| Promise<FloPay> \| null` | A FloPay instance or the promise from `loadFloPay()` |
499
+ | `paypalFlopay` | `FloPay \| Promise<FloPay> \| null` | Optional instance for the Stripe-rendered PayPal leg |
500
+ | `options.billingApiUrl` | `string?` | Billing API base URL exposed to child components |
501
+ | `onInstrument` | `(event: FloInstrumentEvent) => void` | Merchant instrument feed |
502
+ | `children` | `ReactNode` | Checkout content |
1163
503
 
1164
- ```ts
1165
- import * as Sentry from '@sentry/browser';
1166
- import { dropThirdPartyOnlyError } from '@flopay/react';
504
+ ### FloPayCardSetupProps
1167
505
 
1168
- Sentry.init({ beforeSend: dropThirdPartyOnlyError });
1169
- ```
506
+ | Prop | Type | Description |
507
+ |------|------|-------------|
508
+ | `sessionId` | `string` | Opaque id from the merchant-authenticated setup-session endpoint |
509
+ | `nonce` | `string` | Session-bound token returned with `sessionId` |
510
+ | `billingApiUrl` | `string?` | Billing API base URL; defaults to the configured environment |
511
+ | `telemetry` | `boolean?` | Set `false` to opt out of operational telemetry |
512
+ | `theme` | `ThemeId \| VaultCardThemeColors` | Preset (default `modern-light`) or raw hosted-form colors |
513
+ | `containerStyle` | `CSSProperties?` | Container styles merged over the preset surface |
514
+ | `loading` | `ReactNode?` | Content shown while the session and form load |
515
+ | `onReady` | `() => void` | The hosted form is mounted and accepting input |
516
+ | `onComplete` | `(event: FloPayCardSetupCompleteEvent) => void` | The card is verified and usable |
517
+ | `onDecline` | `(event: FloPayCardSetupDeclineEvent) => void` | Verification was declined |
518
+ | `onValidation` | `(message: string \| null) => void` | Live hosted-field validation, not an error |
519
+ | `onError` | `(error: FloPayCardSetupError) => void` | Validation or technical failure with `code` and `retryable` |
520
+ | `onCancel` | `(event: FloPayCardSetupCancelEvent) => void` | Unmounted before a terminal outcome |
521
+
522
+ ### FloPayAutomaticPaymentButtonProps
523
+
524
+ Also accepts every standard `button` attribute, such as `onClick`, `disabled`, and `className`.
1170
525
 
1171
- No Sentry package or configuration is bundled by FloPay. The predicate drops
1172
- only complete exception stacks with no app or `@flopay/*` frame and at least
1173
- one recognised browser-extension URL or Stripe `dahlia/stripe.js` path/module
1174
- marker. Anonymous frames may accompany that marker. Mixed, unrelated,
1175
- incomplete, and anonymous-only errors pass through unchanged. The `in_app`
1176
- flag is ignored because host rewriting may set it to `true` on third-party
1177
- frames.
1178
-
1179
- Inline session creation classifies bounded failures without inspecting raw
1180
- errors: network/CORS/timeouts emit `transport_error`, 5xx responses emit
1181
- `server_error`, and malformed response discriminants emit `invalid_response`.
1182
- Stripe and PayPal SDK load/runtime failures emit `provider_runtime`. Inline
1183
- 4xx validation rejections and ordinary payment declines remain non-error
1184
- `validation_rejected` / `payment_declined` outcomes and never carry
1185
- `failureCategory`.
1186
-
1187
- If Stripe.js cannot initialize after `FloPayCheckout` resolves a full session,
1188
- the checkout no longer hides a provider-independent payment surface. A
1189
- vault-capable session continues with the hosted card form, and Direct PayPal
1190
- continues when the session advertises it; Stripe wallets and APMs are omitted.
1191
- The degraded surface exposes one **Retry payment methods** action. It retries
1192
- only provider initialization and never repeats session read/create/claim,
1193
- automatic checkout, intent creation, or payment processing. A failed retry is
1194
- not offered again during that mount, and cards or Direct PayPal remain usable.
1195
-
1196
- When neither the hosted card form nor Direct PayPal can render, `onError` and
1197
- the `error` render prop receive a `FloPayError` with code
1198
- `CheckoutInitializationFailed`. The default error surface uses fixed buyer-safe
1199
- copy. Raw provider messages are never placed in the DOM or merchant callback.
1200
- Existing-session transport and malformed-response failures are separately
1201
- reported at `session_read`; expected session outcomes remain non-alerting.
1202
-
1203
- Direct PayPal script and unusable-namespace failures remain
1204
- `PROVIDER_LOAD_FAILED` at `provider_load`. Once PayPal reports the button as
1205
- eligible, a pre-ready `onError` or rejected `render()` is instead
1206
- `PROVIDER_RUNTIME_FAILED` at `provider_ready`. These bounded groups contain no
1207
- provider message or stack. Ineligibility, superseded renders, and recognized
1208
- PayPal teardown remain non-alerting; only true load failures project to the
1209
- merchant instrument feed as `checkout_error` / `sdk_load`.
1210
-
1211
- The same classification applies to React-owned saved-payment processing in
1212
- `auto` and `confirm` modes. An ordinary saved-payment `400` decline, a
1213
- structured-field `400`, or a `422` still falls back to the usable full checkout
1214
- and emits `operation.fallback`, but reports only the matching
1215
- `payment_declined` or `validation_rejected` outcome. Genuine server,
1216
- transport, malformed-response, and provider-runtime failures remain
1217
- categorized technical errors. This does not change fallback rendering or
1218
- merchant callback behavior, and no response body, provider payload, session
1219
- value, payment data, or raw error message is added to telemetry.
526
+ | Prop | Type | Description |
527
+ |------|------|-------------|
528
+ | `sessionId` / `nonce` | `string?` | Reuse an existing auto-mode session |
529
+ | `createSession` | `InlineSessionDraft?` | Inline draft for the session to create |
530
+ | `clientId` | `string?` | Merchant client id for inline creation |
531
+ | `items` / `subscriptions` / `products` | arrays | Cart for inline creation |
532
+ | `account` | `object?` | Buyer account; `userId` required, `email` optional |
533
+ | `successUrl` / `cancelUrl` | `string?` | Redirect targets |
534
+ | `couponCodes` | `string[]?` | Coupons to apply |
535
+ | `tagsData` / `utmMetadata` | objects | Analytics context |
536
+ | `billingApiUrl` | `string?` | Billing API base URL |
537
+ | `locale` | `string?` | Locale for the fallback checkout |
538
+ | `theme` | `ThemeId?` | Theme for the button and its fallback checkout |
539
+ | `appearance` / `buttonsStyles` | objects | Appearance overrides |
540
+ | `onSuccess` | `(event: FloPayAutomaticPaymentSuccessEvent) => void` | Payment completed |
541
+ | `onDecline` | `(decline: DeclineEvent) => void` | Payment declined |
542
+ | `onError` | `(error: FloPayError) => void` | Payment or setup error |
543
+ | `children` | `ReactNode?` | Button content |
1220
544
 
1221
545
  ### SplitCardFormProps
1222
546
 
1223
- Key payment-surface props include:
547
+ | Prop | Type | Description |
548
+ |------|------|-------------|
549
+ | `sessionId` | `string` | Checkout session id |
550
+ | `nonce` | `string?` | Session-bound token forwarded on intent, decline, account, and process calls |
551
+ | `session` | `CheckoutSession \| null` | Session capability data, including the optional `vault` block and gateways |
552
+ | `billingApiUrl` | `string?` | Billing API base URL |
553
+ | `email` / `userId` / `firstName` / `lastName` | `string?` | Buyer details |
554
+ | `totalAmount` | `number?` | Amount in the smallest currency unit, for wallet and PayPal configuration |
555
+ | `currency` | `string?` | Currency code for wallet and PayPal configuration |
556
+ | `layout` | `'default' \| 'buttons'` | Payment form layout |
557
+ | `theme` | `ThemeId?` | Theme bundle |
558
+ | `submitLabel` | `(string \| null)?` | Live wording for the hosted card submit button; omitted, `null`, or blank shows the hosted default `CONFIRM PAYMENT` |
559
+ | `showStripe` / `showPayPal` | `boolean?` | Toggle Stripe methods and PayPal |
560
+ | `enabledPaymentMethods` | `string[]?` | Stripe method identifiers; `card` advertises the hosted card form |
561
+ | `enableAVS` | `boolean \| AVSFieldConfig` | Collect and validate the billing address |
562
+ | `cardFieldOrder` | `VaultCardFieldKey[]?` | Order of the hosted card rows |
563
+ | `onBuyerIdentityReady` | `(identity) => MaybePromise<void>` | Attach a buyer identity collected by the form; `FloPayCheckout` wires its late-buyer claim here |
564
+ | `onTokenizedBody` | `(body: TokenizedBody) => void` | Take over non-card backend submission yourself |
565
+ | `onComplete` / `onDecline` / `onError` | callbacks | Checkout outcomes |
566
+ | `directPaypal` | `object?` | Direct PayPal client configuration when wiring PayPal manually; `FloPayCheckout` resolves it from `gateways.paypal` |
567
+ | `showApplePay` / `showGooglePay` | deprecated | Superseded by the session's `enabledPaymentMethods` |
568
+
569
+ ### DirectPayPalButtonProps
1224
570
 
1225
571
  | Prop | Type | Description |
1226
572
  |------|------|-------------|
1227
- | `sessionId` | `string` | Checkout session UUID. |
1228
- | `nonce` | `string` | Session-bound token forwarded on intent, decline, account, and process calls. |
1229
- | `session` | `CheckoutSession?` | Session capability data, including the optional hosted `vault` fast path and gateways. |
1230
- | `billingApiUrl` | `string` | Billing API base URL. |
1231
- | `showStripe` | `boolean` | Show Stripe-backed wallets/APMs and the hosted card path when the session embeds `vault` or advertises `card`. |
1232
- | `showPayPal` | `boolean` | Show PayPal. Renderer chosen by `gateways.paypal` presence (DirectPayPal JS SDK when present; Stripe-rendered PayPal otherwise). Default: `true`. |
1233
- | `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. |
1234
- | `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`. |
1235
- | `showGooglePay` | `boolean` | *Deprecated.* See `showApplePay`. |
1236
- | `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. |
1237
- | `totalAmount` | `number` | Amount in cents for PayPal / wallet config |
1238
- | `currency` | `string` | Currency code for PayPal / wallet config (default: `'usd'`) |
1239
- | `submitLabel` | `(string \| null)?` | Final, already-localized wording for the hosted card button (e.g. `'Pay $10'`). Updates live without remounting the card form; omitted, cleared, `null`, or blank shows the hosted default `CONFIRM PAYMENT`. See [Hosted card submit label](#hosted-card-submit-label). |
1240
- | `onFirstNameChange` | `(value: string) => void` | First name change callback |
1241
- | `onLastNameChange` | `(value: string) => void` | Last name change callback |
1242
- | `onBuyerIdentityReady` | `(identity) => MaybePromise<void>?` | Called once checkout has collected a missing buyer email (or a wallet / PayPal approval supplied one) on a still-anonymous session. `FloPayCheckout` wires its late-buyer `PATCH /claim` here — including for custom-layout children, via `CheckoutContext`. A standalone form around a `session` with `buyerIdentified: false` that is given neither a prop nor a context claim builds its own from `sessionId` + `nonce`, so the hosted vault charge is never submitted against an unbound buyer. Supply the prop to take the claim over yourself. |
1243
- | `onComplete` / `onDecline` / `onError` | callbacks | Observable checkout outcomes. |
1244
-
1245
- #### Deferred-vault rollout boundary
1246
-
1247
- SDK `1.4.9` is the compatibility boundary for omitting embedded capture
1248
- credentials. Keep the backend's current
1249
- `link session → capture → complete idempotency → 201` ordering for requests
1250
- from older or unknown SDK versions, and until the deployed `@flopay/react`
1251
- population has crossed the adoption threshold chosen by the backend rollout.
1252
- The SDK advertises its version in `x-flo-sdk-version` on session create/read.
1253
-
1254
- After that threshold, the backend may omit capture credentials from
1255
- create/read/idempotent-replay responses for requests advertising SDK `>=1.4.9`,
1256
- provided the session explicitly includes `card` in
1257
- `gateways.stripe.enabledPaymentMethods` and the nonce-protected
1258
- `POST /v1/checkouts/sessions/{id}/vault/capture` route remains available.
1259
- Backend rollout verification should confirm that create/read/replay no longer
1260
- invoke PCIVault and that capture issuance occurs only through the deferred
1261
- endpoint when a buyer enters the card path.
573
+ | `sessionId` / `nonce` | `string` | Checkout session and its token |
574
+ | `billingApiUrl` | `string` | Billing API base URL |
575
+ | `clientId` | `string` | PayPal client id (`gateways.paypal.publishableKey`) |
576
+ | `environment` | `GatewayEnvironment?` | `sandbox` or `live` |
577
+ | `currency` | `string` | ISO 4217 currency code |
578
+ | `isSubscription` | `boolean` | Fallback flow selection when the session advertises no `providerObjectType` |
579
+ | `approvalPresentation` | `PayPalApprovalPresentation?` | Billing-API-advertised approval mode |
580
+ | `session` | `CheckoutSession \| null` | Backing session; its advertised values are authoritative |
581
+ | `onComplete` / `onDecline` | callbacks | Payment outcomes |
582
+ | `onTechnicalFailure` | callback | Post-click technical failures, including `popupBlocked` |
1262
583
 
1263
584
  ### Types
1264
585
 
1265
586
  | Type | Description |
1266
587
  |------|-------------|
588
+ | `FloPayCheckoutProps` | Props for `FloPayCheckout` |
1267
589
  | `FloPayProviderProps` | Props for `FloPayProvider` |
1268
590
  | `FloPayCardSetupProps` | Props for `FloPayCardSetup` |
1269
- | `FloPayCardSetupCompleteEvent` | Verified setup completion with optional display-only `paymentMethod` |
1270
- | `FloPayCardSetupDeclineEvent` | Terminal card-verification decline |
1271
- | `FloPayCardSetupCancelEvent` | Unmount-before-terminal cancellation |
1272
- | `FloPayCardSetupError` | Structured setup failure with stable `code` and `retryable` classification |
591
+ | `FloPayCardSetupCompleteEvent` | Verified setup: `status`, `sessionId`, and optional display-only `paymentMethod` |
592
+ | `FloPayCardSetupDeclineEvent` | Declined setup: `status`, `sessionId`, `reason?`, `message?` |
593
+ | `FloPayCardSetupCancelEvent` | Unmounted before a terminal outcome: `status`, `sessionId` |
594
+ | `FloPayAutomaticPaymentButtonProps` | Props for `FloPayAutomaticPaymentButton` |
595
+ | `FloPayAutomaticPaymentSuccessEvent` | `result`, `session`, `sessionId`, and `autoCompleted` |
1273
596
  | `SplitCardFormProps` | Props for `SplitCardForm` |
1274
- | `CheckoutState` | `{ session, loading, error, claimPending }` |
1275
- | `FloInstrumentEvent` | Versioned merchant instrument union with seven lifecycle names, four `checkout_error` phases, and an optional gateway. |
597
+ | `DirectPayPalButtonProps` | Props for `DirectPayPalButton` |
598
+ | `VaultCardFieldsProps` | Props for `VaultCardFields` |
599
+ | `CheckoutState` | `session`, `loading`, `error`, `claimPending?` |
600
+ | `DeclineEvent` | `method`, `message`, `code?`, `declineCode?` |
601
+ | `BeforeButtonClickEvent` | `method`, `sessionId?`, `createSession?` |
602
+ | `CheckoutButtonMethod` | The payment method a button represents |
603
+ | `InlineSessionDraft` | Draft accepted by `FloPayCheckoutProps.createSession` |
604
+ | `InlineSessionPatch` | Partial draft returned from `FloPayCheckoutProps.onBeforeButtonClick` |
605
+ | `FloInstrumentEvent` | Versioned merchant instrument union |
606
+
607
+ ## Errors
608
+
609
+ 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`.
610
+
611
+ | Code | Type | When |
612
+ |------|------|------|
613
+ | `checkout_session_expired` | `api_error` | `FloPayCheckout` or `FloPayAutomaticPaymentButton` loaded an expired session. Create a fresh session and send the buyer back through checkout. |
614
+ | `CurrencyRequired` | `validation_error` | `createSession` has no resolvable currency. Thrown before any request. |
615
+ | `InvalidIdempotencyKey` | `validation_error` | `createSession.idempotencyKey` is blank or longer than 255 characters. |
616
+ | `CaptureMethodUnsupportedForSubscription` | `validation_error` | Manual capture was requested for a cart with a subscription. |
617
+ | `BuyerEmailCollectionRequired` | `validation_error` | A buyer email supplied to the checkout is not a valid email address. |
618
+ | `UnsupportedBackendVaultCapability` | `api_error` | The session offers no hosted card form or card capability and no other usable payment method. |
619
+ | `CheckoutInitializationFailed` | `api_error` | Neither the hosted card form nor Direct PayPal could render after Stripe.js failed to initialize. |
620
+ | `paypal_init_timeout` | `api_error` | A PayPal-only checkout could not start PayPal after automatic recovery. The **Retry PayPal** button tries again. |
621
+ | `external_payment_method_failed` | `api_error` | A wallet, PayPal, or alternative method failed technically after the buyer clicked it. The chooser offers a retry. |
622
+ | `InvalidCardSetupSurface` | `validation_error` | `FloPayCheckout` was given a card-setup session; use `FloPayCardSetup`. |
623
+ | `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. |
624
+ | `InvalidPaymentProcessResponse` | `api_error` | Saved-payment processing returned a response the SDK could not use. |
625
+
626
+ 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.
627
+
628
+ `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.
629
+
630
+ | Code | When |
631
+ |------|------|
632
+ | `MissingCheckoutSessionId` | Rendered without `sessionId` |
633
+ | `MissingCheckoutSessionToken` | Rendered without `nonce` |
634
+ | `InvalidCardSetupSession` | The session is not a zero-amount, product-free setup session |
635
+ | `UnsupportedBackendVaultCapability` | The setup session has no hosted card capability |
636
+ | `card_setup_widget_failed` | The hosted card form failed at runtime (retryable) |
637
+ | `card_setup_load_failed` | Loading the session or card form failed for another reason |