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.

This guide is the spine of a headless integration. It links out rather than repeating: Embedded Payments with Stripe Elements owns the collection mechanics, Handling Declines & Payment Attempts owns recovery, and Checkout Sessions owns 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#

text
Browser                 Your backend              Flint            Stripe.js
   |  add to cart            |                      |                  |
   |------------------------>| create/update order  |                  |
   |                         |--------------------->|                  |
   |  buyer-safe state       |  order + collection  |                  |
   |<------------------------|<---------------------|                  |
   |  mount Elements                                |                  |
   |----------------------------------------------------------------->|
   |  ctoken_...                                    |                  |
   |<-----------------------------------------------------------------|
   |  submit                 |                      |                  |
   |------------------------>| PayOrder             |                  |
   |                         |--------------------->|                  |
   |  attempt state          |  order + attempt     |                  |
   |<------------------------|<---------------------|                  |

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.

The important rule, because it surprises people: a payment request does not persist a shipping address onto an order. A shipping address becomes Flint fulfillment data only through a checkout delivery selection. See Add Shipping to Checkout.

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 '{
    "payment_source": {
      "confirmation_token": "ctoken_1kmn0aExample"
    },
    "expected_outstanding_money": {"amount": 6400, "currency": "USD"},
    "completion_behavior": "complete_order",
    "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 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.

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