Checkout sessions
A hosted checkout that ends in a paid order.
One call turns an order into a branded page with Apple Pay, Google Pay, cards, bank debit, tips, and promo codes. When the buyer pays, you get back a closed order with the receipt, refunds, and reporting already attached. Nothing to assemble afterward.
5
ways to pay on one page
published payment-option catalog
6
session states, every one rendered for the buyer
CheckoutSession.status
12
config sections on the one create call
POST /v1/checkout-sessions
01The page
Every way to pay, under your name.
Cards, Apple Pay, Google Pay, and ACH direct debit, plus saved cards for a buyer you already know and Affirm on eligible purchases. Authentication challenges run on the page. Flint appears once, as Powered by flint.
- ach_debit
- affirm
- apple_pay
- card
- google_pay
Wallets above the fold
Apple Pay and Google Pay render above the card fields, so a buyer on a phone pays without typing a card number.
Bank debit, on the page
ACH direct debit with instant verification and the mandate shown inline. Flint offers it only while it is still available for the final amount and order.
- payments.enabled_payment_options
Saved cards for known buyers
A customer the session already knows picks a saved card instead of retyping one.
- customer_collection.customer_id
Affirm on eligible purchases
Affirm appears when you enable it and the connected account and transaction are eligible. Flint handles the redirect, the return, and the retry.
Authentication handled
The issuer's 3D Secure challenge runs inside the hosted flow. Your application never handles an authentication step.
Tips and codes, priced by the server
Percent presets or fixed amounts, and promotion codes applied to the order before payment, so the total on the button is the total that settles.
- tip.tip_percent_options
- promotion_config.codes_enabled
{
"order_id": "ord_1kmn0aExample",
"payments": { "enabled_payment_options": ["card", "ach_debit"] }
}Omit the payments section and the methods you enabled in the dashboard apply. Name them here to narrow the page for one session.
ACH is asynchronous, and the session says so. A bank payment can still be processing after the buyer is finished, and the session is not paid until it settles. The webhook is your fulfillment signal either way, so nothing in your code changes between a card and a bank account. The ACH guide covers returns and delayed settlement.
02Create
One call sells three things.
Every create names exactly one of three things, and that choice is what the session sells. Payment links and invoices create sessions for you underneath, so every Flint checkout runs on this one lifecycle.
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: checkout-order-gear-001" \
-d '{
"order_id": "ord_1kmn0aExample",
"redirects": {
"success_redirect_url": "https://example.com/thanks",
"cancel_redirect_url": "https://example.com/cart"
}
}'{
"data": {
"checkout_session": {
"checkout_session_id": "cs_1kmn0aExample",
"status": "open",
"origin": "api",
"order_id": "ord_1kmn0aExample",
"expires_at": "2026-09-16T17:04:05Z",
"url": "https://checkout.withflintpay.com/checkout/cs_1kmn0aExample#checkout_token=..."
},
"checkout_access": { "checkout_auth_token": "ckat_1kmn0aExample" }
}
}Send the buyer to data.checkout_session.url exactly as returned. The token fragment is what authenticates them, so do not rebuild the URL from the id.
What the session sells
order_id- Your app created the order and owns its pricing and customer. The default.
quick_pay_item- A name and an amount, no order to build first. Flint creates the order for you.
subscription_plan_id- Hosted signup for a subscription plan. Flint creates the subscription and its first order.
A session created by a payment link carries a payment-link origin and one owned by an invoice carries the invoice id, so the same read, the same webhooks, and the same lifecycle rules cover every checkout Flint runs. Which of the three surfaces to reach for is its own guide.
03The order does the math
The total is the order's, not the page's.
Tax, the buyer's tip, and the promotion code land on the order before payment. The page renders what the API computed, so the amount on the pay button is the amount that settles and the amount on the record.
| ord_1kmn0aExample | at create | code applied | |
|---|---|---|---|
| Subtotalpricing_amounts.subtotal_money | $49.00 | $49.00 | |
| SPRING10, entered on the pagepricing_amounts.discount_money | $0.00 | -$4.90 | |
| Tax, recomputed on the discounted amountpricing_amounts.tax_money | $4.04 | $3.64 | |
| Total duepricing_amounts.total_money | $53.04 | $47.74 | |
What the buyer keeps. Same order, same code, same tax, printed from the record rather than from the page.
{
"data": {
"checkout_session_id": "cs_1kmn0aExample",
"status": "paid",
"terminal_reason": "payment_succeeded",
"payment_intent_ids": ["pi_1kmn0aExample"],
"order": {
"order_id": "ord_1kmn0aExample",
"status": "closed",
"payment_status": "paid",
"pricing_amounts": {
"subtotal_money": { "amount": 4900, "currency": "USD" },
"discount_money": { "amount": 490, "currency": "USD" },
"tax_money": { "amount": 364, "currency": "USD" },
"total_money": { "amount": 4774, "currency": "USD" }
}
}
}
}No record to assemble. The order is closed and paid with computed totals, and refunds, receipts, and reporting are already attached to it.
04Six states
Every state, already rendered.
Only open can be paid. A buyer who opens a link that has ended sees a page that says so, never a payment form for a total that no longer exists. You do not build those pages. The hosted checkout renders all of them.
- the buyer paid
- money settled, a balance remains
- the deadline passed
- you ended it
- the order changed, or a replacement took over
The page is live and the buyer can pay. Every session starts here, and only this state can be paid.
Why it ended, not just that it did
Status tells you a session is over. terminal_reason tells you what happened, which is the field your handler branches on. An expired quote and a superseded link are both terminal and want different follow-ups, and both name themselves.
- payment_succeeded
- payment_partially_succeeded
- expired
- api
- order_mutated
- superseded
- invoice_paid_elsewhere
- invoice_voided
- invoice_uncollectible
05Lifecycle
One open session per order.
Two live pages can never both collect. The API refuses the second create, replacement is atomic, and an order edit invalidates the stale page on its own. None of it is code you write.
{
"order_id": "ord_1kmn0aExample",
"replace_checkout_session_id": "cs_1kmn0aCurrent"
}Atomic. It succeeds only if that id is still the current open session and no payment is resolving; a stale id and an in-flight payment each get their own named error, and the second one is retryable.
{
"data": {
"checkout_session_id": "cs_1kmn0aCurrent",
"status": "invalidated",
"terminal_reason": "superseded",
"superseding_checkout_session_id": "cs_2bqr7dSuccessor"
}
}Terminal, with a pointer to its successor on both the read and the webhook, so anything holding the old id follows the chain forward.
curl -X POST \
https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample/close \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{ "reason_message": "quote withdrawn" }'A second create is refused, not absorbed
An order has at most one open session. Creating another without replacement intent returns 409 CHECKOUT_SESSION_ALREADY_EXISTS, carrying the id of the one already live. A buyer's open page is never torn down because another request arrived.
Editing the order underneath
A merchant-side financial change is not blocked. It invalidates the open checkout, so the old URL cannot collect a total that no longer exists, and you send the buyer a fresh link.
A payment already in flight is the exception: the edit is refused with a retryable error and both the order and the checkout stay as they are, because the alternative is changing the price under someone mid-payment.
Buyer-driven changes on the page, tips, promotion codes, and delivery selection, belong to that checkout and disturb none of this.
Invalidation is a chain, not a dead end
- replace_checkout_session_id
- existing_checkout_session_id
- superseding_checkout_session_id
06Configure
Everything about the page, on one call.
Brand, buyer fields, tips, codes, payment methods, legal, expiry, delivery, and tax are sections of one body. Omit any of them and your dashboard settings apply, so a bare create already renders a complete page.
{
"order_id": "ord_1kmn0aExample",
"theme": { "accent_color": "#2e6b4f", "title": "Cedar & Stone" },
"tip": {
"tip_percent_options": [15, 18, 20],
"default_tip_percent": 18
},
"promotion_config": {
"codes_enabled": true,
"automatic_enabled": true
},
"payments": {
"enabled_payment_options": [
"card",
"apple_pay",
"google_pay",
"ach_debit"
]
},
"customer_collection": {
"require_email": true,
"require_phone": false,
"require_billing_address": true,
"enable_address_autocomplete": true
},
"legal": {
"terms_of_service_url": "https://example.com/terms",
"require_terms_of_service": true,
"refund_policy_url": "https://example.com/refunds"
},
"custom_text": {
"order_summary_message": "Pickup at the Fillmore counter."
},
"expiration": {
"expires_in_seconds": 1800,
"expiration_url": "https://example.com/quote-expired"
},
"tax": { "enabled": true }
}Tax appears once the buyer gives an address, because address-dependent tax cannot be computed before that. Every other section takes effect the moment the page loads.
"theme": { "accent_color": "#2e6b4f" }Brand and copy
Accent and primary color, the page title, and a message on the order summary. The pay button guards contrast, so a brand color that would be unreadable is corrected rather than shipped.
- theme.accent_color
- theme.title
- custom_text
"require_billing_address": trueWhat the buyer must give you
Email, phone, and billing address requirements, address autocomplete, and prefill for a customer you already know. Require only what fulfillment needs and the page gets shorter.
- customer_collection
- prefilled_customer_info
"tip": { "tip_percent_options": [15, 18, 20] }Tips and codes
Percent presets with a default, or fixed amounts, plus a custom field. Promotion-code entry can be turned on for this session or left to the merchant default.
- tip.tip_percent_options
- promotion_config
"expiration": { "expires_in_seconds": 1800 }Legal and deadline
Terms, refund and shipping policy links, an optional hard terms gate, and an expiry window with a landing URL of your own for buyers who arrive late.
- legal
- expiration.expires_in_seconds
Every section of the create body
- theme
- customer_collection
- tip
- promotion_config
- payments
- legal
- custom_text
- expiration
- redirects
- tax
- delivery_method_ids
- metadata
Stripe Checkout has no local pickup setting, and the lookup on Stripe Checkout local pickup covers the workarounds next to the pickup and local delivery methods delivery_method_ids offers.
07Proof
Fulfill on proof, not on a redirect.
Flint appends csId and orderId to your success URL so the landing page knows what to show. The signal that money moved is the webhook, or one read of the session with the order expanded.
# the redirect is for the buyer. this is for you.
curl "https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample?expand=order" \
-H "Authorization: Bearer YOUR_API_KEY"One read returns the session, its terminal reason, and the paid order under it. Fulfillment keys off this or the webhook, never off the buyer reaching your success page.
Buyers close tabs before a redirect fires, and a success URL can be opened directly by anyone who has seen it once. Neither is unusual, and neither can produce a paid order in your system, because your system fulfills on the record.
Events to subscribe to
- checkout_session.completed
- order.paid
- checkout_session.closed
- checkout_session.expired
- checkout_session.invalidated
- subscription.activated
The completed event carries the session, order, and payment ids. The three that end a session without payment carry the reason, and the superseding session id when a replacement took over. Webhooks guide
Moving a Stripe Checkout handler? The lookup on which Stripe webhook event to trust maps each Stripe integration to the event it should fulfill from.
- GET /v1/checkout-sessions/{checkout_session_id}
- expand=order
- terminal_reason
What you stop building
A completed session leaves a paid order with line items, not an amount you rebuild from display data.
expand=orderThe tip and the code the buyer entered are on the order before payment, so the total never drifts from the cart.
pricing_amountsA session that ended tells you why it ended, so your handler branches on a field instead of guessing.
terminal_reasonA replaced session points at its successor, so an old link forwards instead of dying.
superseding_checkout_session_idTwo live pages never both collect, because the API refuses the second one.
CHECKOUT_SESSION_ALREADY_EXISTSEvery ended state has a buyer-facing page already, so a stale link is never a blank screen or a 404.
CheckoutSession.statusreviewed 2026-09-15 against the checkout sessions guide · corrections: Flint Help
08Your storefront
Same session, your own page.
Set surface to embedded and your storefront renders the checkout while Flint stays authoritative for the total, the payment attempt, and the fulfillment signal. The session mints a credential scoped to its own order, so your backend runs the buyer's checkout without your full API key.
{ "order_id": "ord_1kmn0aExample", "surface": "embedded" }Creation returns the same session and checkout auth token and omits the hosted URL. Your backend sends the token as X-Checkout-Session-ID and X-Checkout-Session-Secret; the browser talks to your backend and to Stripe.js, never to Flint directly.
What the checkout credential can do
- Read its own session and order
- Start, resume, and cancel its payment attempt
- Quote and select delivery, or check pickup availability
- Apply and remove promotion codes
- Set the tip and update tax
- List the buyer's saved payment methods
- Resend the receipt
Everything else, including refunds and any other order, needs your merchant key. The credential resolves the order and customer from the session itself, so it cannot be pointed anywhere else.
Build your own checkout walks the integration end to end, and Embedded payments with Stripe Elements covers collection and recovery.
09Start
A working checkout in three calls.
Node
CLI
Create an order, create a session for it, open the returned URL, and pay with 4242 4242 4242 4242 and any future expiry. Then read the session with the order expanded and watch the paid order come back with its totals already computed.
Then read these
- 01
- 02
- 03
- 04
- 05
Skip the wait: npm install -g @flintpay/cli && flint signup creates your account and a sandbox key from the terminal. CLI guide
FAQ
Questions worth asking first.
Is the success redirect proof that the buyer paid?
No. Buyers close tabs before the redirect fires, and a success URL can be opened directly. Flint appends csId and orderId query parameters so your landing page knows what to display. The proof is the checkout_session.completed or order.paid webhook, or a backend read of the session with expand=order.
Can I use a checkout session without building an order first?
Yes. Pass quick_pay_item with a name and an amount and Flint creates the order for you, so refunds, receipts, and reporting still hang off a real record. Pass subscription_plan_id instead for a hosted subscription signup, and Flint creates the subscription and its first order when the buyer completes it.
What happens if I create a second session for the same order?
It is rejected. An order has at most one open session, so a second create without replacement intent returns 409 CHECKOUT_SESSION_ALREADY_EXISTS, and the error carries existing_checkout_session_id. Flint does not invalidate a live buyer URL just because another create request arrived. To regenerate deliberately, pass the current session id as replace_checkout_session_id in the same create call.
Can I edit the order while its checkout is open?
Yes, and the checkout gives way. A merchant-side financial change invalidates the order's open session so the old URL cannot collect a stale total, then you create a fresh session and send the buyer its URL. The one exception is a payment already in flight: that returns a retryable CHECKOUT_PAYMENT_RESOLVING and leaves both the order and the checkout untouched. Buyer-driven changes on the page, like tips and promotion codes, stay part of that checkout.
Which payment methods does the hosted page offer?
Card, Apple Pay, Google Pay, and ACH direct debit, plus saved cards for a customer you already know. Affirm appears when you enable it and the connected account and transaction are eligible. Authentication challenges run on the page, so your app never handles them. ACH settles asynchronously: a session is not paid while its payment is still processing, and the webhook tells you when it is.
Can I run the checkout on my own storefront?
Yes. Create the session with surface set to embedded and your pages render the checkout while Flint stays authoritative for the total, the payment attempt, and the fulfillment signal. The session mints a checkout auth token scoped to its own order, which your backend sends as X-Checkout-Session-ID and X-Checkout-Session-Secret. The browser talks to your backend and to Stripe.js, never to Flint directly.


