Documentation
Guides

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:

  1. The shell: POST /v1/checkouts/sessions with deferDataAttachment: 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 carries gateways and the hosted vault block.
  2. 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:

RouteUnclaimed session
POST /v1/checkouts/sessions/:id/process409 Conflict
POST /v1/checkouts/payments/intents409 Conflict
POST /v1/checkouts/payments/setup-intents409 Conflict
POST /v1/checkouts/payments/intents/decline409 Conflict
POST /v1/checkouts/sessions/:id/vault/error409 Conflict
PCIVault capture webhook503 Service Unavailable
POST /v1/checkouts/sessions/:id/vault/captureAllowed, 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: true
  • nonce: the checkout session token required by the claim and every later call
  • gateways for the bound provider: start loading Stripe.js
  • a usable vault block: 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:

ClaimResult
First claim of a deferred shell200 OK with the attached session.
Identical replay200 OK with the same claimed session: safe across transport retries.
Materially different payload409 Conflict: "Checkout session data has already been attached with a different payload."
Session created without deferDataAttachment409 Conflict: "Checkout session data was attached outside the claim contract."
Products or coupons fail catalog validation422 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.

  • FloPayCheckout renders 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:

StageCoversLog names
session_shellCreate → mountable card formsession.shell.ready
session_claimThe background attach (own request category)session.claim.started, session.claim.completed
session_createThe whole logical create: shell and claimsession.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.

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:

  1. Creates the checkout session from your createSession payload (no backend route required)
  2. Reads gateways.stripe.publishableKey to initialize card fields and every payment method enabled on the connected Stripe account
  3. Reads gateways.paypal to decide which PayPal path to render:
    • When gateways.paypal is present: loads the PayPal JS SDK directly (see Direct PayPal guide)
    • When null or missing: renders PayPal via Stripe's ExpressCheckoutElement as fallback (see PayPal via Stripe)
    • PayPal never renders twice
  4. Uses each gateway entry's environment ('stage' or 'production') to pick sandbox vs live credentials per gateway
  5. 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
  6. Handles 3DS authentication
  7. Calls onComplete when 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:

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:

CountryLabel
USZIP Code
GB, AU, NZPostcode
CAPostal Code
IEEircode
All othersPostal 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

  1. User selects country and enters postal code in the form
  2. On submit, billing_details (name + address with country and postal_code) are passed to Stripe's createPaymentMethod()
  3. Stripe runs AVS checks automatically when billing details are present
  4. The accountData.zip and accountData.country are also sent to your backend in the process request
  5. 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

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.

MethodRoutePurpose
GET/v1/checkouts/sessions/:idFetch the checkout session.
GET/v1/checkouts/sessions/:id/statusPoll the session's processing status.
GET/v1/checkouts/sessions/:id/productsList the products attached to the session. Replaces the removed GET /v1/checkouts/items.
PATCH/v1/checkouts/sessions/:id/claimAttach buyer and catalog data to a session shell. See Session Creation.
PATCH/v1/checkouts/sessions/:id/accountUpdate the buyer account on the session.
POST/v1/checkouts/sessions/:id/processSubmit a tokenized payment. Moved from POST /v1/checkouts/sessions/process.
POST/v1/checkouts/sessions/:id/vault/captureRe-mint PCIVault capture credentials.
POST/v1/checkouts/sessions/:id/vault/errorReport a vault-side failure for the session. Moved from POST /v1/checkouts/sessions/vault/error.
POST/v1/checkouts/sessions/:id/3ds/completeComplete a 3DS challenge for the session.
POST/v1/checkouts/payments/intentsCreate the PaymentIntent for the session.
POST/v1/checkouts/payments/setup-intentsCreate 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

StatusMeaning
401Header missing, or the value does not match checkout_session.nonce for the session id on the URL.
404The session id on the URL does not exist.
409The 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.

RecordWhen its snapshot is taken
Checkout sessionThe request value from POST /v1/checkouts/sessions is fixed when the session is first stored. Omitted or null input produces no returned snapshot.
Transaction/paymentThe new transaction copies the checkout session snapshot when the payment transaction is created.
SubscriptionThe subscription copies the checkout session snapshot when the subscription is first stored.
InvoiceAn 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 attemptEach attempt copies the subscription snapshot when the attempt is reserved, before its provider outcome is known.
Rebill resultEach successful or failed rebill result copies the subscription snapshot into a new transaction.
Refund and chargebackEach 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 render
  • buildCheckoutDisplayData() formats session data for display (handles discounts, trial naming, item hiding)
  • FloPayCheckout automatically reads email, userId, amount, and currency from the session
  • Error suppression: pass error={() => <></>} to handle errors entirely via onError
  • The component handles 3DS, PayPal redirects, and wallet payments internally

On this page