Accept a payment
Create a payment session, embed FloPayCheckout, authenticate continuation calls, and handle a complete checkout flow.
Accept a payment
Use this guide to choose how your application creates a checkout session, mounts FloPayCheckout, and continues the payment safely.
Who this is for
This guide is for developers adding an embedded checkout to a web application, whether the browser creates the session inline or a trusted backend creates it first.
Choose an integration approach
Use inline session creation when the browser can supply the approved cart and buyer data directly to FloPay. Create the session on a trusted backend when pricing, entitlements, or other business rules must stay server-side. Both approaches use the same checkout modes, layouts, callbacks, and session-token contract.
Create a payment session
A Full checkout session is created in two phases:
- The shell:
POST /v1/checkouts/sessionswithdeferDataAttachment: true. A lightweight session that runs no catalog validation and takes none of the buyer-identity advisory locks that serialize concurrent checkouts for the same customer. The backend routes a gateway for it, so the response already carriesgatewaysand the hostedvaultblock. - The claim:
PATCH /v1/checkouts/sessions/{id}/claim. Attaches buyer identity, address, products and coupons, validates them against the catalog, and returns the fully-populated session.
Splitting them lets the hosted card form mount from the shell and become interactive while buyer and cart data are still being attached. Stripe.js for wallets, APMs and PayPal loads in parallel with the claim rather than after it.
When you use @flopay/react or @flopay/js, both phases are handled for you: see SDK behavior below. This guide describes the contract for direct HTTP integrators and for anyone who needs to reason about what the SDK is doing.
Mounting is not charging
The shell exists to be mounted, not charged. Every route that can take money still requires attached data:
| Route | Unclaimed session |
|---|---|
POST /v1/checkouts/sessions/:id/process | 409 Conflict |
POST /v1/checkouts/payments/intents | 409 Conflict |
POST /v1/checkouts/payments/setup-intents | 409 Conflict |
POST /v1/checkouts/payments/intents/decline | 409 Conflict |
POST /v1/checkouts/sessions/:id/vault/error | 409 Conflict |
| PCIVault capture webhook | 503 Service Unavailable |
POST /v1/checkouts/sessions/:id/vault/capture | Allowed, provided the shell bound a gateway |
The 409 responses carry a machine-readable code:
{
"code": "checkout_session_data_attachment_required",
"message": "Checkout session buyer and catalog data must be attached before payment or vault operations."
}The capture webhook is the deliberate exception. It answers 503 rather than a 4xx so PCIVault re-delivers on its 8-attempt schedule: a claim landing moments later lets the retried delivery succeed instead of terminally failing the buyer.
Creating the shell
Only clientId and checkoutMode: 'full' are structurally required. currency, products, couponCodes and accountData: normally required on a create: become optional, because they arrive with the claim.
POST /v1/checkouts/sessions
Content-Type: application/json{
"clientId": "18bff186-284c-483f-acee-e712f21d2b8d",
"checkoutMode": "full",
"deferDataAttachment": true,
"currency": "USD",
"accountData": { "country": "US" },
"successUrl": "/success",
"cancelUrl": "/cancel"
}currency and accountData.country are accepted here as gateway-routing inputs only. No buyer is resolved, upserted or linked, and the persisted shell carries no buyer identity: accountData is blanked on the stored session. Supplying them lets the backend route the same gateway it would have routed for the full create, so the vault block on the response is the one the buyer will actually pay through.
The response is 201 Created with the normal session shape, plus:
dataAttachmentDeferred: truenonce: the checkout session token required by the claim and every later callgatewaysfor the bound provider: start loading Stripe.js- a usable
vaultblock: mount the hosted card form immediately
A shell that could not bind any gateway carries no vault block on the create response, and POST /v1/checkouts/sessions/:id/vault/capture rejects it with 400: there is no provider to mint capture credentials against.
deferDataAttachment is supported only for Full checkout sessions. Sending it with checkoutMode of auto or confirm is rejected with a validation error: "deferDataAttachment is supported only for Full checkout sessions".
Claiming the session
Claim the shell before any payment, intent, decline-reporting or processing call. The claim is authenticated with the session nonce.
PATCH /v1/checkouts/sessions/{sessionId}/claim
X-Checkout-Session-Token: {nonce}
Content-Type: application/json{
"currency": "USD",
"products": [{ "code": "plan_a", "quantity": 1 }],
"couponCodes": ["SAVE10"],
"accountData": {
"userId": "buyer-123",
"email": "buyer@example.com",
"country": "US"
}
}The body accepts the cart and buyer fields of the create body: currency, products, couponCodes, accountData, and the deprecated subscriptions / items arrays. Everything else about the session (clientId, checkoutMode, successUrl, cancelUrl, tags) was fixed at create time and is not re-sent.
The attachment is atomic: buyer identity, address, and validated product and coupon snapshots all land together, and the response is 200 OK with the same full session shape a one-shot create returns.
Retries and conflicts
The claim payload is fingerprinted server-side (SHA-256 over a stable stringify), which makes it replay-safe:
| Claim | Result |
|---|---|
| First claim of a deferred shell | 200 OK with the attached session. |
| Identical replay | 200 OK with the same claimed session: safe across transport retries. |
| Materially different payload | 409 Conflict: "Checkout session data has already been attached with a different payload." |
Session created without deferDataAttachment | 409 Conflict: "Checkout session data was attached outside the claim contract." |
| Products or coupons fail catalog validation | 422 Unprocessable Entity: see Product Catalog. |
products, subscriptions, items and couponCodes are treated as sets, not ordered lists, so a cart rebuilt in a different order still replays cleanly. Every other field is compared as sent: retry with a byte-identical body wherever you can.
Catalog validation moves to the claim
Because the shell create skips it, an invalid product or coupon surfaces as a 422 from the claim rather than from the create. The status, the message, and the issues[] shape are unchanged: only the timing moves.
Gateway semantics across the claim
The claim does not re-route by default. If the shell's bound gateway is still usable: loaded, active, and owned by the session's client: the claim keeps it and leaves the vault binding untouched. Re-routing would silently invalidate a widget the buyer may already be typing into.
Only when the shell's binding is no longer usable does the claim re-route. It then clears the stale capture credentials, so the claim response carries a fresh vault block.
Treat a changed vault block on the claim response as a remount signal; an unchanged one means keep the mounted widget.
SDK behavior
@flopay/react and @flopay/js create inline sessions this way by default.
FloPayCheckoutrenders from the shell, then applies the claim when it lands. The card widget's submit stays gated for that window; a click during it is not swallowed: the buyer is asked to press pay again. In practice the claim resolves while the card is still being filled in.PaymentAPI.createDetachedSession()exposes both phases as{ shell, sessionId, nonce, claimed }.PaymentAPI.createAndFetchSession()uses the same flow but awaits the claim, so its resolved value is a single fully-populated session.
Eligibility is decided by isDetachedSessionEligible(): Full mode only, no tokenizedData, and deferDataAttachment: false opts back into the one-shot create.
<FloPayCheckout
createSession={{
clientId: 'your-client-id',
currency: 'EUR',
products: [{ code: 'omni-ai-booster' }],
account: { userId: 'user_123', email: 'customer@example.com' },
successUrl: '/success',
cancelUrl: '/cancel',
deferDataAttachment: false, // one-shot create
}}
/>There is no client-side fallback. The SDK requires a billing API that exposes PATCH /v1/checkouts/sessions/{id}/claim; if the create returns no session shell it raises FloPayError with code InvalidCheckoutSessionResponse.
Telemetry
Each phase is measured on its own:
| Stage | Covers | Log names |
|---|---|---|
session_shell | Create → mountable card form | session.shell.ready |
session_claim | The background attach (own request category) | session.claim.started, session.claim.completed |
session_create | The whole logical create: shell and claim | session.create.started, session.request.completed |
session_create reports the create's attempt count rather than the claim's, so end-to-end dashboards keep measuring the same span.
Related
- Checkout Session Token: the nonce that authenticates the claim and every continuation call.
- FloPayCheckout Guide: the inline component that drives this flow.
- Product Catalog: what the claim validates against.
- Error Handling: how these statuses surface as
FloPayError.
Embed FloPayCheckout
FloPayCheckout is the fastest way to integrate FloPay. One component handles session fetching, Stripe initialization, card fields, wallets, PayPal, 3DS, and checkout modes: no manual wiring needed.
Quick Start
The recommended integration is inline: pass a createSession payload and FloPayCheckout creates the session and renders the form for you: no separate backend route to build, call, and pass a sessionId back to the client.
import { FloPayCheckout } from '@flopay/react';
import { configureFlopay } from '@flopay/shared';
// Call once at app startup (layout.tsx or _app.tsx)
configureFlopay({ environment: 'production' });
function CheckoutPage() {
return (
<FloPayCheckout
createSession={{
clientId: 'your-client-id',
currency: 'EUR',
products: [{ code: 'omni-ai-booster' }],
account: {
userId: 'user_123',
email: 'customer@example.com',
firstName: 'Jane',
lastName: 'Doe',
},
successUrl: '/success',
cancelUrl: '/cancel',
}}
onComplete={(result) => {
if (result.status === 'succeeded') {
window.location.href = '/success';
}
}}
onError={(err) => console.error(err.message)}
/>
);
}That's it. The component automatically:
- Creates the checkout session from your
createSessionpayload (no backend route required) - Reads
gateways.stripe.publishableKeyto initialize card fields and every payment method enabled on the connected Stripe account - Reads
gateways.paypalto decide which PayPal path to render:- When
gateways.paypalis present: loads the PayPal JS SDK directly (see Direct PayPal guide) - When
nullor missing: renders PayPal via Stripe'sExpressCheckoutElementas fallback (see PayPal via Stripe) - PayPal never renders twice
- When
- Uses each gateway entry's
environment('stage'or'production') to pick sandbox vs live credentials per gateway - Renders card fields, the Stripe-enabled payment methods configured on the connected Stripe account (Apple Pay, Google Pay, Cash App, Klarna, Afterpay, iDEAL, Bancontact, etc.), and PayPal
- Handles 3DS authentication
- Calls
onCompletewhen payment succeeds
See Inline Session Creation for the full createSession payload, currency requirements, and email handling.
Already have a session ID?
If you create the session on your backend (for example to keep pricing or entitlement logic server-side), pass the returned sessionId instead of createSession:
<FloPayCheckout sessionId={sessionId} onComplete={handleSuccess} />Every option on this page works the same whether you pass createSession or sessionId. The examples below use sessionId for brevity.
Environment Setup
FloPayCheckout needs to know which billing API to call. Choose one:
Option A: configureFlopay() (recommended)
import { configureFlopay } from '@flopay/shared';
configureFlopay({ environment: 'production' });Call this once at app startup, for example in app/layout.tsx or _app.tsx.
Option B: Explicit prop
<FloPayCheckout
sessionId={sessionId}
billingApiUrl="https://api.flopay.com"
onComplete={handleSuccess}
/>Checkout Modes
Full Mode (default)
Shows the complete payment form: card fields, wallets, PayPal.
<FloPayCheckout sessionId={sessionId} onComplete={handleSuccess} />Confirm Mode
Uses a saved payment method. Shows a single "Confirm Purchase" button. Falls back to full mode if payment fails.
<FloPayCheckout
sessionId={sessionId}
checkoutMode="confirm"
onComplete={handleSuccess}
/>Customize the confirm button:
<FloPayCheckout
sessionId={sessionId}
checkoutMode="confirm"
renderConfirmButton={({ onConfirm, isProcessing }) => (
<button onClick={onConfirm} disabled={isProcessing}>
{isProcessing ? 'Working...' : 'Buy Now'}
</button>
)}
onComplete={handleSuccess}
/>Auto Mode
Automatically submits with a saved payment method: no user interaction. Falls back to full mode if it fails.
<FloPayCheckout
sessionId={sessionId}
checkoutMode="auto"
onComplete={handleSuccess}
onSessionCompleted={(successUrl) => {
// Session was already completed (e.g. page reload after success)
window.location.href = successUrl;
}}
/>Layout Modes
Default Layout
All payment methods visible together: wallets on top, divider, then card fields below.
Buttons Layout
Payment methods shown as stacked buttons. Clicking "Credit / Debit Card" expands into the card form with a back button and title.
<FloPayCheckout
sessionId={sessionId}
layout="buttons"
onComplete={handleSuccess}
/>Theme Bundles
Pick a bundled theme and the SDK styles both the Stripe-rendered fields and the React-rendered wrapper, submit, and inputs:
// Modern light: Inter, soft shadows, generous spacing
<FloPayCheckout sessionId={id} layout="buttons" theme="modern-light" />
// Bold dark: saturated FloPay blue on a dark surface
<FloPayCheckout sessionId={id} layout="buttons" theme="bold-dark" />
// Glass dark: translucent surfaces over a blue gradient
<FloPayCheckout sessionId={id} layout="buttons" theme="glass-dark" />Available theme values: 'classic' | 'modern-light' | 'modern-dark' | 'bold-light' | 'bold-dark' | 'glass-light' | 'glass-dark'. See the Theming guide for the full reference and resolution precedence.
Custom Styles
Override individual elements with buttonsStyles:
<FloPayCheckout
sessionId={sessionId}
layout="buttons"
buttonsStyles={{
cardButton: { borderRadius: '12px', border: '2px solid #4A49FF' },
cardButtonFontSize: '1rem',
cardFormContainer: { backgroundColor: '#f8f7ff' },
cardInputBorder: '#c4c3ff',
cardInputColor: '#1a1a2e',
cardInputPlaceholderColor: '#9ca3af',
submitButton: { backgroundColor: '#2d2ccc', borderRadius: '12px' },
title: { color: '#2d2ccc' },
}}
onComplete={handleSuccess}
/>Custom Card Button Content
Use cardButtonContent to replace the default card button body with your own React content:
<FloPayCheckout
sessionId={sessionId}
layout="buttons"
cardButtonContent={
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
width: '100%',
}}
>
<span style={{ fontWeight: 700 }}>Pay by card</span>
<span style={{ fontSize: '0.75rem', opacity: 0.7 }}>
Visa, Mastercard, Amex
</span>
<span style={{ marginLeft: 'auto', fontSize: '0.7rem' }}>Secure</span>
</div>
}
onComplete={handleSuccess}
/>Use cardButtonContent for the inner content and buttonsStyles.cardButton for the outer button container styles.
Buttons Layout Header Slots
Use cardBackButtonContent and cardTitleContent to replace the default "Go back" and "Secure card checkout" text in the expanded card form header. Pass '' when you want to remove the text completely.
<FloPayCheckout
sessionId={sessionId}
layout="buttons"
cardBackButtonContent=""
cardTitleContent="Enter card details"
onComplete={handleSuccess}
/>cardTitleContent also replaces the standalone title in the default card form layout.
For dark backgrounds, the bundled bold-dark / glass-dark themes already style the Stripe Element text inside the iframe. Override individual fields with cardInputColor, cardInputPlaceholderColor, and cardInputBackground when needed:
<FloPayCheckout
sessionId={sessionId}
layout="buttons"
theme="bold-dark"
buttonsStyles={{
cardInputColor: '#f9fafb',
cardInputPlaceholderColor: '#6b7280',
cardInputBackground: '#1f2937',
nameInput: { backgroundColor: '#1f2937', color: '#f9fafb' },
}}
onComplete={handleSuccess}
/>See the full ButtonsLayoutStyles reference for all available properties.
Inline Session Creation
This is the recommended flow shown in the Quick Start: pass createSession and skip the backend API route entirely. The component creates the session and renders the checkout for you:
<FloPayCheckout
layout="buttons"
createSession={{
clientId: 'your-client-id',
currency: 'EUR',
products: [{ code: 'omni-ai-booster' }],
account: {
userId: 'user_123',
email: 'customer@example.com',
firstName: 'John',
lastName: 'Doe',
},
successUrl: '/success',
cancelUrl: '/cancel',
}}
onComplete={(result) => window.location.href = '/success'}
/>No sessionId needed: the component creates the session, initializes the payment provider, and renders the form automatically.
For embedded checkout, include createSession.account.email in the initial payload. If you only have a temporary value such as test@email.com, pass it first and then replace it in onBeforeButtonClick before the card flow continues.
Session-level currency is required. The SDK throws FloPayError({ type: 'validation_error', code: 'CurrencyRequired' }) synchronously when no currency can be resolved from the session or any per-line currency. Set createSession.currency at the top of the payload for the cleanest behavior.
How the session is created
Creation runs in two phases. The component first creates a lightweight session shell and mounts the hosted card form from it, then applies a background claim that attaches buyer identity, address, products and coupons. The card form is interactive before the claim lands, and Stripe wallets, APMs and PayPal load in parallel with it rather than after it.
Nothing else changes for you: the same session, the same callbacks, the same results. Catalog and coupon errors still surface as a checkout load error: just from the claim rather than the create.
The card submit stays gated until the claim lands, because the billing API rejects a charge against an unclaimed session. A buyer who clicks during that window is asked to press pay again; in practice the claim resolves while the card is still being filled in.
Opt out per session to restore the original single-request create:
<FloPayCheckout
createSession={{
/* … */
deferDataAttachment: false,
}}
/>Two-phase creation applies to checkoutMode: 'full' only, is skipped when tokenizedData is supplied, and requires a billing API that exposes PATCH /v1/checkouts/sessions/{id}/claim: there is no client-side fallback. See the Session Creation guide for the full contract.
Tracking Button Clicks
Fire GTM events when users interact with payment buttons:
<FloPayCheckout
sessionId={sessionId}
layout="buttons"
onButtonClick={(method) => {
window.dataLayer?.push({
event: 'initiate_checkout',
payment_method: method,
});
}}
onComplete={handleSuccess}
/>method values: 'card', 'paypal', 'apple_pay', 'google_pay'.
Before Card Button Click
Use onBeforeButtonClick when you need to do async work before the credit card button continues, such as confirming checkout details or replacing a temporary email address:
<FloPayCheckout
layout="buttons"
createSession={{
clientId: 'your-client-id',
currency: 'EUR',
products: [{ code: 'product-1' }],
account: {
userId: 'user_1',
email: 'test@email.com',
},
successUrl: '/success',
cancelUrl: '/cancel',
}}
onBeforeButtonClick={async ({ method, createSession }) => {
if (method !== 'card') return;
const email = await openEmailCaptureModal({
initialEmail: createSession?.account.email ?? '',
});
if (!email) return false;
return {
account: { email },
};
}}
onComplete={handleSuccess}
/>onBeforeButtonClick is credit card only. It runs only for the "Credit / Debit Card" button in layout="buttons". It does not run for PayPal, Apple Pay, or Google Pay. If you need required data before every payment method, collect it before rendering FloPayCheckout.
Returning false cancels the card click. Throwing routes the error through onError. When createSession is used, the returned patch refreshes the card flow with the merged session draft. createSession.account.email should already be present when the embedded checkout renders; use this hook to update it, not to skip it.
Decline Events
Use onDecline when you want GTM or analytics events for declined payments, failed 3DS, PayPal cancellation, or wallet dismissal:
<FloPayCheckout
sessionId={sessionId}
onDecline={(decline) => {
window.dataLayer?.push({
event: 'checkout_decline',
...decline,
});
}}
onComplete={handleSuccess}
/>Payment Methods
Payment methods are toggled at the gateway level with two props: showStripe and showPayPal. Which individual methods appear inside the Stripe gateway (cards, Apple Pay, Google Pay, Cash App, Klarna, Afterpay, iDEAL, Bancontact, etc.) is controlled from your Stripe dashboard: see Stripe payment methods.
Stripe
Enabled by default. Renders card fields plus every payment method enabled on the connected Stripe account. Apple Pay and Google Pay only paint on supported devices/browsers; Stripe-enabled local methods (Cash App, Klarna, Afterpay, iDEAL, Bancontact, etc.) appear when the buyer's locale and currency match the method's eligibility rules.
// Hide the entire Stripe gateway: cards, wallets, and all Stripe-enabled payment methods
<FloPayCheckout
sessionId={sessionId}
showStripe={false}
onComplete={handleSuccess}
/>Wallet buttons depend on Stripe domain registration. Apple Pay also needs the Apple association file. See the Apple Pay Setup and Google Pay Setup guides.
PayPal
Enabled by default. Routes to the direct PayPal path when the session response includes gateways.paypal, otherwise falls back to PayPal via Stripe. Handles redirects automatically.
// Hide the entire PayPal gateway (both direct and Stripe-rendered paths)
<FloPayCheckout sessionId={sessionId} showPayPal={false} onComplete={handleSuccess} />AVS (Address Verification)
Enable AVS to collect the user's country and postal/ZIP code. When enabled, billing_details are passed to Stripe's createPaymentMethod() so Stripe can run postal code and address verification checks automatically.
Basic Setup
<FloPayCheckout
sessionId={sessionId}
enableAVS
onComplete={handleSuccess}
/>The country dropdown defaults to the session's customer.country value (typically resolved from the user's IP by your backend). If no country is provided, it defaults to US.
Country from GEO/IP Lookup
Pass the user's country in the session creation params. The SDK pre-fills the dropdown:
<FloPayCheckout
createSession={{
clientId: 'your-client-id',
currency: 'EUR',
products: [{ code: 'product-1' }],
account: {
userId: 'user_1',
email: 'user@example.com',
country: 'GB', // resolved from user's IP
},
successUrl: '/success',
cancelUrl: '/cancel',
}}
enableAVS
layout="buttons"
onComplete={handleSuccess}
/>The dropdown shows "United Kingdom" and the postal code label says "Postcode" instead of "ZIP Code".
Dynamic Labels
The postal code field label adapts to the selected country:
| Country | Label |
|---|---|
| US | ZIP Code |
| GB, AU, NZ | Postcode |
| CA | Postal Code |
| IE | Eircode |
| All others | Postal Code |
AVS Field Layout
By default, country and postal code sit side-by-side in a row. Switch to stacked:
<FloPayCheckout
sessionId={sessionId}
enableAVS
avsLayout="column"
onComplete={handleSuccess}
/>Theming AVS Fields
AVS fields inherit from the card input styles. Override individually with countrySelect and zipInput in buttonsStyles:
<FloPayCheckout
sessionId={sessionId}
enableAVS
layout="buttons"
theme="bold-dark"
buttonsStyles={{
countrySelect: { backgroundColor: '#1f2937', color: '#f9fafb' },
zipInput: { backgroundColor: '#1f2937', color: '#f9fafb' },
}}
onComplete={handleSuccess}
/>How It Works
- User selects country and enters postal code in the form
- On submit,
billing_details(name + address with country and postal_code) are passed to Stripe'screatePaymentMethod() - Stripe runs AVS checks automatically when billing details are present
- The
accountData.zipandaccountData.countryare also sent to your backend in the process request - Configure Stripe Dashboard → Radar → Rules to block or allow based on AVS results (e.g., "Block if postal code check fails")
AVS is performed by Stripe at payment confirmation time. The SDK sends the billing details: your Stripe Radar rules determine whether to block, allow, or flag based on the verification result. No backend code changes are needed for basic AVS.
Error Handling
<FloPayCheckout
sessionId={sessionId}
onError={(err) => {
// err.type: 'validation_error' | 'api_error' | 'authentication_error' | ...
// err.message: human-readable error message
console.error(`Payment failed: ${err.message}`);
showToast(err.message);
}}
onComplete={handleSuccess}
/>Suppress the built-in error UI and handle it yourself:
<FloPayCheckout
sessionId={sessionId}
error={() => <></>}
onError={(err) => setMyError(err.message)}
onComplete={handleSuccess}
/>Custom Loading State
<FloPayCheckout
sessionId={sessionId}
loading={<MySkeletonLoader />}
onComplete={handleSuccess}
/>Full Example
See the FloPayCheckout Example for a complete checkout page with order summary, discount timer, and all props configured.
Next Steps
- API Reference: full props table
- Theming: customize card field appearance
- Stripe payment methods: how the dynamic Stripe gateway resolves methods at runtime
- Apple Pay Setup: domain verification
- Google Pay Setup: Stripe payment method domains
- AVS (Address Verification): postal code verification setup
Authenticate continuation calls
Every continuation call against the /v1/checkouts/* API is authenticated with the session nonce issued at session-create time. Clients send the nonce as the x-checkout-session-token request header, and the billing API matches it against checkout_session.nonce for the session id on the URL.
The session-create endpoint (POST /v1/checkouts/sessions) is the only /v1/checkouts/* route that does not require the header: it issues the nonce.
When the SDK creates the session for you (@flopay/js, @flopay/react, or @flopay/node), you do not need to manage the header by hand: it reads CheckoutSessionResponseDto.nonce from the create response and attaches it to every subsequent request automatically.
When you instead hand the SDK a session your backend created: e.g. the sessionId prop on FloPayAutomaticPaymentButton or FloPayCheckout: pass the session's nonce via the matching nonce prop. A session read does not echo the nonce back, so the SDK cannot recover it on its own; omit it and the continuation calls fail with 401.
Capture the nonce
The session-create response returns the nonce as nonce on CheckoutSessionResponseDto. Stash it alongside the session id; you'll need it on every follow-up call for the lifetime of the session.
const res = await fetch(`${billingApiUrl}/v1/checkouts/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
clientId,
currency: 'USD',
products: [{ code: 'pro_plan', totalAmount: 49.99 }],
account: { userId, email },
successUrl: '/success',
cancelUrl: '/cancel',
}),
});
const { id: sessionId, nonce } = await res.json();Echo as x-checkout-session-token
Send the nonce as the x-checkout-session-token header on every continuation call. The header value is the raw nonce string: no Bearer prefix, no encoding.
await fetch(`${billingApiUrl}/v1/checkouts/sessions/${sessionId}/process`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-checkout-session-token': nonce,
'x-user-id': userId,
},
body: JSON.stringify({ tokenizedData, accountData }),
});A missing or mismatched header is rejected with 401 Unauthorized before any business logic runs.
Protected routes
The header is required on every /v1/checkouts/* route below. For routes that include :id, that session id must match the session that issued the nonce.
| Method | Route | Purpose |
|---|---|---|
GET | /v1/checkouts/sessions/:id | Fetch the checkout session. |
GET | /v1/checkouts/sessions/:id/status | Poll the session's processing status. |
GET | /v1/checkouts/sessions/:id/products | List the products attached to the session. Replaces the removed GET /v1/checkouts/items. |
PATCH | /v1/checkouts/sessions/:id/claim | Attach buyer and catalog data to a session shell. See Session Creation. |
PATCH | /v1/checkouts/sessions/:id/account | Update the buyer account on the session. |
POST | /v1/checkouts/sessions/:id/process | Submit a tokenized payment. Moved from POST /v1/checkouts/sessions/process. |
POST | /v1/checkouts/sessions/:id/vault/capture | Re-mint PCIVault capture credentials. |
POST | /v1/checkouts/sessions/:id/vault/error | Report a vault-side failure for the session. Moved from POST /v1/checkouts/sessions/vault/error. |
POST | /v1/checkouts/sessions/:id/3ds/complete | Complete a 3DS challenge for the session. |
POST | /v1/checkouts/payments/intents | Create the PaymentIntent for the session. |
POST | /v1/checkouts/payments/setup-intents | Create the SetupIntent for the session. |
Two routes were renamed in this release. Update any client code that still posts to /v1/checkouts/sessions/process or /v1/checkouts/sessions/vault/error: both now live under /v1/checkouts/sessions/:id/.... GET /v1/checkouts/items has been removed; use GET /v1/checkouts/sessions/:id/products instead.
Lifetime
The nonce is bound to the session row and lives as long as the session does: it does not rotate per request. Persist it next to the session id wherever your client tracks checkout state (component state, sessionStorage, server-side cache) so that downstream calls can read it back.
There is no separate refresh endpoint. If you lose the nonce, the session can no longer be continued and the buyer must restart at session-create.
Errors
| Status | Meaning |
|---|---|
401 | Header missing, or the value does not match checkout_session.nonce for the session id on the URL. |
404 | The session id on the URL does not exist. |
409 | The token was valid, but the session is a shell whose buyer and catalog data has not been attached yet. Carries the code checkout_session_data_attachment_required: claim the session first. See Session Creation. |
In the SDK these surface as FloPayError with type: 'authentication_error' (401) or type: 'api_error' (404 / 409). See the Error Handling guide.
Preserve checkout context with metadata
Attach checkoutMetadata when you create a checkout session to carry non-sensitive merchant context through FloPay's billing lifecycle. A merchant order reference is a good correlation value.
Use metadata when you need to correlate a checkout with your own non-sensitive order or workflow reference across API reads, billing records, and webhook deliveries:
{
"checkoutMetadata": {
"merchantOrderId": "order-123"
}
}The map belongs to the checkout, not to a catalog product or payment provider. For the request type, boundaries, errors, and read response shapes, see the checkout metadata REST reference.
Add metadata with an SDK
The released SDK packages expose CheckoutMetadata from @flopay/shared. Creation types accept CheckoutMetadata | null, while returned session types expose an optional, non-null snapshot.
JavaScript
createCheckoutSession() and createCheckoutSessionWithRetries() accept the map through CreateSessionParams. PaymentAPI accepts the same field for one-shot and detached inline creation.
import { createCheckoutSession, PaymentAPI } from '@flopay/js';
import type {
CheckoutMetadata,
CheckoutSession,
CreateSessionParams,
InlineSessionDraft,
NormalizedCheckoutSession,
} from '@flopay/shared';
const checkoutMetadata: CheckoutMetadata = {
merchantOrderId: 'order-123',
};
const redirectParams: CreateSessionParams = {
billingApiUrl: 'https://api.flopay.com',
checkoutBaseUrl: 'https://checkout.flopay.com',
clientId: '00000000-0000-0000-0000-000000000000',
currency: 'USD',
products: [{ code: 'starter', quantity: 1 }],
account: { userId: 'buyer-123', email: 'buyer@example.com' },
successUrl: 'https://merchant.example/success',
cancelUrl: 'https://merchant.example/checkout',
checkoutMetadata,
};
export async function createRedirectCheckout() {
return createCheckoutSession(redirectParams);
}
const inlineDraft: InlineSessionDraft = {
clientId: redirectParams.clientId,
currency: redirectParams.currency,
products: redirectParams.products,
account: redirectParams.account,
successUrl: redirectParams.successUrl,
cancelUrl: redirectParams.cancelUrl,
checkoutMetadata,
};
export async function createDetachedCheckout() {
const paymentApi = new PaymentAPI('https://api.flopay.com');
const detached = await paymentApi.createDetachedSession(inlineDraft);
const normalized: NormalizedCheckoutSession = await detached.claimed;
const session: CheckoutSession | undefined = normalized.data.session;
return (
normalized.checkoutMetadata?.merchantOrderId ??
session?.checkoutMetadata?.merchantOrderId
);
}Detached creation sends checkoutMetadata on the initial shell POST only. The catalog claim and late-buyer claim do not repeat or edit it. A populated map, null, and {} are forwarded unchanged, while omission leaves the wire field absent.
Node
@flopay/node uses the same CreateSessionParams contract:
import { FloPay } from '@flopay/node';
import type { CheckoutMetadata, CreateSessionParams } from '@flopay/shared';
const checkoutMetadata: CheckoutMetadata = {
merchantOrderId: 'order-123',
};
const params: CreateSessionParams = {
billingApiUrl: 'https://api.flopay.com',
checkoutBaseUrl: 'https://checkout.flopay.com',
clientId: '00000000-0000-0000-0000-000000000000',
currency: 'USD',
products: [{ code: 'starter', quantity: 1 }],
account: { userId: 'buyer-123', email: 'buyer@example.com' },
successUrl: 'https://merchant.example/success',
cancelUrl: 'https://merchant.example/checkout',
checkoutMetadata,
};
export async function createNodeCheckout() {
const flopay = new FloPay('<your-flopay-secret-key>');
return flopay.checkout.sessions.create(params);
}React
FloPayCheckout accepts metadata through its InlineSessionDraft. A just-in-time InlineSessionPatch replaces the whole map before session creation; return null to clear the draft value.
import { FloPayCheckout } from '@flopay/react';
import type { InlineSessionDraft, InlineSessionPatch } from '@flopay/shared';
const createSession: InlineSessionDraft = {
clientId: '00000000-0000-0000-0000-000000000000',
currency: 'USD',
products: [{ code: 'starter', quantity: 1 }],
account: { userId: 'buyer-123', email: 'buyer@example.com' },
successUrl: 'https://merchant.example/success',
cancelUrl: 'https://merchant.example/checkout',
checkoutMetadata: { merchantOrderId: 'order-123' },
};
const metadataPatch: InlineSessionPatch = {
checkoutMetadata: { merchantOrderId: 'order-124' },
};
export function Checkout() {
return (
<FloPayCheckout
createSession={createSession}
onBeforeButtonClick={() => metadataPatch}
/>
);
}The SDK does not duplicate the backend's length or size validation. Invalid metadata remains a backend 400 surfaced as a structured FloPayError.
How immutable snapshots flow
Checkout metadata uses copy-on-write snapshots. Each durable entity owns a separate JSON value rather than a reference to shared live state.
| Record | When its snapshot is taken |
|---|---|
| Checkout session | The request value from POST /v1/checkouts/sessions is fixed when the session is first stored. Omitted or null input produces no returned snapshot. |
| Transaction/payment | The new transaction copies the checkout session snapshot when the payment transaction is created. |
| Subscription | The subscription copies the checkout session snapshot when the subscription is first stored. |
| Invoice | An initial invoice copies its transaction or subscription snapshot when the invoice is first created. A renewal invoice copies the subscription snapshot when that renewal cycle begins. |
| Renewal attempt | Each attempt copies the subscription snapshot when the attempt is reserved, before its provider outcome is known. |
| Rebill result | Each successful or failed rebill result copies the subscription snapshot into a new transaction. |
| Refund and chargeback | Each refund and chargeback record copies the original purchase snapshot when that money-movement record is created. |
Transaction and invoice ledgers use first-write-wins behavior, including a snapshot stored as SQL null. Provider synchronization, retry completion, and reconciliation can enrich other fields on an existing record, but they cannot fill, replace, or remove its checkout snapshot.
Later lifecycle work never rewrites an earlier snapshot. A renewal attempt does not update its parent subscription. A refund does not update its original payment. A chargeback does not update an earlier invoice. Every record continues to describe the context that was attached when that record crossed its own copy boundary.
Why each record owns a copy
Independent snapshots preserve historical meaning. If records shared one mutable map, a later write could make an old payment, invoice, or refund appear to have happened under context that did not exist at the time. Copy-on-write lets API reads, financial reconciliation, and webhook consumers agree about which merchant order a specific record represented.
Create-time only
Checkout metadata is create-time only. Supply it on the initial session creation request. In the detached SDK flow, it is sent on the initial shell POST and is not repeated on the catalog or late-buyer claim.
There is no public post-creation mutation operation. FloPay does not provide metadata search or filter behavior through the public API, and there are no dashboard editing instructions for this feature.
checkoutMetadata is FloPay-local. It is not forwarded to Stripe or PayPal, and it is never merged into provider, catalog, transaction, subscription, or legacy session metadata fields.
Keep metadata non-sensitive
Never put secrets, API tokens, payment credentials, card data, or personal data in checkout metadata.
Treat arbitrary metadata as visible application context. Do not copy it into logs, URLs, or telemetry. Use opaque internal references such as merchantOrderId: "order-123", then retrieve any sensitive business record through your own authorized system.
Complete checkout example
A full checkout page with order summary, discount timer, and FloPayCheckout handling all payment logic.
Complete Page
'use client';
import type { NormalizedCheckoutSession, PaymentResult } from '@flopay/shared';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { buildCheckoutDisplayData, configureFlopay, resolveBillingApiUrl } from '@flopay/shared';
import { FloPayCheckout } from '@flopay/react';
import { PaymentAPI } from '@flopay/js';
import { useSearchParams } from 'next/navigation';
// Configure once at module load
configureFlopay({ environment: 'production' });
function CheckoutContent() {
const searchParams = useSearchParams();
const sessionId = searchParams.get('id') ?? '';
const modeParam = searchParams.get('mode') as 'full' | 'auto' | 'confirm' | null;
const [session, setSession] = useState<NormalizedCheckoutSession | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [countdown, setCountdown] = useState(600);
const api = useMemo(() => new PaymentAPI(resolveBillingApiUrl()), []);
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
(async () => {
try {
const unified = await api.getUnifiedCheckoutSession(sessionId);
if (!cancelled) {
setSession(unified);
if (unified.data.session?.status === 'complete')
window.location.href = '/success';
}
} catch (err) {
if (!cancelled)
setError(err instanceof Error ? err.message : 'Failed to load session');
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => { cancelled = true; };
}, [sessionId, api]);
// Countdown timer
useEffect(() => {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) { clearInterval(timer); return 0; }
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, []);
const handleComplete = useCallback((result: PaymentResult) => {
if (result.status === 'succeeded') window.location.href = '/success';
}, []);
const formatCountdown = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
if (!sessionId) return <p>No session ID. Browse products first.</p>;
if (isLoading) return <p>Loading...</p>;
if (!session?.data.session) return <p>Checkout Error: {error}</p>;
const cs = session.data.session;
const displayData = buildCheckoutDisplayData(cs);
const { items, currency, total, totalSave, discountPercent } = displayData;
const fmt = (n: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(n);
return (
<div style={{
background: 'white', maxWidth: 526, margin: '0 auto',
borderRadius: '16px', boxShadow: '0 0 34px rgba(43, 51, 67, 0.12)',
overflow: 'hidden',
}}>
{/* Title */}
<div style={{ textAlign: 'center', padding: '1rem 1.5rem 0.25rem' }}>
<p style={{ fontSize: '1.5rem', fontWeight: 600 }}>Safe & Secure Checkout</p>
</div>
<div style={{ padding: '0.25rem 1rem 1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{/* Discount timer */}
{countdown > 0 && totalSave > 0 && (
<div style={{
background: '#fef2f2', borderRadius: '8px', padding: '0.5rem',
textAlign: 'center', color: '#991b1b',
}}>
Your Discount Reserved for <strong>{formatCountdown(countdown)}</strong>
</div>
)}
{/* Error */}
{error && (
<div style={{ background: '#FAECE9', borderRadius: '8px', padding: '0.5rem 0.75rem', color: '#C72B23' }}>
Error: {error}
</div>
)}
{/* Order summary */}
<div>
{items.map((item, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0 0.75rem', marginBottom: '0.5rem' }}>
<span>{item.name.toUpperCase()}</span>
<span style={{ color: '#A8A9AD' }}>{fmt(item.originalPrice)}</span>
</div>
))}
{totalSave > 0 && (
<div style={{ background: '#EFF9F0', borderRadius: '8px', padding: '0.25rem 0.5rem', display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<span style={{ color: '#7DAD3A', fontSize: '0.8rem', fontWeight: 500 }}>{discountPercent}% DISCOUNT APPLIED</span>
<span style={{ color: '#7DAD3A' }}>-{fmt(totalSave)}</span>
</div>
)}
<div style={{ borderTop: '1px solid #A8A9AD', margin: '0.5rem 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<p style={{ fontSize: '1.5rem', fontWeight: 600 }}>Total Due Today:</p>
<p style={{ fontSize: '1.5rem', fontWeight: 600 }}>{fmt(total)}</p>
</div>
</div>
{/* FloPayCheckout handles everything */}
<FloPayCheckout
sessionId={sessionId}
checkoutMode={modeParam ?? undefined}
onComplete={handleComplete}
onError={(err) => setError(err.message)}
/>
</div>
</div>
);
}
export default function CheckoutPage() {
return (
<Suspense fallback={<p>Loading...</p>}>
<CheckoutContent />
</Suspense>
);
}With Buttons Layout
<FloPayCheckout
sessionId={sessionId}
layout="buttons"
theme="bold-dark"
onComplete={handleComplete}
onError={(err) => setError(err.message)}
/>With Confirm Mode
<FloPayCheckout
sessionId={sessionId}
checkoutMode="confirm"
renderConfirmButton={({ onConfirm, isProcessing }) => (
<button
onClick={onConfirm}
disabled={isProcessing}
style={{
width: '100%', padding: '1rem',
backgroundColor: '#4A49FF', color: 'white',
border: 'none', borderRadius: '8px',
fontWeight: 600, cursor: isProcessing ? 'not-allowed' : 'pointer',
}}
>
{isProcessing ? 'Processing...' : 'Confirm Purchase'}
</button>
)}
onComplete={handleComplete}
/>Key Points
configureFlopay()is called once at module load: not per renderbuildCheckoutDisplayData()formats session data for display (handles discounts, trial naming, item hiding)FloPayCheckoutautomatically readsemail,userId,amount, andcurrencyfrom the session- Error suppression: pass
error={() => <></>}to handle errors entirely viaonError - The component handles 3DS, PayPal redirects, and wallet payments internally