@flopay/js 1.9.16 → 1.9.18

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 +280 -485
  2. package/package.json +2 -2
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @flopay/js
2
2
 
3
- Browser-side FloPay SDK. Provides `loadFloPay()` to initialize the SDK, a `FloPay` class for hosted card capture and session access, `PaymentAPI` for billing API calls, and `createCheckoutSession` for server-initiated checkout flows.
3
+ Browser-side FloPay SDK. `loadFloPay()` initializes a `FloPay` instance for hosted card capture and session access, `PaymentAPI` is the billing API client, and `createCheckoutSession()` creates a checkout session and redirects to the hosted checkout page.
4
4
 
5
- Currently backed by Stripe via the `StripeAdapter`. The adapter pattern (`PaymentProviderAdapter` interface) allows swapping providers without changing consumer code.
5
+ Providers sit behind the `PaymentProviderAdapter` interface (implemented today by `StripeAdapter`), so checkout code does not change when a session is routed to a different gateway.
6
6
 
7
7
  ## Installation
8
8
 
@@ -10,9 +10,11 @@ Currently backed by Stripe via the `StripeAdapter`. The adapter pattern (`Paymen
10
10
  pnpm add @flopay/js
11
11
  ```
12
12
 
13
- `@stripe/stripe-js` ships as a direct dependency of `@flopay/js` — consumers do not need to install it separately.
13
+ `@stripe/stripe-js` and `@flopay/shared` install automatically as dependencies; you do not need to add them. Choose the billing API environment once at startup with `configureFlopay()` from `@flopay/shared`, or the `NEXT_PUBLIC_FLOPAY_ENV` environment variable.
14
14
 
15
- ## Quick Start
15
+ For React applications, `@flopay/react` builds on this package and renders the complete checkout.
16
+
17
+ ## Usage
16
18
 
17
19
  ### Initialize the SDK
18
20
 
@@ -20,394 +22,114 @@ pnpm add @flopay/js
20
22
  import { loadFloPay } from '@flopay/js';
21
23
 
22
24
  const flopay = await loadFloPay('pk_test_...', {
23
- billingApiUrl: 'https://billing.example.com', // optional, enables retrieveSession/retrieveUnifiedSession
25
+ billingApiUrl: 'https://billing.example.com', // enables retrieveSession() and retrieveUnifiedSession()
24
26
  });
25
27
  ```
26
28
 
27
- `loadFloPay` caches by publishable key and behavior-affecting options. Concurrent
28
- calls with the same key and options share one in-flight provider initialization
29
- and resolve to the same `FloPay` instance. A failed initialization is evicted so
30
- a later call can retry. The optional second argument accepts
31
- `Omit<FloPayConfig, 'publishableKey'>`, including `billingApiUrl`, `locale`,
32
- `appearance`, and `telemetry`; set `telemetry: false` to opt out of privacy-safe
33
- operational telemetry.
34
-
35
- When telemetry is enabled, a Stripe loader rejection is reported only with the
36
- bounded `provider_runtime` category, while a null Stripe loader result uses the
37
- distinct `invalid_response` category. Both use `PROVIDER_LOAD_FAILED` at
38
- `provider_load` with request category `provider_sdk`; neither changes the
39
- publicly thrown error or failed-load retry behavior, and neither transmits the
40
- publishable key, rejection, provider message, stack, URL, or provider payload.
41
-
42
- `FloPayCheckout` in `@flopay/react` also classifies failures around the session
43
- and provider boundary without exposing raw errors. An existing session-read
44
- transport failure uses `transport_error`; malformed JSON uses
45
- `invalid_response`. A provider failure after the session resolves reuses the
46
- provider-load tuple above rather than collapsing into `INTERNAL_SDK_ERROR` at
47
- `checkout_mount`. See the React package README for its hosted-card fallback and
48
- bounded provider retry behavior.
49
-
50
- ### Retrieve a Session
29
+ The optional second argument accepts every `FloPayConfig` field except `publishableKey`: `billingApiUrl`, `locale`, `appearance`, and `telemetry`. `loadFloPay` caches by publishable key and behavior-affecting options: concurrent calls with the same key and options share one in-flight provider initialization and resolve to the same `FloPay` instance. A failed initialization is evicted, so a later call can retry.
51
30
 
52
- ```ts
53
- // Using billingApiUrl from config (set at init time):
54
- const session = await flopay.retrieveSession('session_uuid');
55
- console.log(session.amount, session.currency, session.status);
31
+ ### Retrieve a session
56
32
 
57
- // Or pass billingApiUrl explicitly:
58
- const session2 = await flopay.retrieveSession('session_uuid', 'https://billing.example.com');
59
-
60
- // For provider-specific data (Stripe clientSecret, publishableKey, etc.):
61
- const unified = await flopay.retrieveUnifiedSession('session_uuid');
62
- console.log(unified.data.stripe?.clientSecret);
63
- ```
33
+ `flopay.retrieveSession(sessionId)` returns the normalized `CheckoutSession` (amount, currency, status, customer). `flopay.retrieveUnifiedSession(sessionId)` returns a `NormalizedCheckoutSession`: every gateway the session advertises is listed in `providers`, and gateway data such as the Stripe client secret is under `data`. Both use the `billingApiUrl` from `loadFloPay`, or a URL you pass as the second argument.
64
34
 
65
35
  `retrieveUnifiedSession()` exposes provider-specific session data without
66
36
  mounting provider UI. Use `FloPayCheckout` or `SplitCardForm` from
67
37
  `@flopay/react` for hosted-vault card, wallet, and APM rendering.
68
38
 
69
- Failed session loads reject with `FloPayError`. When the billing API returns a structured error body, the SDK preserves the safe `message`, backend `code`, and HTTP `statusCode`; body-less responses fall back to `code: "http_<status>"`.
39
+ ### Call the billing API directly
70
40
 
71
- ### PaymentAPI (Billing API Client)
41
+ `PaymentAPI` wraps the billing API routes the checkout uses. Session reads and continuation calls take the session **nonce**, the session-bound checkout token returned when the session was created, and send it as `x-checkout-session-token`.
72
42
 
73
- ```ts
74
- import { PaymentAPI } from '@flopay/js';
43
+ - `getUnifiedCheckoutSession(sessionId, nonce)` reads and normalizes a session.
44
+ - `createSessionIntent(sessionId, nonce, request)` creates a Stripe wallet/APM or direct-PayPal intent. The request is a discriminated union of provider, payment-method category, type, and id, and intent kind; direct-card requests are not representable.
45
+ - `reportSessionIntentDecline(sessionId, nonce, request)` reports a client-observed non-card decline using a classification code only. Messages, identifiers, card data, credentials, and personal data are rejected before sending.
46
+ - `processPayment(userId, params)` submits a saved or approved non-card payment to the session's `/process` route. When the billing API answers `202`, the SDK keeps polling the session until it completes or times out.
47
+ - `getPaymentsByEmail(email)` looks up prior payments for a saved-payment experience.
75
48
 
76
- const api = new PaymentAPI('https://billing.example.com');
49
+ ### Create a hosted checkout session
77
50
 
78
- // Fetch and normalize a checkout session. `nonce` is the session-bound
79
- // checkout token returned from session creation — required by post-#640
80
- // backends, which match it against `checkout_session.nonce` before returning
81
- // the row.
82
- const session = await api.getUnifiedCheckoutSession('session_uuid', sessionNonce);
83
- // session.provider === 'stripe' | 'chargebee' | 'recurly'
84
- // session.data.session contains the normalized CheckoutSession
85
- // session.data.session.clientSecret carries the same nonce so downstream
86
- // code can re-use it.
87
-
88
- // Create an Apple Pay intent. Direct-card requests are not part of this union.
89
- const intent = await api.createSessionIntent(
90
- 'session_uuid',
91
- sessionNonce,
92
- {
93
- provider: 'stripe',
94
- paymentMethodCategory: 'wallet',
95
- paymentMethodType: 'apple_pay',
96
- paymentMethodId: 'pm_xxx',
97
- intentKind: 'payment',
98
- },
99
- );
100
-
101
- // Report a client-observed non-card decline using a provider classification
102
- // code only. Messages, identifiers, card data, credentials, and PII are rejected.
103
- await api.reportSessionIntentDecline('session_uuid', sessionNonce, {
104
- provider: 'stripe',
105
- paymentMethodCategory: 'wallet',
106
- paymentMethodType: 'apple_pay',
107
- providerDeclineReason: 'payment_failed',
108
- });
51
+ `createCheckoutSession(options)` posts to `POST /v1/checkouts/sessions` and redirects the browser:
109
52
 
110
- // Process a tokenized payment. `nonce` is required: the SDK throws a
111
- // `FloPayError` with code `MissingCheckoutSessionToken` when it is missing,
112
- // and otherwise POSTs to `/v1/checkouts/sessions/<id>/process` with the
113
- // header set.
114
- const processResponse = await api.processPayment('user_id', {
115
- sessionId: 'session_uuid',
116
- nonce: sessionNonce,
117
- tokenizedData: { id: 'pm_xxx', type: 'card' },
118
- accountData: { userId: 'user_id', email: 'user@example.com', firstName: 'John', lastName: 'Doe' },
119
- });
120
- // If the backend returns `202 checkout_processing`, the SDK keeps polling the
121
- // checkout session until it completes or times out, then resolves/rejects.
53
+ - On `201`, the browser goes to the hosted checkout page and the result is `{ status: 201, redirectUrl, nonce }`. A matching `flopay_checkout_token` cookie is written to the same parent domain as `checkout_data`, so the hosted page can read the token without putting it in the URL, history, or `Referer`.
54
+ - On `204`, a payment method is already on file and the browser goes to `successUrl`.
122
55
 
123
- // Check for prior payments (saved card UX)
124
- const payments = await api.getPaymentsByEmail('user@example.com');
125
- ```
56
+ `createCheckoutSessionWithRetries(options)` adds automatic retry with jittered exponential backoff on transient transport, timeout, and idempotency-in-progress failures: at most two retries (three attempts), all inside one 12-second total deadline you can change with `timeoutMs`.
126
57
 
127
- ### createCheckoutSession
58
+ ### Checkout metadata
128
59
 
129
- ```ts
130
- import { createCheckoutSession } from '@flopay/js';
60
+ Pass `checkoutMetadata?: Record<string, string> | null` to redirect, retrying, one-shot inline, or detached creation. A present map, an explicit `null`, and `{}` are sent unchanged; omitting the field leaves the request body unchanged. Session reads expose a returned snapshot as `normalized.checkoutMetadata` and `normalized.data.session.checkoutMetadata`; an omitted or `null` create value is absent from reads, while `{}` is returned as `{}`. The SDK performs no duplicate size validation: billing API rejections keep their `code`, message, and HTTP `statusCode`.
131
61
 
132
- const result = await createCheckoutSession({
133
- billingApiUrl: 'https://billing.example.com',
134
- checkoutBaseUrl: 'https://checkout.example.com',
135
- clientId: 'client_123',
136
- items: [{
137
- providerItemId: 'prod_abc',
138
- providerItemName: 'Pro Plan',
139
- totalAmount: 49.99,
140
- overrideAmount: 24.99,
141
- }],
142
- account: { userId: 'user_1', email: 'user@example.com' },
143
- successUrl: '/success',
144
- cancelUrl: '/cancel',
145
- checkoutMetadata: { merchantOrderId: 'order-123', campaign: 'summer' },
146
- redirectParams: { email: 'user@example.com', bg: 'courses', mode: 'confirm' },
147
- });
148
- // On 201: browser redirects to checkout page. The resolved
149
- // `CheckoutSessionResult` is `{ status: 201, redirectUrl, nonce }` — `nonce` is
150
- // the session-bound checkout token returned by the billing API. A matching
151
- // `flopay_checkout_token` cookie is written to the same parent domain as
152
- // `checkout_data`, so a hosted-checkout page can read the token from
153
- // `document.cookie` and forward it on continuation calls without having to
154
- // pull it from the URL (kept out of history/Referer).
155
- // On 204: browser redirects to successUrl (payment method on file)
156
- ```
62
+ ### Authorise now, capture later
157
63
 
158
- #### Checkout metadata
159
-
160
- Pass `checkoutMetadata?: Record<string, string> | null` to redirect, retrying,
161
- one-shot inline, or detached creation. A present map, explicit `null`, and `{}`
162
- are sent unchanged; omitting the field leaves the request body unchanged.
163
- Session reads expose a returned snapshot as
164
- `normalized.checkoutMetadata` and `normalized.data.session.checkoutMetadata`.
165
- An omitted or `null` create value is absent from reads, while an explicit `{}`
166
- is returned as `{}`. This field is separate from the legacy session `metadata`
167
- member.
168
-
169
- The SDK deliberately performs no duplicate size or length validation. Backend
170
- rejections remain `FloPayError` instances carrying the backend `code`, message,
171
- and HTTP `statusCode`. Retrying creation replays the same metadata and
172
- idempotency key.
173
-
174
- For an item-only card checkout that should place a hold instead of capturing
175
- immediately, add `captureMethod: 'manual'`. The same option is supported by
176
- `createCheckoutSessionWithRetries`, `PaymentAPI.createAndFetchSession`, and
177
- `PaymentAPI.createDetachedSession`. Omitting it keeps the request and result
178
- behaviour unchanged.
179
-
180
- An accepted hold surfaces as `status: 'authorized'` on normalized sessions and
181
- payment results, with `paymentId`, `sessionId`, and
182
- `authorizationExpiresAt`. Treat it as checkout-terminal but not paid. Capture
183
- is a merchant-authenticated REST operation; this browser package contains no
184
- merchant credential or capture capability. See the repository's
185
- [pre-authorisation guide](../../docs/PREAUTHORIZATION.md).
186
-
187
- Use `createCheckoutSessionWithRetries` for automatic retry with jittered
188
- exponential backoff on transient transport, timeout, and idempotency-in-progress
189
- errors. By default it makes at most two retries (three total attempts). A single
190
- 12-second total deadline (customizable with `timeoutMs`) covers the initial
191
- request, every retry, and all backoff; all attempts receive the same
192
- `AbortSignal`.
193
-
194
- #### Idempotent checkout creation
195
-
196
- When a secure RNG is available, every checkout-session create sends a stable
197
- `Idempotency-Key` header so a timeout or lost response cannot mint a second
198
- session. The behavior is fully additive — existing integrations need no changes:
199
-
200
- - **Automatic (default):** the SDK generates one cryptographically random,
201
- high-entropy key per logical `createCheckoutSession` call and reuses it for
202
- every transport retry of that same operation. Two independent calls get
203
- different keys, even when their request bodies are identical. The generated
204
- key is never derived from customer data and is never logged or returned.
205
- Automatic generation requires a secure RNG (`crypto.randomUUID` /
206
- `crypto.getRandomValues`); in an environment without one the SDK omits the
207
- header rather than emit a weak key — supply a valid `idempotencyKey` yourself
208
- to stay idempotent there.
209
- - **Merchant-supplied:** pass `idempotencyKey` to control the key yourself. It
210
- is sent unchanged and reused across retries, so you can keep it stable across
211
- React remounts, multiple SDK instances, or server retries you control. It
212
- identifies **exactly one** logical checkout creation and must not be reused
213
- for a new purchase. It must be non-empty and at most 255 characters (an
214
- invalid value throws a `FloPayError('validation_error')` before any request).
64
+ For an item-only card checkout that should place a hold instead of capturing immediately, add `captureMethod: 'manual'` to `createCheckoutSession`, `createCheckoutSessionWithRetries`, `PaymentAPI.createAndFetchSession`, or `PaymentAPI.createDetachedSession`. An accepted hold surfaces as `status: 'authorized'` on normalized sessions and payment results, with `paymentId`, `sessionId`, and `authorizationExpiresAt`. Treat it as checkout-terminal but not paid. Capture is a merchant-authenticated REST operation, never available to browser code; see the [payments and authorisations reference](https://flopay.com/docs/technical-reference/rest/payments).
215
65
 
216
- ```ts
217
- // Merchant-controlled key — reused for this one logical checkout only.
218
- await createCheckoutSession({ /* … */ idempotencyKey: `checkout:${orderId}` });
219
- ```
66
+ ### Idempotent checkout creation
67
+
68
+ Every checkout-session create sends a stable `Idempotency-Key` header when a secure random source is available, so a timeout or lost response cannot mint a second session. Existing integrations need no changes.
220
69
 
221
- `createCheckoutSessionWithRetries` resolves the key once, before its retry loop,
222
- so all attempts — timeouts, lost responses, and the backend's documented
223
- in-progress reply — replay the same key. A payload-conflict (`409`) is surfaced
224
- without retrying. The header is optional on the backend: older SDKs and direct
225
- API clients that omit it keep the legacy, unkeyed path. The same automatic and
226
- merchant-supplied behavior applies to inline session creation via
227
- `PaymentAPI.createAndFetchSession` (used by `@flopay/react`). A one-shot inline
228
- create uses the same 12-second total deadline and jitter policy, with one shared
229
- three-attempt budget across network failures and idempotency-in-progress
230
- responses. A detached inline create applies `timeoutMs` afresh to its shell POST
231
- and initial catalogue-claim PATCH; each phase has its own signal and retry
232
- budget. Pass `timeoutMs` in the inline session params to choose a different
233
- per-phase ceiling.
234
-
235
- `@flopay/shared` also exports the primitives directly if you build your own
236
- create flow: `generateIdempotencyKey()`, `resolveIdempotencyKey(supplied?)`,
237
- `IDEMPOTENCY_KEY_HEADER`, and `MAX_IDEMPOTENCY_KEY_LENGTH`.
70
+ - **Automatic (default):** the SDK generates one cryptographically random key per logical `createCheckoutSession` call and reuses it for every retry of that operation. Independent calls get different keys even when their bodies are identical. The key is never derived from customer data and is never logged or returned. Without `crypto.randomUUID` or `crypto.getRandomValues` the header is omitted; supply your own key to stay idempotent there.
71
+ - **Merchant-supplied:** pass `idempotencyKey` to keep the key stable across remounts, SDK instances, or your own server retries. It identifies exactly one logical checkout and must never be reused for a new purchase.
72
+
73
+ `createCheckoutSessionWithRetries` resolves the key once, before its retry loop, so timeouts, lost responses, and in-progress replies all replay the same key; a `409` payload conflict is surfaced without retrying. `PaymentAPI.createAndFetchSession` (used by `@flopay/react`) follows the same rules, with one shared three-attempt budget across network failures and in-progress replies.
238
74
 
239
75
  ### Detached session creation (default)
240
76
 
241
- Inline session creation is **detached** by default (TeamFloPay/backend#1099).
242
- Instead of one request that resolves buyer identity and the catalog inline, the
243
- SDK issues two:
244
-
245
- 1. `POST /v1/checkouts/sessions` with `deferDataAttachment: true` — a
246
- lightweight *shell*. It runs no catalog validation and takes none of the
247
- buyer-identity advisory locks that serialise concurrent checkouts for the
248
- same customer. The backend routes a gateway for it from `currency` and the
249
- buyer's `country`, so the response already carries `gateways` and the hosted
250
- `vault` block: **the card form can mount from it.** Checkout metadata is sent
251
- on this initial request.
252
- 2. `PATCH /v1/checkouts/sessions/{id}/claim` — attaches products and coupons in
253
- the background. When the draft already has an email, the same request also
254
- attaches the buyer. An email-less draft sends no `accountData` block, so the
255
- returned session has its full catalog, totals and eligible method metadata
256
- while `buyerIdentified` remains `false`. Checkout metadata is never repeated
257
- or edited by either the catalog or late-buyer claim.
258
-
259
- For detached creation, `timeoutMs` is a fresh per-phase budget: the shell POST
260
- gets the full value, and after it succeeds the initial claim PATCH gets the full
261
- value again under a different `AbortSignal`. The default is 12 seconds for each
262
- phase, so time spent creating a usable shell never starves its catalogue claim.
263
- One-shot creation still has one total deadline.
264
-
265
- `PaymentAPI.createDetachedSession(params)` exposes both phases. When the draft
266
- already has an email, the combined catalog-and-buyer claim starts immediately.
267
- When email is missing, a catalog-only claim still starts immediately; the
268
- caller can attach the buyer later with the separate `claim(account)` operation:
77
+ Inline session creation is detached by default. Instead of one request that resolves buyer identity and the catalog together, the SDK issues two:
269
78
 
270
- ```ts
271
- const detached = await api.createDetachedSession({
272
- ...params,
273
- account: { userId: 'buyer_123' },
274
- });
79
+ 1. `POST /v1/checkouts/sessions` with `deferDataAttachment: true` returns a lightweight **shell**. It runs no catalog validation and takes none of the buyer-identity locks that serialise concurrent checkouts for the same customer, yet already carries `gateways` and the hosted `vault` block: **the card form can mount from it.** Checkout metadata is sent on this request.
80
+ 2. `PATCH /v1/checkouts/sessions/{id}/claim` attaches products and coupons in the background. When the draft has an email it also attaches the buyer; an email-less draft omits the buyer, and the returned session has its full catalog, totals, and methods while `buyerIdentified` stays `false`.
275
81
 
276
- mountCardForm(detached.shell.data.session?.vault); // interactive immediately
277
- const session = await detached.claimed; // catalog, totals, methods; buyerIdentified === false
82
+ `PaymentAPI.createDetachedSession(params)` exposes both phases as `{ shell, sessionId, nonce, claimed, claim }`. `claimed` settles when the catalog is attached; `claim(account)` attaches a buyer identity later. Both are independently replay-safe: repeated valid `claim(account)` calls return the same promise, and a local invalid-email rejection does not consume the operation. `createAndFetchSession` also accepts an email-less draft and resolves with the catalog-attached anonymous session.
278
83
 
279
- const identified = await detached.claim({
280
- email: 'buyer@example.com',
281
- firstName: 'Jane',
282
- lastName: 'Doe',
283
- }); // buyerIdentified === true
284
- ```
84
+ Nothing may be charged before `claimed` settles: the billing API rejects process, intent, and decline calls on an unclaimed session with `409 checkout_session_data_attachment_required`, and holds an unclaimed hosted card charge with a retryable `503`. `@flopay/react` gates the card form's submit button for exactly this window.
285
85
 
286
- The catalog claim and late buyer claim are independently replay-safe. Repeated
287
- valid `claim(account)` calls return the same buyer-attachment promise; a local
288
- invalid-email rejection does not consume the operation. Blank/null identity
289
- fields are omitted. `createAndFetchSession` also accepts an email-less draft and
290
- resolves with the catalog-attached anonymous session.
86
+ For detached creation, `timeoutMs` is a fresh per-phase budget: the shell POST gets the full value, and after it succeeds the initial claim PATCH gets the full value again under a different `AbortSignal` (12 seconds each by default), so time spent creating a usable shell never starves its catalog claim. Detached creation requires billing API v1.7.12 or newer and `checkoutMode: 'full'`, and is skipped when `tokenizedData` is supplied. There is no fallback to the one-shot create; pass `deferDataAttachment: false` to force it. Because catalog validation moves to the claim, an invalid product or coupon surfaces as the same `422`, later in the flow. The claim body is fingerprinted by the billing API, so the SDK replays a byte-identical payload across retries, and a materially different claim for an already-claimed session returns `409` without retrying.
291
87
 
292
- Nothing may be charged before `claimed` settles: the billing API rejects
293
- process / intent / decline calls on an unclaimed session with
294
- `409 checkout_session_data_attachment_required`, and holds an unclaimed vault
295
- charge with a retryable `503`. `@flopay/react` gates the card widget's submit
296
- button for exactly this window.
88
+ ### Server-side card gateways (Worldpay)
297
89
 
298
- With an email present, `createAndFetchSession` uses the same two-phase flow but
299
- awaits the combined claim, so its resolved value and request contract are
300
- unchanged. Detached creation requires billing API `v1.7.12` or newer,
301
- `checkoutMode: 'full'`, is skipped when `tokenizedData` is supplied, and there
302
- is **no fallback** to the one-shot create: it needs a billing API with the claim
303
- endpoint. Pass `deferDataAttachment: false` to force the one-shot create.
90
+ The billing API can process hosted-vault cards through an approved Worldpay Corporate Gateway entirely server-side. Whether a session can use it is the billing API's decision, made from the merchant's active gateways and their Operations-approved capability record. `PaymentAPI` sends no capability token, gateway hint, or provider selection on any create, so the same request works for every SDK version and integration style. Everything stays provider-neutral, so existing integrations change nothing:
304
91
 
305
- Because catalog validation moves to the claim, an invalid product or coupon
306
- surfaces as the same `422` — later in the flow, but with identical semantics.
307
- The claim body is fingerprinted server-side, so the SDK replays a byte-identical
308
- payload across transport retries; a materially different claim for an
309
- already-claimed session returns `409` and is surfaced without retrying.
92
+ - Cards still go through the hosted card form, from the embedded `vault` block or `getVaultCapture()`. There is no Worldpay browser SDK, key, or credential: `gateways.worldpayCorporateGateway` has a `null` publishable key and no enabled methods, and it never appears in the normalized `providers` list.
93
+ - Outcomes arrive as the same `complete`, `decline`, and `error` events. Declines use FloPay's decline taxonomy, never a raw processor code. An ambiguous submission stays pending until the billing API reconciles it; the hosted form reports that as an `error`, never a success, and a later session read returns the reconciled status.
94
+ - Worldpay takes one-time item carts only and offers no 3DS authentication, stored credentials, or card setup; the billing API keeps subscription carts on another card gateway.
310
95
 
311
- Coupon validation errors are surfaced as `FloPayError` with structured `code`:
96
+ Auto, Confirm, and pre-tokenized sessions charge a stored payment method, which the Worldpay route does not accept; the billing API keeps those on the released Stripe and PayPal routing by itself. Do not add gateway selection, capability headers, or provider-specific branches to a create request, whether through this SDK or a request you build yourself: the billing API owns that decision, and a client-side copy of it would become a second routing authority that drifts from the merchant's real gateway state.
312
97
 
313
- - `CouponLimitExceeded` — more than 5 coupon codes supplied (also enforced client-side before the request leaves the browser).
314
- - `CouponCurrencyUnsupported` — an amount-based coupon has no price for the cart currency.
98
+ ### Hosted card capture
315
99
 
316
- ```ts
317
- import { FloPayError } from '@flopay/shared';
100
+ `flopay.cardCapture()` returns a `CardCaptureAdapter` that injects the billing API's self-contained hosted card form. The form owns the PCI card fields, its own submit button, tokenization, the charge (created and confirmed server-side), 3DS, and the result, so **no Stripe.js runs on the card path** and card numbers never enter the SDK runtime. The SDK injects the form and relays its terminal outcome.
318
101
 
319
- try {
320
- await createCheckoutSession({ /* … */ couponCodes });
321
- } catch (err) {
322
- if (err instanceof FloPayError) {
323
- if (err.code === 'CouponLimitExceeded') {
324
- // show "Too many coupons" toast
325
- } else if (err.code === 'CouponCurrencyUnsupported') {
326
- // show "Coupon is not valid for this currency" toast
327
- }
328
- }
329
- }
102
+ - Use the session's embedded `vault` block when present. Otherwise fetch one with `PaymentAPI.getVaultCapture(sessionId, nonce)` for a session whose Stripe `enabledPaymentMethods` explicitly includes `card`, or that advertises `gateways.worldpayCorporateGateway`. `getVaultCapture()` shares one active request between concurrent callers, aborts a stalled attempt after ten seconds, and retries transient network failures on the idempotent `POST /v1/checkouts/sessions/{id}/vault/capture` route.
103
+ - Outcome events are `ready`, `complete`, `decline`, and `error`. The form is injected same-window, so the adapter rejects terminal outcomes that are not bound to the mounted session, that mismatch the block's `messageToken`, or that arrive from a different `expectedOrigin`. Pass both from the vault block to `mount()`.
104
+ - A `complete` event's `intentId` is the processing gateway's own reference. It is a Stripe PaymentIntent (or SetupIntent) id only when Stripe took the card; for a server-side card gateway it may be an opaque order reference. Reconcile by `paymentId` and the session instead.
105
+ - For a merchant-server-created no-charge setup session, create the adapter with `operation: 'card_setup'`. A `complete` event may then carry a display-only `paymentMethod` (FloPay id, brand, last four, expiry, and status).
106
+ - `capture.applyTheme(theme)` re-skins the mounted form without remounting; `applyTheme(null)` is the explicit classic reset. Theming is cosmetic and never blocks capture.
107
+ - `capture.setSubmitLabel(label)` sets the hosted submit button's wording live (billing API v1.9.35+), without remounting or clearing entered card data. Pass final, already-localized plain text; the hosted form shows its session default (`CONFIRM PAYMENT`, or `ADD CARD` for card setup) for blank or invalid text. `capture.setSubmitLabel(null)` is the explicit reset to that default, and a `submitLabel` mount option sets the initial wording.
108
+
109
+ The adapter retains the latest label and replays it
110
+ every time the widget reports an authenticated `ready`, so a late or remounted
111
+ form still receives it. Because the directive carries merchant text, it is
112
+ posted only when the vault block supplied both `messageToken` and
113
+ `expectedOrigin`, bound to the mounted `sessionId`, with the exact
114
+ `expectedOrigin` as the `postMessage` target — never `'*'`:
115
+
116
+ ```ts
117
+ { source: 'flopay-vault-host', type: 'submitLabel', sessionId, messageToken, label }
330
118
  ```
331
119
 
332
- ### Server-side card gateways (Worldpay)
120
+ A form mounted without those fields (an older billing API) keeps its default. The label is never added to telemetry. The other host directives (theme, submit gate, field order, and the 3DS `action_completed` signal) also target the exact `expectedOrigin` when one was supplied, falling back to `'*'` only for a widget mounted without it.
333
121
 
334
- The billing API can process hosted-vault cards through an approved Worldpay
335
- Corporate Gateway entirely server-side (TeamFloPay/backend#1563). Whether a
336
- session can use it is the billing API's decision, made from the merchant's
337
- active gateways and their Operations-approved capability record
338
- (TeamFloPay/backend#1641). `PaymentAPI` sends no capability token, gateway
339
- hint, or provider selection on any create, so the same request works for every
340
- SDK version and integration style. Everything stays provider-neutral, so
341
- existing integrations change nothing:
342
-
343
- - Cards still go through the hosted vault widget, from the embedded `vault`
344
- block or `getVaultCapture()`. There is no Worldpay browser SDK, key, or
345
- credential: `gateways.worldpayCorporateGateway` carries
346
- `publishableKey: null` and no enabled methods, and it never appears in the
347
- normalized `providers` list.
348
- - Outcomes arrive as the same `complete` / `decline` / `error` widget events.
349
- Declines carry Flo's decline taxonomy, never a raw refusal code. An ambiguous
350
- submission stays pending until the billing API reconciles it; the hosted form
351
- reports that as an `error`, never a success, and a later session read returns
352
- the reconciled status.
353
- - In the merged billing API version, Worldpay takes one-time item carts only
354
- and offers no 3DS authentication, stored credentials, or card setup; the
355
- billing API keeps subscription carts on another card gateway. No
356
- client-visible authentication continuation exists for Worldpay.
357
-
358
- Auto, Confirm, and pre-tokenized sessions charge a stored payment method, which
359
- the Worldpay route does not accept; the billing API keeps those on the released
360
- Stripe and PayPal routing by itself. Do not add gateway selection, capability
361
- headers, or provider-specific branches to a create request, whether through
362
- this SDK or a request you build yourself: the billing API owns that decision,
363
- and a client-side copy of it would become a second routing authority that
364
- drifts from the merchant's real gateway state.
365
-
366
- ## API Reference
367
-
368
- ## Privacy-safe operational telemetry
369
-
370
- Browser telemetry is enabled by default and is best-effort. It reports only
371
- the closed SDK error/lifecycle/performance taxonomy through a Flo-owned API;
372
- it never bundles Sentry, reads Resource Timing, persists a queue, or transmits
373
- payment/customer/form data, credentials, URLs, provider payloads, arbitrary
374
- messages, or stacks. Existing merchant callbacks remain available with their
375
- existing arguments and invocation order. Callback exceptions and rejected
376
- promises are contained and logged only to the merchant console; they are not
377
- uploaded as SDK telemetry.
122
+ ### Privacy-safe operational telemetry
378
123
 
379
- ```ts
380
- const flopay = await loadFloPay('pk_live_…', {
381
- telemetry: false, // merchant opt-out
382
- });
124
+ Browser telemetry is on by default and best-effort. It reports only the closed SDK error, lifecycle, and performance taxonomy to a FloPay-owned API; it never bundles Sentry, reads Resource Timing, persists a queue, or transmits payment, customer, or form data, credentials, URLs, provider payloads, arbitrary messages, or stacks. Opt out with `telemetry: false` on `loadFloPay`, `PaymentAPI`, `PciVaultCardCapture`, `createCheckoutSession`, and `createCheckoutSessionWithRetries`. Merchant callbacks keep their arguments and invocation order; exceptions they throw are contained and logged only to your console.
383
125
 
384
- const api = new PaymentAPI(billingApiUrl, { telemetry: false });
385
- const vault = new PciVaultCardCapture({ telemetry: false });
386
- await createCheckoutSession({ /* normal checkout fields… */ telemetry: false });
387
- ```
126
+ Genuine technical failures carry one bounded category: `server_error` for billing API 5xx responses, `transport_error` when no usable response arrives, `invalid_response` for malformed responses, and `provider_runtime` for provider SDK or hosted-form runtime failures. Payment declines and request validation failures are reported as non-error `payment_declined` and `validation_rejected` outcomes.
388
127
 
389
- There is no custom telemetry endpoint or enrichment option. See
390
- [`docs/TELEMETRY.md`](../../docs/TELEMETRY.md) for collected categories,
391
- exclusions, unauthenticated beacon transport, failure isolation, retention,
392
- and rollout gates.
393
-
394
- The same default-on behavior and boolean opt-out apply to direct `FloPay`,
395
- `PaymentAPI`, `createCheckoutSession`, `createCheckoutSessionWithRetries`, and
396
- `PciVaultCardCapture` usage.
397
-
398
- Genuine payment failures carry only a bounded technical category:
399
- `server_error` for backend 5xx failures, `transport_error` when no usable
400
- response arrives, `invalid_response` for malformed closed response shapes, and
401
- `provider_runtime` for provider SDK or hosted-widget runtime failures. Hosted
402
- vault widget errors use that category both before and after submission without
403
- including widget messages or session data. Categories are never derived from
404
- messages, response bodies, decline text, stacks, or identifiers. Backend payment
405
- declines and request validation failures use the non-error `payment_declined`
406
- and `validation_rejected` outcomes and carry no category. Uncategorized technical
407
- events remain serializable for backend rollout compatibility.
408
-
409
- Hosts with their own Sentry client can filter recognized third-party-only
410
- browser errors without suppressing app-owned failures:
128
+ When telemetry is enabled, a Stripe loader rejection is reported only with the bounded `provider_runtime` category, while a null Stripe loader result uses the distinct `invalid_response` category. Neither changes the thrown error or the failed-load retry behavior, and neither transmits the publishable key, rejection, provider message, stack, URL, or provider payload.
129
+
130
+ `FloPayCheckout` in `@flopay/react` also classifies failures around the session and provider boundary without exposing raw errors. An existing session-read transport failure uses `transport_error`; malformed JSON uses `invalid_response`.
131
+
132
+ ### Filter third-party errors in your own Sentry client
411
133
 
412
134
  ```ts
413
135
  import * as Sentry from '@sentry/browser';
@@ -416,163 +138,236 @@ import { dropThirdPartyOnlyError } from '@flopay/js';
416
138
  Sentry.init({ beforeSend: dropThirdPartyOnlyError });
417
139
  ```
418
140
 
419
- This is a pure, dependency-free predicate rather than a bundled Sentry
420
- integration. It drops only complete exception stacks with no resolvable app or
421
- `@flopay/*` frame and at least one browser-extension URL or Stripe controller
422
- marker (`dahlia/stripe.js` path segment or `dahlia/stripe` module). Anonymous
423
- frames may accompany those markers. Mixed, unrelated, incomplete, and
424
- anonymous-only errors are returned unchanged. Detection ignores `in_app`,
425
- which can be rewritten to `true` for third-party frames by host tooling.
141
+ This pure predicate drops only complete exception stacks with no app or `@flopay/*` frame and at least one browser-extension URL or Stripe `dahlia/stripe.js` controller frame. Mixed, unrelated, incomplete, and anonymous-only errors are returned unchanged, and Sentry's `in_app` flag is ignored.
426
142
 
427
- ### Exports
143
+ ## Examples
428
144
 
429
- | Export | Description |
430
- |--------|-------------|
431
- | `loadFloPay(publishableKey, options?)` | Initializes the SDK. Returns a `Promise<FloPay>`. Shares successful and in-flight work by key; failed work remains retryable. |
432
- | `FloPay` | Main SDK class. Methods: `cardCapture()`, `retrieveSession()`, `retrieveUnifiedSession()`, `getRawProvider()`, `destroy()` |
433
- | `StripeAdapter` | `PaymentProviderAdapter` implementation for Stripe |
434
- | `PciVaultCardCapture` | `CardCaptureAdapter` implementation that injects the backend-served hosted vault card widget. See [Vault card capture](#vault-card-capture). |
435
- | `PaymentAPI` | Billing API client. Includes session retrieval/creation, vault recovery, `/process`, `createSessionIntent()` (including PayPal payment/setup continuations), `reportSessionIntentDecline()`, completion polling, and saved-payment lookup. Normalized direct-PayPal config preserves the backend's bounded `providerObjectType` and `approvalPresentation`; it never accepts a return URL. |
436
- | `createCheckoutSession(options)` | Creates a checkout session and redirects. Returns `CheckoutSessionResult`. |
437
- | `createCheckoutSessionWithRetries(options)` | Same as above with two retries by default (three total attempts) under one total deadline with jittered exponential backoff. |
438
- | `dropThirdPartyOnlyError(event)` | Host-owned Sentry `beforeSend` predicate. Drops recognized third-party-only exception stacks and otherwise returns the original event. |
439
- | `toStripeAppearance(appearance)` | Normalizes a `FloPayAppearance` into a `StripeSafeAppearance` by mapping FloPay's public `theme` token (`'default' \| 'flat' \| 'night' \| 'none'`) onto Stripe's accepted set (`'stripe' \| 'flat' \| 'night'`). Use at any site that hands an appearance to `@stripe/react-stripe-js`'s `<Elements>` so `theme: 'default'` never reaches Stripe.js. `variables`/`rules` pass through untouched. |
440
- | `toStripeAppearanceTheme(theme)` | Lower-level helper backing `toStripeAppearance`: maps a single FloPay `theme` token to Stripe's. |
145
+ Initialize the SDK and read a session's gateways:
146
+
147
+ ```ts
148
+ import { loadFloPay } from '@flopay/js';
441
149
 
442
- ### FloPay Class Methods
150
+ const flopay = await loadFloPay('pk_test_...', { billingApiUrl: 'https://billing.example.com' });
443
151
 
444
- | Method | Returns | Description |
445
- |--------|---------|-------------|
446
- | `cardCapture(options?)` | `CardCaptureAdapter` | Creates a hosted vault card-widget adapter (`PciVaultCardCapture`). See [Vault card capture](#vault-card-capture). |
447
- | `retrieveSession(sessionId, billingApiUrl?)` | `Promise<CheckoutSession>` | Retrieves a checkout session by ID via `GET /v1/checkouts/sessions/{id}`. Uses `billingApiUrl` from config or the optional second argument. |
448
- | `retrieveUnifiedSession(sessionId, billingApiUrl?)` | `Promise<NormalizedCheckoutSession>` | Retrieves and normalizes a checkout session, including provider-specific data (Stripe `clientSecret`/`publishableKey`, etc.). |
449
- | `getRawProvider()` | `unknown` | Returns the raw underlying provider instance (e.g. Stripe object) |
450
- | `destroy()` | `void` | Tears down telemetry and the provider |
152
+ const session = await flopay.retrieveSession('session_uuid');
153
+ console.log(session.amount, session.currency, session.status);
154
+
155
+ const unified = await flopay.retrieveUnifiedSession('session_uuid');
156
+ if (unified.providers.includes('stripe')) {
157
+ console.log('Stripe publishable key:', unified.data.stripe?.publishableKey);
158
+ }
159
+ ```
451
160
 
452
- ### Vault card capture
453
-
454
- `flopay.cardCapture()` returns a `CardCaptureAdapter` that injects a
455
- **backend-served, self-contained hosted vault widget**
456
- (TeamFloPay/backend#823, Model A). The widget owns the PCI
457
- card fields, its own submit button, card tokenization, the PaymentIntent (created
458
- **and** confirmed server-side), **3DS**, and the result — so **no Stripe.js runs
459
- on the card path** and PAN / CVC never enter the SDK runtime. The SDK's only job
460
- is to inject the widget HTML and relay its terminal `postMessage` outcome.
461
-
462
- For a merchant-server-created no-charge setup session, `@flopay/react` uses
463
- the same adapter with `operation: 'card_setup'`. A valid terminal `complete`
464
- message may include `paymentMethod`, a reconstructed display-only object with
465
- FloPay id, brand, last four, expiry, and status. Unknown or malformed nested
466
- fields are dropped. During a mixed backend rollout the field may be absent; the
467
- result stays bound to the setup session and the SDK never invents an id. Setup
468
- terminal telemetry uses `card_setup_succeeded` / `card_setup_declined`, not
469
- payment-success outcomes.
161
+ Create a hosted checkout session with metadata and a merchant-controlled idempotency key:
470
162
 
471
163
  ```ts
472
- // 1. Obtain the vault capture block — use an embedded block when supplied, or
473
- // fetch it through the explicit recovery route for a session whose Stripe
474
- // enabledPaymentMethods explicitly includes `card`, or that advertises a
475
- // server-side card gateway (`gateways.worldpayCorporateGateway`). The first
476
- // argument is the checkout session id (`getVaultCapture(checkoutSessionId, nonce?)`):
477
- const vault = session.vault ??
478
- await new PaymentAPI(billingApiUrl).getVaultCapture(checkoutSessionId, nonce);
479
- const { html, messageToken, expectedOrigin } = vault;
480
-
481
- // 2. Inject it and relay the widget's outcome.
482
- const capture = flopay.cardCapture({
483
- sessionId: checkoutSessionId,
484
- captureMethod: session.captureMethod,
164
+ import { createCheckoutSessionWithRetries } from '@flopay/js';
165
+ import { FloPayError } from '@flopay/shared';
166
+
167
+ try {
168
+ const result = await createCheckoutSessionWithRetries({
169
+ billingApiUrl: 'https://billing.example.com',
170
+ checkoutBaseUrl: 'https://checkout.example.com',
171
+ clientId: 'client_123',
172
+ currency: 'USD',
173
+ items: [{ providerItemId: 'prod_abc', providerItemName: 'Pro Plan', totalAmount: 49.99 }],
174
+ account: { userId: 'user_1', email: 'user@example.com' },
175
+ successUrl: '/success',
176
+ cancelUrl: '/cancel',
177
+ couponCodes: ['SAVE10'],
178
+ checkoutMetadata: { merchantOrderId: 'order-123', campaign: 'summer' },
179
+ idempotencyKey: 'checkout:order-123',
180
+ });
181
+ console.log('Checkout session created with status', result.status);
182
+ } catch (error) {
183
+ if (error instanceof FloPayError && error.code === 'CouponLimitExceeded') {
184
+ console.warn('Too many coupon codes.');
185
+ } else if (error instanceof FloPayError && error.code === 'CouponCurrencyUnsupported') {
186
+ console.warn('A coupon is not valid for this currency.');
187
+ } else {
188
+ throw error;
189
+ }
190
+ }
191
+ ```
192
+
193
+ Create a detached session, mount the card form from the shell, and identify the buyer later:
194
+
195
+ ```ts
196
+ import { PaymentAPI } from '@flopay/js';
197
+
198
+ const api = new PaymentAPI('https://billing.example.com');
199
+
200
+ const detached = await api.createDetachedSession({
201
+ clientId: 'client_123',
202
+ currency: 'EUR',
203
+ items: [{ providerItemId: 'prod_abc', providerItemName: 'Widget', totalAmount: 29.99 }],
204
+ account: { userId: 'buyer_123' },
205
+ successUrl: '/success',
206
+ cancelUrl: '/cancel',
485
207
  });
208
+
209
+ console.log('Card form can mount from', detached.shell.data.session?.vault);
210
+ const anonymous = await detached.claimed; // catalog, totals, and methods; buyerIdentified is false
211
+ const identified = await detached.claim({ email: 'buyer@example.com', firstName: 'Jane', lastName: 'Doe' });
212
+ console.log(anonymous.data.session?.buyerIdentified, identified.data.session?.buyerIdentified);
213
+ ```
214
+
215
+ Mount the hosted card form and relay its outcome:
216
+
217
+ ```ts
218
+ import { loadFloPay, PaymentAPI } from '@flopay/js';
219
+ import type { PaymentResult } from '@flopay/shared';
220
+
221
+ const billingApiUrl = 'https://billing.example.com';
222
+ const sessionId = 'session_uuid';
223
+ const nonce = 'session_nonce';
224
+
225
+ function onComplete(result: PaymentResult) {
226
+ console.log('Checkout finished:', result.status, result.paymentId);
227
+ }
228
+
229
+ const flopay = await loadFloPay('pk_test_...', { billingApiUrl });
230
+ const session = await flopay.retrieveSession(sessionId);
231
+ const vault = session.vault ?? (await new PaymentAPI(billingApiUrl).getVaultCapture(sessionId, nonce));
232
+
233
+ const capture = flopay.cardCapture({ sessionId, captureMethod: session.captureMethod });
486
234
  capture.on('complete', (e) => {
487
235
  if (e.outcome === 'authorized' || session.captureMethod === 'manual') {
488
236
  onComplete({
489
237
  status: 'authorized',
490
- paymentIntentId: e.intentId,
491
238
  paymentId: e.paymentId,
492
239
  sessionId: e.sessionId,
493
240
  authorizationExpiresAt: e.authorizationExpiresAt,
494
241
  });
495
242
  return;
496
243
  }
497
- onComplete({ status: 'succeeded', paymentIntentId: e.intentId });
244
+ onComplete({ status: 'succeeded', paymentId: e.paymentId, sessionId: e.sessionId });
498
245
  });
499
- capture.on('decline', (e) => onDecline(e.declineReason, e.message));
500
- capture.on('error', (e) => onError(e.message));
501
- // Pass the block's `messageToken` / `expectedOrigin` so the adapter can
502
- // authenticate the widget's terminal postMessage (both optional).
503
- await capture.mount(container, { html, messageToken, expectedOrigin });
246
+ capture.on('decline', (e) => console.warn('Card declined:', e.declineReason));
247
+ capture.on('error', (e) => console.error('Card form failed:', e.message));
248
+
249
+ const container = document.getElementById('card-form');
250
+ if (container && vault.html) {
251
+ await capture.mount(container, {
252
+ html: vault.html,
253
+ messageToken: vault.messageToken,
254
+ expectedOrigin: vault.expectedOrigin,
255
+ submitLabel: 'Pay €29.99',
256
+ });
257
+ }
504
258
  ```
505
259
 
506
- `e.intentId` is the processing gateway's own reference for the attempt. It is a
507
- Stripe PaymentIntent (or SetupIntent) id only when Stripe took the card; when
508
- the session advertises a server-side card gateway such as
509
- `gateways.worldpayCorporateGateway`, it may be an opaque processor order
510
- reference instead. Reconcile by `paymentId` and the session rather than
511
- treating it as a Stripe object. `FloPayCheckout` and `SplitCardForm` omit
512
- `paymentIntentId` from `onComplete` for those sessions.
513
-
514
- `capture.applyTheme(theme)` re-skins the mounted widget without remounting it.
515
- Pass the `VaultCardThemeColors` the React components compute: the flat keys
516
- every hosted asset understands plus the optional `submitButton` snapshot
517
- (billing API v1.9.6+) — an older hosted asset ignores the snapshot and keeps the
518
- solid `primaryColor` approximation, so both sides of a rolling deployment stay
519
- capture-safe. `capture.applyTheme(null)` is the explicit classic reset: the
520
- hosted form restores every historical neutral value, and the adapter retains the
521
- directive so a widget that becomes ready later replays it. Theming is cosmetic
522
- and failure-tolerant — an unsupported member never blocks capture or touches
523
- card data.
524
-
525
- `capture.setSubmitLabel(label)` sets the hosted card form's final submit
526
- wording live (billing API v1.9.35+), for example `'Pay $10'`, without
527
- remounting the widget or clearing entered card data. Pass final,
528
- already-localized plain text; the hosted form normalizes it, renders it as
529
- inert text, and shows its session default — `CONFIRM PAYMENT`, or `ADD CARD`
530
- for card setup — for blank or invalid text. `capture.setSubmitLabel(null)` is
531
- the explicit reset to that default, and a `submitLabel` mount option sets the
532
- initial wording. The adapter retains the latest label and replays it
533
- every time the widget reports an authenticated `ready`, so a late or remounted
534
- form still receives it. Because the directive carries merchant text, it is
535
- posted only when the vault block supplied both `messageToken` and
536
- `expectedOrigin`, bound to the mounted `sessionId`, with the exact
537
- `expectedOrigin` as the `postMessage` target — never `'*'`:
260
+ Create a wallet intent and report a client-observed decline:
538
261
 
539
262
  ```ts
540
- { source: 'flopay-vault-host', type: 'submitLabel', sessionId, messageToken, label }
263
+ import { PaymentAPI } from '@flopay/js';
264
+
265
+ const api = new PaymentAPI('https://billing.example.com', { telemetry: false });
266
+
267
+ const intent = await api.createSessionIntent('session_uuid', 'session_nonce', {
268
+ provider: 'stripe',
269
+ paymentMethodCategory: 'wallet',
270
+ paymentMethodType: 'apple_pay',
271
+ paymentMethodId: null,
272
+ intentKind: 'payment',
273
+ });
274
+ if (intent.provider === 'stripe') {
275
+ console.log('Confirm the wallet payment with', intent.clientSecret);
276
+ }
277
+
278
+ await api.reportSessionIntentDecline('session_uuid', 'session_nonce', {
279
+ provider: 'stripe',
280
+ paymentMethodCategory: 'wallet',
281
+ paymentMethodType: 'apple_pay',
282
+ providerDeclineReason: 'payment_failed',
283
+ });
541
284
  ```
542
285
 
543
- A widget mounted without those fields (an older backend) keeps its default.
544
- The label is never added to telemetry or breadcrumbs. The other host directives
545
- (theme, submit gate, field order, and the 3DS `action_completed` signal) also
546
- target the exact `expectedOrigin` when one was supplied, falling back to `'*'`
547
- only for a widget mounted without it.
548
-
549
- Calling `cardCapture()` emits the separate `vault.capture.requested` lifecycle
550
- signal. The machine-duration `vault_ready` measurement starts when `mount()`
551
- begins and ends at the first validated widget `ready` message, so time before
552
- mount (including buyer time on a payment-method picker) is excluded. Duplicate
553
- `ready` messages do not emit additional readiness samples.
554
-
555
- `CardCaptureAdapter` methods: `mount(container, { html, messageToken?, expectedOrigin?, submitLabel? })`,
556
- `on(event, handler)` (`'ready' | 'complete' | 'decline' | 'error'`), `unmount()`, plus
557
- the optional live controls `applyTheme()`, `setSubmitGate()`, `setCardFieldOrder()`,
558
- and `setSubmitLabel()`.
559
- Outcome events arrive via a `window.postMessage` from the widget shaped
560
- `{ source: 'flopay-vault', type, sessionId, messageToken?, intentId?, declineReason?, message?, paymentMethod? }`.
561
- The widget is injected **same-window**, so `source` alone is forgeable: the
562
- adapter rejects terminal `complete` / `decline` outcomes that are not bound to
563
- the mounted `sessionId`, that mismatch the mounted `messageToken` (when one was
564
- supplied), or that arrive from a non-matching `expectedOrigin` (when set).
565
-
566
- > Backend contract: TeamFloPay/backend#823. Starting with SDK 1.4.9, an explicit
567
- > `card` entry in `gateways.stripe.enabledPaymentMethods` advertises that hosted
568
- > capture can be recovered without an embedded `vault` block. A
569
- > `gateways.worldpayCorporateGateway` entry on an open session advertises the
570
- > same (TeamFloPay/backend#1563).
571
- > `PaymentAPI.getVaultCapture()` coalesces concurrent callers, aborts a stalled
572
- > attempt after ten seconds, and keeps bounded transient-network retry on the
573
- > idempotent `POST /v1/checkouts/sessions/{id}/vault/capture` route.
574
- >
575
- > Rollout boundary: continue embedding capture credentials for older/unknown
576
- > SDKs and until the deployed 1.4.9+ adoption threshold is established. After
577
- > that threshold, requests advertising `x-flo-sdk-version: 1.4.9` or newer may
578
- > omit the block during create/read/idempotent replay when `card` is advertised.
286
+ ## Public API
287
+
288
+ ### Exports
289
+
290
+ | Export | Description |
291
+ |--------|-------------|
292
+ | `loadFloPay(publishableKey, options?)` | Initializes the SDK and resolves a `FloPay`. Shares successful and in-flight work by key and options; failed work stays retryable. |
293
+ | `FloPay` | Main SDK class, created by `loadFloPay()` |
294
+ | `PaymentAPI` | Billing API client: `new PaymentAPI(billingApiUrl, { telemetry })` |
295
+ | `createCheckoutSession(options)` | Creates a checkout session and redirects. Resolves a `CheckoutSessionResult`. |
296
+ | `createCheckoutSessionWithRetries(options)` | Like `createCheckoutSession`, with up to two retries (three attempts) inside one total deadline, using jittered exponential backoff |
297
+ | `PciVaultCardCapture` | `CardCaptureAdapter` implementation that injects the billing API's hosted card form |
298
+ | `CardCaptureOperation` | `'checkout'` / `'card_setup'` |
299
+ | `PciVaultCardCaptureConfig` | Adapter configuration: `sessionId?`, `captureMethod?`, `operation?`, `expectedOrigin?`, `telemetry?` |
300
+ | `StripeAdapter` | `PaymentProviderAdapter` implementation for Stripe |
301
+ | `toStripeAppearance(appearance)` | Converts a `FloPayAppearance` into a `StripeSafeAppearance`, mapping FloPay's `theme` token (`'default'`, `'flat'`, `'night'`, `'none'`) onto Stripe's accepted set. Use it wherever you hand an appearance to Stripe Elements yourself. |
302
+ | `toStripeAppearanceTheme(theme)` | Lower-level helper that maps a single FloPay `theme` token to Stripe's |
303
+ | `StripeSafeAppearance` | An appearance Stripe Elements accepts |
304
+ | `cacheSessionDisplayData(sessionId, data, options?)` | Stashes display-only session data client-side so it survives the redirect to a checkout page |
305
+ | `getSessionDisplayData(sessionId)` | Reads cached display data, or `null` when none is cached or it expired |
306
+ | `clearSessionDisplayData(sessionId)` | Drops cached display data, for example on the success page |
307
+ | `SessionDisplayCacheData` | Display-only data stored by `cacheSessionDisplayData()` |
308
+ | `dropThirdPartyOnlyError(event)` | Sentry `beforeSend` predicate that drops recognized third-party-only exception stacks and otherwise returns the original event |
309
+
310
+ ### FloPay methods
311
+
312
+ | Method | Returns | Description |
313
+ |--------|---------|-------------|
314
+ | `cardCapture(options?)` | `CardCaptureAdapter` | Creates a hosted card-form adapter bound to `sessionId` and `captureMethod` |
315
+ | `retrieveSession(sessionId, billingApiUrl?)` | `Promise<CheckoutSession>` | Reads a session through `GET /v1/checkouts/sessions/{id}` |
316
+ | `retrieveUnifiedSession(sessionId, billingApiUrl?)` | `Promise<NormalizedCheckoutSession>` | Reads and normalizes a session, including gateway data |
317
+ | `getRawProvider()` | `unknown` | The underlying provider instance, such as the Stripe object |
318
+ | `destroy()` | `void` | Tears down telemetry and the provider |
319
+
320
+ ### PaymentAPI methods
321
+
322
+ | Method | Returns | Description |
323
+ |--------|---------|-------------|
324
+ | `getUnifiedCheckoutSession(sessionId, nonce?)` | `Promise<NormalizedCheckoutSession>` | Reads and normalizes a session |
325
+ | `getVaultCapture(sessionId, nonce?)` | `Promise<VaultCaptureBlock>` | Fetches the hosted card form when the session has no embedded `vault` block |
326
+ | `createAndFetchSession(params)` | `Promise<NormalizedCheckoutSession>` | Creates an inline session and resolves once its catalog (and any supplied buyer) is attached |
327
+ | `createDetachedSession(params)` | `Promise<DetachedCheckoutSession>` | Creates an inline session in two phases and exposes both |
328
+ | `claimCheckoutSession(sessionId, nonce, payload)` | `Promise<NormalizedCheckoutSession>` | Sends a `PATCH /v1/checkouts/sessions/{id}/claim` |
329
+ | `createSessionIntent(sessionId, nonce, request, options?)` | `Promise<SessionIntent>` | Creates a Stripe wallet/APM or direct-PayPal intent |
330
+ | `reportSessionIntentDecline(sessionId, nonce, request, options?)` | `Promise<void>` | Reports a non-card decline classification |
331
+ | `processPayment(userId, params, options?)` | `Promise<Response>` | Submits a saved or approved non-card payment to `/process`, polling while the billing API is still processing |
332
+ | `getPaymentsByEmail(email, options?)` | `Promise<{ data, total, page, limit }>` | Lists prior payments for an email |
333
+ | `destroy()` | `void` | Tears down the client's telemetry |
334
+
335
+ ### PciVaultCardCapture methods
336
+
337
+ | Method | Description |
338
+ |--------|-------------|
339
+ | `mount(container, { html, messageToken?, expectedOrigin?, submitLabel? })` | Injects the hosted card form |
340
+ | `on(event, handler)` | Subscribes to `ready`, `complete`, `decline`, or `error` |
341
+ | `unmount()` | Removes the form and its listeners |
342
+ | `applyTheme(theme)` | Re-skins the mounted form; `null` is the classic reset |
343
+ | `setSubmitGate(blocked)` | While `blocked`, the hosted form cancels its next submit and emits `blocked` so the host can validate its own fields (such as AVS) first |
344
+ | `setCardFieldOrder(order, autoFocus)` | Reorders the hosted card rows (`name`, `number`, `expiry`); the first row is focused unless `autoFocus` is false |
345
+ | `setSubmitLabel(label)` | Sets the hosted submit wording live; `null` restores the session default |
346
+
347
+ ## Errors
348
+
349
+ Failures reject with `FloPayError` from `@flopay/shared`. Branch on `type` and `code`, never on `message`. When the billing API returns a structured error body, the SDK keeps its safe `message`, `code`, and HTTP `statusCode`; a body-less response falls back to `code: 'http_<status>'`.
350
+
351
+ | Code | Type | When |
352
+ |------|------|------|
353
+ | `CurrencyRequired` | `validation_error` | Session creation could not resolve a currency from the session, items, subscriptions, or products. Thrown before any request. |
354
+ | `CouponLimitExceeded` | `validation_error` or `api_error` | More than five coupon codes were supplied: a `validation_error` before the request, or an `api_error` from the billing API. |
355
+ | `CouponCurrencyUnsupported` | `api_error` | An amount-based coupon has no price in the cart currency. |
356
+ | `CaptureMethodUnsupportedForSubscription` | `validation_error` | Manual capture was requested for a cart with a subscription. |
357
+ | `InvalidIdempotencyKey` | `validation_error` | A supplied `idempotencyKey` is blank or longer than 255 characters. Thrown before any request. |
358
+ | `IdempotencyKeyInProgress` | `api_error` | The same key is still being processed. Retried automatically with the same key within the deadline. |
359
+ | `MissingCheckoutSessionToken` | `validation_error` or `api_error` | A continuation call was made without the session nonce, or the billing API created a session without returning one. |
360
+ | `BuyerEmailCollectionRequired` | `validation_error` | A buyer email supplied for creation or a late claim is not a valid email address. |
361
+ | `checkout_session_data_attachment_required` | `api_error` | A process, intent, or decline call was made before the detached session's catalog claim settled (HTTP 409). Wait for `claimed`. |
362
+ | `InvalidSessionIntentRequest` | `validation_error` | An intent request is not a supported non-card or PayPal request. |
363
+ | `InvalidAuthorizationAttemptId` | `validation_error` | An intent request's authorization attempt id is not a valid UUID. |
364
+ | `InvalidSessionIntentDeclineRequest` | `validation_error` | A decline report carries anything other than safe classification codes. |
365
+ | `InvalidSessionIntentResponse` | `api_error` | The billing API returned an intent that does not match the request. |
366
+ | `InvalidCheckoutSessionResponse` | `api_error` | The billing API returned a malformed session or no session id. |
367
+ | `session_auto_completed` | `api_error` | Inline creation found a payment method already on file and completed the session without a checkout. |
368
+ | `checkout_processing_timeout` | `api_error` | `processPayment()` stopped polling while the checkout was still processing. Read the session again shortly. |
369
+ | `vault_capture_timeout` | `api_error` | `getVaultCapture()` did not return the hosted card form within ten seconds. |
370
+ | `card_capture_no_window` | `api_error` | `PciVaultCardCapture.mount()` ran outside a browser. |
371
+ | `card_capture_no_widget_html` | `api_error` | `PciVaultCardCapture.mount()` was called without hosted form HTML. |
372
+
373
+ Card declines are not thrown: they arrive as the card form's `decline` event with a `declineReason`, and technical failures as its `error` event. A product or coupon rejected during the detached catalog claim surfaces as the billing API's `422` error from `claimed`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flopay/js",
3
- "version": "1.9.16",
3
+ "version": "1.9.18",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  ],
33
33
  "dependencies": {
34
34
  "@stripe/stripe-js": "^9.8.0",
35
- "@flopay/shared": "1.9.16"
35
+ "@flopay/shared": "1.9.18"
36
36
  },
37
37
  "devDependencies": {
38
38
  "jsdom": "^29.1.1",