Checkout
You will see all six of these in production.
A hosted page is easy to demo and hard to trust, because the demo only ever shows you the one state where everything worked. A Flint checkout session is a collection pass over a real order, and it has six states. Here they all are.
- open
- paid
- partially_paid
- expired
- closed
- invalidated
only open can be paid · the other five are terminal
01The state machine
Six states. Five are terminal.
A buyer who opens a session that has ended sees a page that says so, not a payment form and not an error. That is the whole reason a stale link cannot collect a stale price.
The hosted page is live and the buyer can pay. Every session is created here.
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 should branch on. An expired quote and a superseded link are both terminal and want completely different follow-ups.
- payment_succeeded
- payment_partially_succeeded
- expired
- api
- order_mutated
- superseded
- invoice_paid_elsewhere
- invoice_voided
- invoice_uncollectible
02Create
One call, and one decision.
Every create names exactly one of four things, and that choice is what the session sells.
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: checkout-order-tote-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",
"surface": "hosted",
"order_id": "ord_1kmn0aExample",
"expires_at": "2026-07-04T17:04:05Z"
},
"checkout_access": {
"checkout_auth_token": "ckat_1kmn0aExample",
"hosted_url": "https://checkout.withflintpay.com/checkout/cs_1kmn0aExample#checkout_token=..."
}
}
}Send the buyer to data.checkout_access.hosted_url exactly as returned. The token fragment is what authenticates them, so do not rebuild the URL.
What the session sells
order_id- Your app already created the order and owns its pricing and customer context. The default.
quick_pay_item- You want to charge a name and an amount without building an order first.
plan_id- You want hosted signup for a subscription plan.
payment_intent_id- You already have an orderless PaymentIntent and want Flint to collect for it.
Payment links and invoices create sessions for you behind the scenes, which is why a link-generated session carries a payment-link origin and an invoice-owned one carries an invoice id. Which of the three surfaces to reach for is its own guide, and that guide owns the decision.
03Configure
What you get to change about the page.
Eight config objects on the session, each one driving something the buyer actually sees.
{
"order_id": "ord_1kmn0aExample",
"theme": { "accent_color": "#e85d10", "title": "Lucia's" },
"tip": { "tip_percentages": [15, 18, 20], "default_tip_percentage": 18 },
"promotion_config": { "codes_enabled": true, "automatic_enabled": true },
"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 }
}Omit any of these and the merchant's dashboard defaults apply. Tax appears once the buyer gives an address, because address-dependent tax cannot be computed before that.
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
What the buyer must give you
Email, phone, and billing address requirements, address autocomplete, and prefill for a customer you already know.
- customer_collection
- prefilled_customer_info
Tips and codes
Percent presets or fixed smart-tip amounts, and promotion-code entry that can be turned on for this session or left to the merchant default.
- tip.tip_percentages
- promotion_config
Legal and deadline
Terms, refund and shipping policy links, an optional hard terms gate, and an expiry window with its own landing page for buyers who arrive late.
- legal
- expiration.expires_in_seconds
- theme.accent_color
- tip.tip_percentages
- promotion_config.codes_enabled
- customer_collection.require_email
- legal.require_terms_of_service
- expiration.expires_in_seconds
04Lifecycle
One open session per order.
The rules here are stricter than they look, and they are stricter on purpose: two live pages must never both be able to collect.
409 CHECKOUT_SESSION_ALREADY_EXISTS, carrying the id of the one already live. Flint does not tear down a buyer's open page just because another request arrived.{
"order_id": "ord_1kmn0aExample",
"replace_checkout_session_id": "cs_1kmn0aCurrent"
}Atomic, and 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 can follow the chain forward.
curl -X POST \
https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample/close \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{ "reason": "quote withdrawn" }'Editing the order underneath
A merchant-side financial change does not get blocked. It invalidates the open checkout, so the old URL cannot collect a total that no longer exists. You then create a fresh session and send the buyer its link.
The exception is a payment already in flight. That is refused with a retryable error and leaves the order and the checkout both untouched, because the alternative is changing the price under someone who is mid-payment.
Buyer-driven changes on the page, tips and promotion codes and fulfillment selection, are part of that checkout and do not disturb any of this.
Invalidation is a chain, not a dead end
- replace_checkout_session_id
- existing_checkout_session_id
- superseding_checkout_session_id
05Collection
What the buyer can actually pay with.
Every method the hosted page can charge, plus a saved card. The asynchronous one changes what paid means.
- ach_debit
- affirm
- apple_pay
- card
- google_pay
{
"order_id": "ord_1kmn0aExample",
"payments": {
"enabled_payment_options": ["card", "ach_debit"]
}
}Flint renders the bank flow only while ACH is still available for the final amount and order. It collects billing name and email, runs instant verification, and presents the mandate on the page.
Saved cards appear for a customer the session already knows, so a returning buyer picks a card instead of retyping one.
Affirm is available for eligible purchases. Hosted checkout shows it when the merchant enables it and the connected account and transaction are eligible, then handles the redirect, return, and retry flow.
3D Secure runs on the page. The issuer's challenge happens inside the hosted flow, so your application never handles an authentication step.
06Completion
The redirect is not proof.
Treating it as proof is the most common bug in hosted checkout integrations.
# the redirect is not proof. read the session, or wait for the webhook.
curl "https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample?expand=order" \
-H "Authorization: Bearer YOUR_API_KEY"Flint appends csId and orderId to your success URL so the landing page knows what to show. That is for display. Fulfillment keys off the webhook or this read.
Buyers close tabs before a redirect fires, and a success URL can be opened directly by anyone who has seen it once. Neither of those is unusual, and both produce a buyer who looks paid and is not.
Events worth subscribing to
- checkout_session.completed
- checkout_session.closed
- checkout_session.expired
- checkout_session.invalidated
- order.paid
- subscription.activated
- GET /v1/checkout-sessions/{checkout_session_id}
- expand=order
- terminal_reason
What a completed session leaves behind
{
"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": {
"total_money": { "amount": 6390, "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.
07Limits
What this does not do.
This page is hosted, and only hosted. If you want the card fields inside your own UI, that is a different integration and it has its own guide. Nothing here is an iframe you can embed.
Affirm is conditional. It is omitted unless the merchant enables it and the connected account and transaction are eligible.
One open session per order, enforced. If your flow assumed you could fire create repeatedly and let the newest win, it will meet a 409. Replacement is available and it is deliberate.
Flint is not a payment processor. Stripe processes every card, and Flint provisions and operates the processing account. You cannot attach a Stripe account you already have, and Flint inherits Stripe underwriting: if Stripe declined your business, Flint cannot approve it.
Against a reconcile-after hosted checkout
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 land on that order before payment, so the total never drifts from the cart.
pricing_amountsA session that ended tells you why it ended, not just that it did.
terminal_reasonA replaced session points at its successor, so an old link is a redirect rather than a dead end.
superseding_checkout_session_idTwo live pages can never both collect, because the API refuses the second one.
CHECKOUT_SESSION_ALREADY_EXISTSReviewed 2026-07-24 against the published API.
08Start
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 close a session and open its URL again to see the terminal page for yourself, which is the part worth checking before you ship.
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, but verification comes from the checkout_session.completed or order.paid webhook, or a backend read of the session with expand=order.
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 the merchant enables 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 merely processing.
Can I run buyer operations without my full API key?
Yes, with a scoped credential your backend holds. A checkout session mints a checkout auth token, sent as X-Checkout-Session-ID and X-Checkout-Session-Secret, that is limited to its own session and order: delivery, promotions, tip, tax, saved-method listing, starting and resuming payment, and resending the receipt. Merchant operations like create, close, and list still require your key. The checkout credential is a backend credential, not a browser one. Flint does not accept requests from merchant origins, so a custom storefront calls Flint through your own backend.
Can I connect my existing Stripe account to Flint?
No. Flint provisions and operates its own Stripe-based processing accounts, and there is no way to attach a Stripe account you already have. Flint also inherits Stripe underwriting, so if Stripe declined your business, Flint cannot approve it.