@flopay/js
v1.6.0
Published
Browser-side FloPay SDK. Provides `loadFloPay()` to initialize the SDK, a `FloPay` class for managing payment elements and confirmations, `PaymentAPI` for billing API calls, and `createCheckoutSession` for server-initiated checkout flows.
Readme
@flopay/js
Browser-side FloPay SDK. Provides loadFloPay() to initialize the SDK, a FloPay class for managing payment elements and confirmations, PaymentAPI for billing API calls, and createCheckoutSession for server-initiated checkout flows.
Currently backed by Stripe via the StripeAdapter. The adapter pattern (PaymentProviderAdapter interface) allows swapping providers without changing consumer code.
Installation
pnpm add @flopay/js@stripe/stripe-js ships as a direct dependency of @flopay/js — consumers do not need to install it separately.
Quick Start
Initialize the SDK
import { loadFloPay } from '@flopay/js';
const flopay = await loadFloPay('pk_test_...', {
billingApiUrl: 'https://billing.example.com', // optional, enables retrieveSession/retrieveUnifiedSession
});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. The optional second argument accepts
Omit<FloPayConfig, 'publishableKey'>, including billingApiUrl, locale,
appearance, and telemetry; set telemetry: false to opt out of privacy-safe
operational telemetry.
Create and Mount Non-card Elements
const elements = flopay.elements({
amount: 2999, // in cents
currency: 'usd',
paymentMethodTypes: ['cashapp', 'ideal'],
});
const paymentElement = await elements.create('payment');
paymentElement.mount(document.getElementById('payment-container')!);paymentMethodTypes is required for payment elements and must contain at
least one wallet/APM method. Any card entry is removed; a missing or empty
non-card allowlist throws FloPayError('validation_error'). When Elements are
initialized with a clientSecret, the provider intent is verified against the
explicit non-card allowlist before mounting; the allowlist remains required and
any extra provider method is rejected. Use
@flopay/react's FloPayCheckout or SplitCardForm for card checkout; both
mount the backend-hosted vault widget supplied in the session's vault block.
PayPal Payment
const result = await flopay.confirmPayPalPayment({
billingApiUrl: 'https://billing.example.com',
sessionId: 'session_uuid',
nonce: sessionNonce,
email: '[email protected]',
returnUrl: window.location.href,
});
// After redirect return, resume the payment:
const resumed = await flopay.resumePayPalPayment();
if (resumed) {
console.log('PayPal payment status:', resumed.status);
}Direct-PayPal checkout surfaces use PaymentAPI.createSessionIntent() instead
of confirmPayPalPayment(). The payment-method type is the channel selector:
legacy paymentMethodType: 'paypal' (always intentKind: 'payment') returns
an Order or provider-managed Subscription exactly as released, while
paymentMethodType: 'paypal_vaulted' opts the request into the Flo-owned
channel — intentKind: 'payment' returns a Flo-owned Order and
intentKind: 'setup' returns a PayPal vault setup token. There is no
capability header; sessions advertise availability via
gateways.paypal.providerObjectType. See
Flo-owned PayPal subscription continuations.
Retrieve a Session
// Using billingApiUrl from config (set at init time):
const session = await flopay.retrieveSession('session_uuid');
console.log(session.amount, session.currency, session.status);
// Or pass billingApiUrl explicitly:
const session2 = await flopay.retrieveSession('session_uuid', 'https://billing.example.com');
// For provider-specific data (Stripe clientSecret, publishableKey, etc.):
const unified = await flopay.retrieveUnifiedSession('session_uuid');
console.log(unified.data.stripe?.clientSecret);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>".
PaymentAPI (Billing API Client)
import { PaymentAPI } from '@flopay/js';
const api = new PaymentAPI('https://billing.example.com');
// Fetch and normalize a checkout session. `nonce` is the session-bound
// checkout token returned from session creation — required by post-#640
// backends, which match it against `checkout_session.nonce` before returning
// the row.
const session = await api.getUnifiedCheckoutSession('session_uuid', sessionNonce);
// session.provider === 'stripe' | 'chargebee' | 'recurly'
// session.data.session contains the normalized CheckoutSession
// session.data.session.clientSecret carries the same nonce so downstream
// code can re-use it.
// Create an Apple Pay intent. Direct-card requests are not part of this union.
const intent = await api.createSessionIntent(
'session_uuid',
sessionNonce,
{
provider: 'stripe',
paymentMethodCategory: 'wallet',
paymentMethodType: 'apple_pay',
paymentMethodId: 'pm_xxx',
intentKind: 'payment',
},
);
// Report a client-observed non-card decline using a provider classification
// code only. Messages, identifiers, card data, credentials, and PII are rejected.
await api.reportSessionIntentDecline('session_uuid', sessionNonce, {
provider: 'stripe',
paymentMethodCategory: 'wallet',
paymentMethodType: 'apple_pay',
providerDeclineReason: 'payment_failed',
});
// Process a tokenized payment. `nonce` is required: the SDK throws a
// `FloPayError` with code `MissingCheckoutSessionToken` when it is missing,
// and otherwise POSTs to `/v1/checkouts/sessions/<id>/process` with the
// header set.
const processResponse = await api.processPayment('user_id', {
sessionId: 'session_uuid',
nonce: sessionNonce,
tokenizedData: { id: 'pm_xxx', type: 'card' },
accountData: { userId: 'user_id', email: '[email protected]', firstName: 'John', lastName: 'Doe' },
});
// If the backend returns `202 checkout_processing`, the SDK keeps polling the
// checkout session until it completes or times out, then resolves/rejects.
// Check for prior payments (saved card UX)
const payments = await api.getPaymentsByEmail('[email protected]');createCheckoutSession
import { createCheckoutSession } from '@flopay/js';
const result = await createCheckoutSession({
billingApiUrl: 'https://billing.example.com',
checkoutBaseUrl: 'https://checkout.example.com',
clientId: 'client_123',
items: [{
providerItemId: 'prod_abc',
providerItemName: 'Pro Plan',
totalAmount: 49.99,
overrideAmount: 24.99,
}],
account: { userId: 'user_1', email: '[email protected]' },
successUrl: '/success',
cancelUrl: '/cancel',
redirectParams: { email: '[email protected]', bg: 'courses', mode: 'confirm' },
});
// On 201: browser redirects to checkout page. The resolved
// `CheckoutSessionResult` is `{ status: 201, redirectUrl, nonce }` — `nonce` is
// the session-bound checkout token returned by the billing API. A matching
// `flopay_checkout_token` cookie is written to the same parent domain as
// `checkout_data`, so a hosted-checkout page can read the token from
// `document.cookie` and forward it on continuation calls without having to
// pull it from the URL (kept out of history/Referer).
// On 204: browser redirects to successUrl (payment method on file)For an item-only card checkout that should place a hold instead of capturing
immediately, add captureMethod: 'manual'. The same option is supported by
createCheckoutSessionWithRetries, PaymentAPI.createAndFetchSession, and
PaymentAPI.createDetachedSession. Omitting it keeps the request and result
behaviour unchanged.
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; this browser package contains no
merchant credential or capture capability. See the repository's
pre-authorisation guide.
Use createCheckoutSessionWithRetries for automatic retry with jittered
exponential backoff on transient transport, timeout, and idempotency-in-progress
errors. By default it makes at most two retries (three total attempts). A single
12-second total deadline (customizable with timeoutMs) covers the initial
request, every retry, and all backoff; all attempts receive the same
AbortSignal.
Idempotent checkout creation
When a secure RNG is available, every checkout-session create sends a stable
Idempotency-Key header so a timeout or lost response cannot mint a second
session. The behavior is fully additive — existing integrations need no changes:
- Automatic (default): the SDK generates one cryptographically random,
high-entropy key per logical
createCheckoutSessioncall and reuses it for every transport retry of that same operation. Two independent calls get different keys, even when their request bodies are identical. The generated key is never derived from customer data and is never logged or returned. Automatic generation requires a secure RNG (crypto.randomUUID/crypto.getRandomValues); in an environment without one the SDK omits the header rather than emit a weak key — supply a valididempotencyKeyyourself to stay idempotent there. - Merchant-supplied: pass
idempotencyKeyto control the key yourself. It is sent unchanged and reused across retries, so you can keep it stable across React remounts, multiple SDK instances, or server retries you control. It identifies exactly one logical checkout creation and must not be reused for a new purchase. It must be non-empty and at most 255 characters (an invalid value throws aFloPayError('validation_error')before any request).
// Merchant-controlled key — reused for this one logical checkout only.
await createCheckoutSession({ /* … */ idempotencyKey: `checkout:${orderId}` });createCheckoutSessionWithRetries resolves the key once, before its retry loop,
so all attempts — timeouts, lost responses, and the backend's documented
in-progress reply — replay the same key. A payload-conflict (409) is surfaced
without retrying. The header is optional on the backend: older SDKs and direct
API clients that omit it keep the legacy, unkeyed path. The same automatic and
merchant-supplied behavior applies to inline session creation via
PaymentAPI.createAndFetchSession (used by @flopay/react). Inline creation
uses the same 12-second total deadline and jitter policy, with one shared
three-attempt budget across network failures and idempotency-in-progress
responses. Pass timeoutMs in the inline session params to choose a different
UX ceiling.
@flopay/shared also exports the primitives directly if you build your own
create flow: generateIdempotencyKey(), resolveIdempotencyKey(supplied?),
IDEMPOTENCY_KEY_HEADER, and MAX_IDEMPOTENCY_KEY_LENGTH.
Detached session creation (default)
Inline session creation is detached by default (TeamFloPay/backend#1099). Instead of one request that resolves buyer identity and the catalog inline, the SDK issues two:
POST /v1/checkouts/sessionswithdeferDataAttachment: true— a lightweight shell. It runs no catalog validation and takes none of the buyer-identity advisory locks that serialise concurrent checkouts for the same customer. The backend routes a gateway for it fromcurrencyand the buyer'scountry, so the response already carriesgatewaysand the hostedvaultblock: the card form can mount from it.PATCH /v1/checkouts/sessions/{id}/claim— attaches buyer identity, address, products and coupons in the background, and returns the fully populated session.
PaymentAPI.createDetachedSession(params) exposes both phases:
const { shell, sessionId, nonce, claimed } = await api.createDetachedSession(params);
mountCardForm(shell.data.session?.vault); // interactive immediately
const session = await claimed; // cart, coupons, totals, buyerNothing may be charged before claimed settles: the billing API rejects
process / intent / decline calls on an unclaimed session with
409 checkout_session_data_attachment_required, and holds an unclaimed vault
charge with a retryable 503. @flopay/react gates the card widget's submit
button for exactly this window.
createAndFetchSession uses the same two-phase flow but awaits the claim, so
its resolved value is unchanged — existing callers get the reduced create
contention with no code change. Detached creation requires
checkoutMode: 'full', is skipped when tokenizedData is supplied, and there
is no fallback to the one-shot create: it needs a billing API with the claim
endpoint. Pass deferDataAttachment: false to force the one-shot create.
Because catalog validation moves to the claim, an invalid product or coupon
surfaces as the same 422 — later in the flow, but with identical semantics.
The claim body is fingerprinted server-side, so the SDK replays a byte-identical
payload across transport retries; a materially different claim for an
already-claimed session returns 409 and is surfaced without retrying.
Coupon validation errors are surfaced as FloPayError with structured code:
CouponLimitExceeded— more than 5 coupon codes supplied (also enforced client-side before the request leaves the browser).CouponCurrencyUnsupported— an amount-based coupon has no price for the cart currency.
import { FloPayError } from '@flopay/shared';
try {
await createCheckoutSession({ /* … */ couponCodes });
} catch (err) {
if (err instanceof FloPayError) {
if (err.code === 'CouponLimitExceeded') {
// show "Too many coupons" toast
} else if (err.code === 'CouponCurrencyUnsupported') {
// show "Coupon is not valid for this currency" toast
}
}
}API Reference
Privacy-safe operational telemetry
Browser telemetry is enabled by default and is best-effort. It reports only the closed SDK error/lifecycle/performance taxonomy through a Flo-owned API; it never bundles Sentry, reads Resource Timing, persists a queue, or transmits payment/customer/form data, credentials, URLs, provider payloads, arbitrary messages, or stacks. Existing merchant callbacks remain available with their existing arguments and invocation order. Callback exceptions and rejected promises are contained and logged only to the merchant console; they are not uploaded as SDK telemetry.
const flopay = await loadFloPay('pk_live_…', {
telemetry: false, // merchant opt-out
});
const api = new PaymentAPI(billingApiUrl, { telemetry: false });
const vault = new PciVaultCardCapture({ telemetry: false });
await createCheckoutSession({ /* normal checkout fields… */ telemetry: false });There is no custom telemetry endpoint or enrichment option. See
docs/TELEMETRY.md for collected categories,
exclusions, unauthenticated beacon transport, failure isolation, retention,
and rollout gates.
The same default-on behavior and boolean opt-out apply to direct FloPay,
PaymentAPI, createCheckoutSession, createCheckoutSessionWithRetries, and
PciVaultCardCapture usage.
Genuine payment failures carry only a bounded technical category:
server_error for backend 5xx failures, transport_error when no usable
response arrives, invalid_response for malformed closed response shapes, and
provider_runtime for provider SDK or hosted-widget runtime failures. Hosted
vault widget errors use that category both before and after submission without
including widget messages or session data. Categories are never derived from
messages, response bodies, decline text, stacks, or identifiers. Backend payment
declines and request validation failures use the non-error payment_declined
and validation_rejected outcomes and carry no category. Uncategorized technical
events remain serializable for backend rollout compatibility.
Hosts with their own Sentry client can filter recognized third-party-only browser errors without suppressing app-owned failures:
import * as Sentry from '@sentry/browser';
import { dropThirdPartyOnlyError } from '@flopay/js';
Sentry.init({ beforeSend: dropThirdPartyOnlyError });This is a pure, dependency-free predicate rather than a bundled Sentry
integration. It drops only complete exception stacks with no resolvable app or
@flopay/* frame and at least one browser-extension URL or Stripe controller
marker (dahlia/stripe.js path segment or dahlia/stripe module). Anonymous
frames may accompany those markers. Mixed, unrelated, incomplete, and
anonymous-only errors are returned unchanged. Detection ignores in_app,
which can be rewritten to true for third-party frames by host tooling.
Exports
| Export | Description |
|--------|-------------|
| loadFloPay(publishableKey, options?) | Initializes the SDK. Returns a Promise<FloPay>. Shares successful and in-flight work by key; failed work remains retryable. |
| FloPay | Main SDK class. Methods: elements(), submitElements(), cardCapture(), confirmPayment(), confirmPayPalPayment(), resumePayPalPayment(), retrieveSession(), retrieveUnifiedSession(), getRawProvider(), destroy() |
| FloPayElements | Element group manager. Methods: create(type, options?), getElement(type), submit(), destroy() |
| StripeAdapter | PaymentProviderAdapter implementation for Stripe |
| PciVaultCardCapture | CardCaptureAdapter implementation that injects the backend-served hosted vault card widget. See Vault card capture. |
| PaymentAPI | Billing API client. Includes session retrieval/creation, vault recovery, /process, createSessionIntent() (including PayPal payment/setup continuations), reportSessionIntentDecline(), completion polling, and saved-payment lookup. |
| createCheckoutSession(options) | Creates a checkout session and redirects. Returns CheckoutSessionResult. |
| createCheckoutSessionWithRetries(options) | Same as above with two retries by default (three total attempts) under one total deadline with jittered exponential backoff. |
| dropThirdPartyOnlyError(event) | Host-owned Sentry beforeSend predicate. Drops recognized third-party-only exception stacks and otherwise returns the original event. |
| 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 (which would warn and silently fall back). variables/rules pass through untouched. StripeAdapter applies this normalization internally. |
| toStripeAppearanceTheme(theme) | Lower-level helper backing toStripeAppearance: maps a single FloPay theme token to Stripe's. |
FloPay Class Methods
| Method | Returns | Description |
|--------|---------|-------------|
| elements(options?) | FloPayElements | Creates a new elements group. Destroys previous group. |
| submitElements() | Promise<{ error? }> | Validates all mounted elements |
| cardCapture(options?) | CardCaptureAdapter | Creates a hosted vault card-widget adapter (PciVaultCardCapture). See Vault card capture. |
| confirmPayment(params) | Promise<PaymentResult> | Confirms a mounted wallet/APM using clientSecret, paymentMethodCategory, and paymentMethodType; rejects card. |
| confirmPayPalPayment(params) | Promise<PayPalPaymentResult> | Stripe-hosted PayPal flow through the nonce-protected session intent contract, followed by confirmation/redirect. |
| resumePayPalPayment() | Promise<PayPalPaymentResult \| null> | Resumes after PayPal redirect. Returns null if no PayPal params in URL. |
| 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. |
| retrieveUnifiedSession(sessionId, billingApiUrl?) | Promise<NormalizedCheckoutSession> | Retrieves and normalizes a checkout session, including provider-specific data (Stripe clientSecret/publishableKey, etc.). |
| getRawProvider() | unknown | Returns the raw underlying provider instance (e.g. Stripe object) |
| destroy() | void | Tears down elements and provider |
Supported Element Types
payment-- Provider PaymentElement for supported non-card methodsaddress-- Address input element
Vault card capture
flopay.cardCapture() returns a CardCaptureAdapter that injects a
backend-served, self-contained hosted vault widget
(TeamFloPay/backend#823, Model A). The widget owns the PCI
card fields, its own submit button, card tokenization, the PaymentIntent (created
and confirmed server-side), 3DS, and the result — so no Stripe.js runs
on the card path and PAN / CVC never enter the SDK runtime. The SDK's only job
is to inject the widget HTML and relay its terminal postMessage outcome.
For a merchant-server-created no-charge setup session, @flopay/react uses
the same adapter with operation: 'card_setup'. A valid terminal complete
message may include paymentMethod, a reconstructed display-only object with
FloPay id, brand, last four, expiry, and status. Unknown or malformed nested
fields are dropped. During a mixed backend rollout the field may be absent; the
result stays bound to the setup session and the SDK never invents an id. Setup
terminal telemetry uses card_setup_succeeded / card_setup_declined, not
payment-success outcomes.
// 1. Obtain the vault capture block — use an embedded block when supplied, or
// fetch it through the explicit recovery route for a session whose Stripe
// enabledPaymentMethods explicitly includes `card`. The first
// argument is the checkout session id (`getVaultCapture(checkoutSessionId, nonce?)`):
const vault = session.vault ??
await new PaymentAPI(billingApiUrl).getVaultCapture(checkoutSessionId, nonce);
const { html, messageToken, expectedOrigin } = vault;
// 2. Inject it and relay the widget's outcome.
const capture = flopay.cardCapture({
sessionId: checkoutSessionId,
captureMethod: session.captureMethod,
});
capture.on('complete', (e) => {
if (e.outcome === 'authorized' || session.captureMethod === 'manual') {
onComplete({
status: 'authorized',
paymentIntentId: e.intentId,
paymentId: e.paymentId,
sessionId: e.sessionId,
authorizationExpiresAt: e.authorizationExpiresAt,
});
return;
}
onComplete({ status: 'succeeded', paymentIntentId: e.intentId });
});
capture.on('decline', (e) => onDecline(e.declineReason, e.message));
capture.on('error', (e) => onError(e.message));
// Pass the block's `messageToken` / `expectedOrigin` so the adapter can
// authenticate the widget's terminal postMessage (both optional).
await capture.mount(container, { html, messageToken, expectedOrigin });Calling cardCapture() emits the separate vault.capture.requested lifecycle
signal. The machine-duration vault_ready measurement starts when mount()
begins and ends at the first validated widget ready message, so time before
mount (including buyer time on a payment-method picker) is excluded. Duplicate
ready messages do not emit additional readiness samples.
CardCaptureAdapter methods: mount(container, { html, messageToken?, expectedOrigin? }),
on(event, handler) ('ready' | 'complete' | 'decline' | 'error'), unmount().
Outcome events arrive via a window.postMessage from the widget shaped
{ source: 'flopay-vault', type, sessionId, messageToken?, intentId?, declineReason?, message?, paymentMethod? }.
The widget is injected same-window, so source alone is forgeable: the
adapter rejects terminal complete / decline outcomes that are not bound to
the mounted sessionId, that mismatch the mounted messageToken (when one was
supplied), or that arrive from a non-matching expectedOrigin (when set).
Backend contract: TeamFloPay/backend#823. Starting with SDK 1.4.9, an explicit
cardentry ingateways.stripe.enabledPaymentMethodsadvertises that hosted capture can be recovered without an embeddedvaultblock.PaymentAPI.getVaultCapture()coalesces concurrent callers, aborts a stalled attempt after ten seconds, and keeps bounded transient-network retry on the idempotentPOST /v1/checkouts/sessions/{id}/vault/captureroute.Rollout boundary: continue embedding capture credentials for older/unknown SDKs and until the deployed 1.4.9+ adoption threshold is established. After that threshold, requests advertising
x-flo-sdk-version: 1.4.9or newer may omit the block during create/read/idempotent replay whencardis advertised.
