Build your own checkout

You render every screen. Stripe Elements collects the card. Flint stays authoritative for the order total, the payment attempt, settlement, and the fulfillment signal.

Use Embedded payments with Stripe Elements for payment collection, Handling declines & payment attempts for recovery, and Checkout sessions for session lifecycle.

A backend-for-frontend is required, not recommended. Flint does not register merchant browser origins, and there is no publishable Flint key. Production CORS permits Flint-owned origins only, so your storefront reaches Flint through your own server. The browser talks to Stripe.js directly and to your backend. It never talks to Flint.

What you own, and what Flint owns#

ConcernOwner
Page, layout, fields, navigation, receipt UIYou
Buyer authentication, browser session, CSRF, CSP, origin checksYou
Card and wallet data collectionStripe Elements, on Stripe's domain
Order total, tax, discounts, outstanding balanceFlint
Payment confirmation, authentication state, attempt state machineFlint
Settlement and the fulfillment signalFlint

Card details never pass through your servers or Flint's.

Before you start#

  • Confirm GET /v1/capabilities?capability=accept_card_payments reports ready. Handle merchant_payments_disabled, capability_pending, and requirements_due by explaining the problem. Do not render an empty payment form.
  • Serve checkout over HTTPS and register every checkout hostname with POST /v1/payment-method-domains before expecting Apple Pay or Google Pay. Domain registration is a Stripe wallet requirement; it does not grant your origin access to Flint.
  • Register a webhook endpoint, verify signatures against the raw body, and deduplicate on webhook-id.
  • Use a least-privilege backend key: commerce.orders.read and commerce.orders.write for order checkout, plus checkouts.checkout_sessions.read and checkouts.checkout_sessions.write if you manage sessions with merchant auth. Provision wallet domains with a separate administrative key.

This walkthrough covers card and wallet payments. Affirm and ACH direct debit have additional requirements and are not part of the merchant-owned Orders API path yet.

The shape of the integration#

Headless checkout
response
Headless checkoutBrowserYour backendFlintStripe.jsadd to cartcreate or update the orderorder and collection detailsbuyer-safe statemount Elementsctoken_...submitPayOrderorder and attemptattempt state
  1. Browser sends add to cart to Your backend
  2. Your backend sends create or update the order to Flint
  3. Flint returns order and collection details to Your backend
  4. Your backend returns buyer-safe state to Browser
  5. Browser sends mount Elements to Stripe.js
  6. Stripe.js returns ctoken_... to Browser
  7. Browser sends submit to Your backend
  8. Your backend sends PayOrder to Flint
  9. Flint returns order and attempt to Your backend
  10. Your backend returns attempt state to Browser

1. Build the cart on your backend#

The browser sends product IDs, variants, and quantities. Your backend resolves catalog records and prices, then creates the order.

Never trust a unit price, tax amount, delivery charge, discount, or total supplied by the browser. Flint computes the payable amount; your job is to render it, not to calculate it.

2. Apply every price-changing choice before payment#

Promotions, tips, tax, delivery, and modifiers all move the total. Apply them first, then re-read the order and render the returned amounts. Treat the order response as authoritative after every mutation.

3. Associate the customer before collection#

For a signed-in buyer, set customer_id on the order before you create the session, so saved payment methods resolve. For a guest, pass buyer_email on PayOrder.

4. Decide whether you need a checkout session#

A card-only backend checkout does not require one. You can read the order's payment_collection, collect a ConfirmationToken, and call PayOrder with your API key.

Create an embedded session when you need checkout-scoped buyer authority, Flint delivery selection, checkout expiration, or redirect return routing.

Bash
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: create-embedded-checkout-cart-123" \
  -H "Content-Type: application/json" \
  -d '{
    "surface": "embedded",
    "order_id": "ord_1kmn0aExample",
    "payments": {
      "enabled_payment_options": ["card", "apple_pay", "google_pay"]
    },
    "customer_collection": {
      "require_email": true
    },
    "expiration": {
      "expires_in_seconds": 1800
    }
  }'

The response returns checkout_access.checkout_auth_token and omits checkout_access.hosted_url, because there is no Flint-hosted page to send anyone to.

Exactly one open session can own an order. Reuse the original idempotency key after a lost create response. To replace the current session deliberately, send its ID as replace_checkout_session_id.

5. Store the credential, relay the calls#

Checkout-scoped requests authenticate with two headers:

HTTP
X-Checkout-Session-ID: cs_1kmn0aExample
X-Checkout-Session-Secret: ckat_1kmn0aExample

Keep both on your backend, associated with your own signed browser session. The browser calls your endpoints, like /checkout/{cart_id}/quote-delivery and /checkout/{cart_id}/pay, and your backend derives Flint IDs from its own checkout record rather than accepting arbitrary IDs from the browser.

Send exactly one authentication mode per Flint request: either the two checkout headers, or Authorization, never both. Combining them returns 400 AMBIGUOUS_AUTH. Enforce that choice centrally in your relay instead of letting each handler assemble headers.

Rate limit your own bootstrap, pricing, delivery, and payment routes, and add CSRF protection and origin checks to cookie-authenticated endpoints. Flint applies its own limits, but your backend must not become an unbounded relay.

Keep keys, checkout credentials, client secrets, and ConfirmationTokens out of logs, analytics, URLs, error messages, and persistent browser storage. Securing a headless checkout covers the full boundary, including what changes for PCI once the payment page runs your own JavaScript.

6. Collect delivery#

Quote, then select. A delivery selection changes tax and the outstanding balance, so re-read the order afterward and re-render the total.

A payment request does not persist a shipping address onto an order. In checkout, the selected delivery option writes the order's delivery_destination. For an order that does not use Flint's delivery selection flow, set delivery_destination when you create or update the open, unpaid order. See Add shipping to a checkout.

Quote the buyer's address#

Buyer-side requests use the checkout ID and secret headers. Send null to assert that no selection exists yet.

HTTP
POST /v1/checkout-sessions/cs_01K1P6G4M7H2N8Q9R3S5T6V7WX/delivery-quotes
X-Checkout-Session-ID: cs_01K1P6G4M7H2N8Q9R3S5T6V7WX
X-Checkout-Session-Secret: CHECKOUT_SECRET
Idempotency-Key: quote-order-42-address-1
Content-Type: application/json

{
  "expected_delivery_selection_id": null,
  "destination_address": {
    "line1": "120 Kent Avenue",
    "city": "Brooklyn",
    "state": "NY",
    "postal_code": "11249",
    "country": "US"
  }
}
JSON
{
  "data": {
    "audience": "buyer",
    "delivery_quote_id": "dquote_01K1P6G4M7H2N8Q9R3S5T6V7WX",
    "status": "open",
    "evaluation_status": "complete",
    "choice_groups": [{
      "delivery_choice_group_id": "dcgrp_01K1P6G4M7H2N8Q9R3S5T6V7WX",
      "availability_status": "available",
      "options": [{
        "delivery_option_id": "dopt_01K1P6G4M7H2N8Q9R3S5T6V7WX",
        "type": "shipment",
        "name": "Standard shipping",
        "amount_money": {"amount": 900, "currency": "USD"}
      }]
    }],
    "buyer_reasons": [],
    "expires_at": "2026-08-03T16:00:00Z"
  },
  "request_id": "req_01K1P6G4M7H2N8Q9R3S5T6V7WX"
}

Quotes expire. expires_at is on every quote, and selecting against a stale one fails rather than charging an outdated rate. When that happens the session reports it in problems[], and each entry carries a remediation naming what to do, which is normally to re-quote. Surface that instead of a generic error: the buyer's address is still valid, only the price is stale.

Select the option#

Use IDs from the quote. Do not reconstruct them from method data.

The selected option decides which recipient details are required. A shipment normally needs a recipient name, and it may require an email or phone for carrier notifications. Those requirements come back on the option, so read them rather than assuming; a selection missing a required field is rejected.

HTTP
POST /v1/checkout-sessions/cs_01K1P6G4M7H2N8Q9R3S5T6V7WX/delivery-selections
X-Checkout-Session-ID: cs_01K1P6G4M7H2N8Q9R3S5T6V7WX
X-Checkout-Session-Secret: CHECKOUT_SECRET
Idempotency-Key: select-order-42-shipping
Content-Type: application/json

{
  "delivery_quote_id": "dquote_01K1P6G4M7H2N8Q9R3S5T6V7WX",
  "expected_delivery_selection_id": null,
  "recipient": {"name": "Morgan Lee", "email": "morgan@example.com", "phone": "+12125550123"},
  "choices": [{
    "delivery_choice_group_id": "dcgrp_01K1P6G4M7H2N8Q9R3S5T6V7WX",
    "delivery_option_id": "dopt_01K1P6G4M7H2N8Q9R3S5T6V7WX"
  }]
}
JSON
{
  "data": {
    "audience": "buyer",
    "delivery_selection_id": "dsel_01K1P6G4M7H2N8Q9R3S5T6V7WX",
    "delivery_quote_id": "dquote_01K1P6G4M7H2N8Q9R3S5T6V7WX",
    "status": "selected",
    "choices": [{
      "delivery_choice_group_id": "dcgrp_01K1P6G4M7H2N8Q9R3S5T6V7WX",
      "delivery_option_id": "dopt_01K1P6G4M7H2N8Q9R3S5T6V7WX",
      "name": "Standard shipping",
      "total_money": {"amount": 900, "currency": "USD"}
    }]
  },
  "request_id": "req_01K1P6G4M7H2N8Q9R3S5T6V7WX"
}

Checkout can now collect payment. Flint commits the selection to the order atomically with successful payment processing.

Re-read the order#

A delivery selection changes what the buyer owes. The shipping charge is added, and because shipping is taxable in many jurisdictions, the tax total can move too.

Read the order after selecting and render the amounts it returns. Do not add the shipping price to a total you already had. If you are collecting payment yourself, the outstanding balance from this read is also what belongs in expected_outstanding_money on PayOrder, so a stale total is caught before the card is charged rather than after.

7. Read fresh collection guidance#

Re-read the order immediately before you mount or update Elements. The payment_collection block is the authoritative description of what to collect and how, and it changes when the total changes.

8. Prefer one-shot collection#

A normal full-balance, automatic-capture checkout does not need a pre-created payment leg. Send a one-shot credential in payment_source and Flint creates the leg and starts the attempt atomically.

Pre-create legs only for split tender, partial payment, manual capture, an explicitly staged amount, or selection among existing legs. Legs freeze amounts and add stale-state handling, so they are not the default storefront path.

9. Mount Elements and create the credential#

Under checkout authentication, payment_collection.stripe.elements.next_step is create_confirmation_token. Dispatch on the returned value rather than assuming it.

The full Elements setup, the elements.submit() sequence, and the stripe.createConfirmationToken call are covered in Embedded payments with Stripe Elements.

The resulting ctoken_... is single-use and bound to one leg. Never store it as retry authority or resend it when continuing an existing attempt.

10. Start the payment#

Re-read the order, then use that response's outstanding amount as the concurrency fence.

Bash
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: pay-cart-123-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "pay",
    "payment_source": {
      "confirmation_token": "ctoken_1kmn0aExample"
    },
    "expected_outstanding_money": {"amount": 6400, "currency": "USD"},
    "buyer_email": "buyer@example.com"
  }'

When an embedded checkout credential owns the operation, send the checkout headers instead of the API key.

expected_outstanding_money stops Flint charging when the live order no longer matches what the buyer approved. On ORDER_CHANGED_REFRESH_REQUIRED, refresh checkout state and ask the buyer to approve the new total. Do not silently resubmit.

The browser must not call stripe.confirmPayment for an order-owned payment intent. PayOrder owns confirmation so that one authoritative attempt state machine exists.

11. Drive the attempt, not the HTTP status#

Interpret every payment response through payment_attempt, not through a 200. Run any returned pending_actions[].client_action, then resume with action: "resume" and payment_attempt_id.

Handling declines & payment attempts has the full status table, the decline set, and the lost-response recovery procedure using active_payment_attempt.

Two cases that only matter to a headless integration:

  • Partial success. A partially_succeeded attempt means some legs settled. Preserve them, show the per-leg errors, and start a new attempt for the remaining balance only. Do not retry the original amount.
  • Credential expiry mid-payment. If an embedded session passes its expiry while an attempt is still recoverable, the credential keeps working for exactly three routes: read the order, read that specific attempt, and resume it. Anything else needs merchant authentication.

12. Handle replacement#

A merchant-side financial mutation can invalidate the open session. When that happens, take superseding_checkout_session_id from the mutation result, the checkout_session.invalidated webhook, or a merchant-authenticated read, then bootstrap the browser with the new credential.

The browser must never depend on an invalidated credential to discover its replacement.

13. Fulfill and receipt#

Fulfill from a verified, deduplicated order.paid webhook or an authoritative backend read, never from the browser reporting success. checkout_session.completed is also available when a session owns collection.

You render your own receipt. To send another copy of Flint's receipt to the order's recipient, POST /v1/orders/{order_id}/receipt works under the checkout credential, and keeps working while the session is paid or partially_paid.

Refunds, disputes, and support workflows use merchant authentication. Checkout credentials are scoped to their own session and order.

Start a subscription#

For a known customer and an existing plan, create an embedded checkout with plan_id, collect a payment credential, and submit it to the checkout's order. Flint saves the payment method and creates the subscription as part of completing that order.

Create the checkout on your backend:

Bash
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: signup-customer-123" \
  -H "Content-Type: application/json" \
  -d '{
    "surface": "embedded",
    "plan_id": "plan_1kmn0aExample",
    "customer_collection": {"customer_id": "cus_1kmn0aExample"},
    "payments": {"enabled_payment_options": ["card"]}
  }'

Use data.checkout_session.order_id and relay the checkout credential. Read the session and order with checkout authentication before collection. The order's settlement_amounts.outstanding_money determines which path to use:

Amount due nowCollection guidanceOrder pay request
Greater than zeropayment_collectionaction: "pay" with payment_source
Zero, including a trial without an initial chargesetup_collectionaction: "setup" with setup_payment_source

Use the chosen collection block's Stripe account and Elements options to create the credential. A trial with a setup fee or another initial charge uses the paid path. Follow the returned collection guidance rather than inferring the amount from the plan price.

For an initial charge, use the one-shot payment request. Zero-balance collection returns next_step: "collect_setup_payment_source" and Elements mode: "setup". After elements.submit() succeeds, call stripe.createPaymentMethod({elements}) and send the returned payment method ID in setup_payment_source.token:

Bash
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: signup-customer-123-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "setup",
    "setup_payment_source": {"token": "pm_1kmn0aExample"},
    "expected_outstanding_money": {"amount": 0, "currency": "USD"}
  }'

Use the currency and outstanding amount from the fresh order read. Browser requests through your backend relay use checkout authentication. Flint owns confirmation for both paths. Run any returned client action and resume the same attempt with action: "resume" and payment_attempt_id, without resending the credential.

After the attempt succeeds, read the order's subscription_id and the subscription. Verify the customer binding, subscription status (active or trialing), and active saved payment method. Grant access from subscription webhooks or an authoritative backend read. Do not call POST /v1/subscriptions after this signup; the completed order already created it.

Test before you ship#

At minimum: immediate card success, 3D Secure success and cancellation, insufficient funds, duplicate submit, network timeout recovery, an order total that changes before payment, wallet available and unavailable, checkout expiration and replacement, session invalidation after an order mutation, recovery of an in-progress attempt after the credential expires, and webhook retry with duplicate delivery.

See Testing for the card matrix.

Rate this doc