FloPayFloPay
Guides

Session Creation

How a checkout session is created in two phases — a lightweight shell that mounts the card form, then a claim that attaches buyer and catalog data.

Session Creation

A Full checkout session is created in two phases:

  1. The shellPOST /v1/checkouts/sessions with deferDataAttachment: true. A lightweight session that 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, so the response already carries gateways and the hosted vault block.
  2. The claimPATCH /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 behaviour 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 behaviour

@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.

On this page