Checkout Sessions
A checkout session is one checkout for one buyer. A hosted session sends the buyer to Flint. An embedded session gives your backend scoped authority while your storefront renders the buyer experience. Both surfaces share order, payment-attempt, expiration, replacement, and webhook behavior.
Sessions are single-use by design. If you want one URL that many buyers can open (a pricing page, a QR code, an ad), use Payment Links. If you're collecting a receivable that needs delivery, reminders, and PDFs, use Invoices. Not sure which fits? See Payment Links vs Checkout Sessions vs Invoices.
If the question is "a buyer clicked Pay in my app, where do I send them?" the answer is a checkout session.
How Sessions Work#
create session -> render or redirect -> buyer pays -> return + webhook -> verify from your backend
A session moves through a small set of states:
| Status | Meaning |
|---|---|
open | The checkout is active and the buyer can pay through its hosted or embedded surface. Sessions are created in this state. |
paid | The buyer completed payment (or subscription signup). Terminal. |
partially_paid | Some money settled but the payment attempt ended with an unpaid balance. Terminal, so the same buyer session cannot collect again. Read the order's outstanding_money before starting new collection. |
expired | The session passed expires_at without payment. Generic sessions default to 24 hours. Terminal. |
closed | You closed it with the close endpoint. Terminal. |
invalidated | The session stopped matching its order or invoice lifecycle, or an explicit replacement superseded it. Terminal. |
Only open sessions can be paid. A buyer who opens the URL of a session in a terminal state sees a clear "this checkout is no longer active" page instead of a payment form, so a stale link can never collect a stale price.
Create a Session#
Every create call includes exactly one of four fields, which decides what the session sells:
| Field | Use when |
|---|---|
order_id | Your app already created an order and owns order state, pricing, or customer context. The default for most integrations. |
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 want hosted or embedded collection for an existing orderless PaymentIntent. |
Payment links and invoices create checkout sessions for you behind the scenes; a payment-link session carries origin: "payment_link" and an invoice-owned session carries invoice_id. Create sessions directly (origin: "api") when your application drives the checkout moment.
Pay an Existing Order#
The order-first flow: your backend creates the order, then asks for a hosted checkout page that collects its balance. Amounts on the order are integers in the currency's minor unit.
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
-H "Content-Type: application/json" \
-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/checkout"
}
}'
{
"data": {
"checkout_session": {
"checkout_session_id": "cs_1kmn0aExample",
"status": "open",
"order_id": "ord_1kmn0aExample",
"origin": "api",
"surface": "hosted",
"expires_at": "2026-07-03T17:04:05Z"
},
"checkout_access": {
"checkout_auth_token": "ckat_v1...",
"hosted_url": "https://checkout.withflintpay.com/checkout/cs_1kmn0aExample#checkout_token=..."
}
}
}
Save data.checkout_session.checkout_session_id for your records and use data.checkout_access.hosted_url for a hosted redirect. If you haven't created an order yet, Accept Your First Payment walks this same flow end to end, starting from the order.
Embed Checkout in Your Storefront#
Set surface: "embedded" when your storefront owns the page. Creation returns the same checkout_session and checkout_access.checkout_auth_token, but omits checkout_access.hosted_url.
Keep the checkout token in your backend session. Send it to Flint as X-Checkout-Session-Secret together with X-Checkout-Session-ID, without merchant authentication on the same request.
Direct browser-to-Flint requests from merchant origins are not supported. Use a backend-for-frontend with your own signed browser session, CSRF protection, origin checks, CSP, and rate limits. Build your own checkout walks the whole integration end to end; Embedded Payments with Stripe Elements covers collection and recovery.
What the Checkout Credential Can Do#
The credential is scoped to its own session and the order that session owns. It resolves the order and customer from the session itself, so it cannot be pointed at anything else.
| Allowed | Not allowed |
|---|---|
| Read the owning session and order | Read or write any other order |
| Create and cancel an order payment intent | General customer administration |
| Start, resume, read, list, and cancel the owning payment attempt | Managing payment methods beyond listing the checkout customer's active ones |
| Quote, select, read, and clear delivery; query pickup availability | Subscription administration |
| Replace order line-item modifiers | Merchant, settings, or key administration |
| Preview, apply, reprice, and remove promotions and discounts | Creating a manual discount (CHECKOUT_MANUAL_DISCOUNT_NOT_ALLOWED) |
| Set and clear a requested tip; apply billing-location tax | Refunds, disputes, and post-purchase support |
| List the checkout customer's active saved payment methods | |
| Resend the owning order's receipt |
Anything outside this list uses merchant authentication.
Send Exactly One Credential#
Every request carries either checkout authentication or merchant authentication, never both. Sending X-Checkout-Session-ID and X-Checkout-Session-Secret alongside Authorization or X-API-Key returns 400 AMBIGUOUS_AUTH.
A backend that holds both credentials should decide centrally which one a given call uses, rather than letting individual handlers assemble Flint headers.
Charge a Quick Amount#
When there's no line-item order to model, quick_pay_item charges a name and an amount in one call. Flint creates the backing order for you, so you still end up with the same durable commerce record as the order-first flow.
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: checkout-consult-001" \
-d '{
"quick_pay_item": {
"name": "Design consultation",
"amount_money": {"amount": 9900, "currency": "USD"}
},
"redirects": {
"success_redirect_url": "https://example.com/thanks"
}
}'
The response includes the generated order_id alongside the session. Keep both: refunds, receipts, and reporting all hang off the order. quick_pay_item also accepts an optional tax object (taxable, tax_category) if the amount should be taxed.
Start a Subscription Signup#
Pass a plan_id to get a hosted signup page for a subscription plan. The buyer enters their details and payment method; when they complete signup, Flint creates the subscription and its first order and payment.
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: checkout-pro-signup-001" \
-d '{
"plan_id": "plan_1kmn0aExample",
"redirects": {
"success_redirect_url": "https://example.com/welcome"
}
}'
Confirm signup through the subscription.created and subscription.activated webhook events, then manage the subscription through the subscriptions API. For a reusable public signup URL instead of one session per buyer, see subscription signup links.
Send the Buyer to Checkout#
For a hosted session, send the buyer to data.checkout_access.hosted_url exactly as returned:
- Server-rendered app: respond with an HTTP redirect to the URL.
- Single-page app: navigate the browser (
window.location.assign(url)).
The #checkout_token fragment on the URL is what authenticates the buyer on the hosted page. Don't strip it, rebuild the URL from the session ID, or store the URL in logs. It's fine to deliver the URL to the buyer (that's its job); treat it like something only that buyer should hold.
Handle the Return#
The redirects object controls where the buyer lands afterward:
success_redirect_url: where the buyer goes after paying. Flint appendscsIdandorderIdquery parameters so your landing page knows which session and order completed.cancel_redirect_url: where the buyer goes if they back out. The session staysopenuntil it expires or you close it, so the buyer can return and finish later from the same URL.expiration.expiration_url: where buyers are sent if they open the session after it expires. Without it they see Flint's expired-checkout page.
The success redirect is user experience, not proof of payment. A buyer can close the tab before the redirect fires, or visit the URL directly. Verification belongs to your backend, below.
Verify the Outcome#
Subscribe to webhooks for the durable signal:
checkout_session.completedfires when a session becomespaid. The payload carriescheckout_session_id,order_id, andpayment_intent_id; fetch the session or order when you need more, and treat this event as the natural trigger for fulfillment.order.paidfires on the underlying order, if you'd rather key fulfillment off orders regardless of which surface collected payment.- A mixed terminal attempt can leave the session
partially_paidwithterminal_reason: "payment_partially_succeeded". Flint emitsorder.partially_paid; it does not emitcheckout_session.completedbecause the order still has a balance. subscription.createdandsubscription.activatedfire for plan sessions.checkout_session.closed,checkout_session.expired, andcheckout_session.invalidatedfire when a session terminalizes without payment. Each carries areason(apifor closed,expiredfor expired, andorder_mutated,superseded,invoice_paid_elsewhere,invoice_voided, orinvoice_uncollectiblefor invalidated) plussuperseding_checkout_session_idwhen a replacement took over. See the webhook events catalog.
You can also read the state directly whenever you need it:
curl "https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample?expand=order" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": {
"checkout_session_id": "cs_1kmn0aExample",
"status": "paid",
"terminal_reason": "payment_succeeded",
"order_id": "ord_1kmn0aExample",
"payment_intent_ids": ["pi_1kmn0aExample"],
"order": {
"order_id": "ord_1kmn0aExample",
"status": "closed",
"payment_status": "paid"
}
}
}
Never fulfill based on the browser redirect alone. Treat the webhook, or a backend fetch like the one above, as the signal that money actually moved.
Configure the Hosted Page#
Everything below is optional. Sections you omit inherit the merchant's checkout settings from the dashboard, so a bare create already renders a complete, working page. Pass a section to override it for this session only.
| Field | What it controls |
|---|---|
customer_collection | Which buyer details are collected, required, or prefilled. |
redirects | Success, cancel, and post-expiration destinations. |
expiration | How long the session stays payable. |
tip | Whether tipping is offered and which presets show. |
coupon | Legacy coupon-code entry gate. |
promotion_config | Object-level promotion behavior, including automatic promotions and promotion-code entry for the session. |
tax | Whether tax is calculated and shown at checkout. |
payments | Which payment methods and wallets are offered, plus your own payment reference. |
legal | Terms of service, refund and shipping policy links, and whether terms must be accepted. |
custom_text | Merchant copy on the page, like an order summary message. |
theme | Branding for the hosted page. |
delivery_method_ids | Delivery methods Flint evaluates for shipment, pickup, and local delivery choices. |
metadata | Your own string key-value pairs for correlation, returned on every read. |
Customer Details#
customer_collection decides what you ask the buyer for and what you already know:
{
"order_id": "ord_1kmn0aExample",
"customer_collection": {
"prefilled_customer_info": {
"email": "ada@example.com",
"phone": "+15555550100"
},
"require_email": true,
"require_phone": false,
"require_billing_address": false,
"enable_address_autocomplete": true
}
}
- With
order_id, customer ownership comes from the order. Setcustomer_idbefore creating checkout. Checkout accepts an omitted or matchingcustomer_collection.customer_id, but it never assigns or changes the order customer. If an open checkout already owns collection, close or expire it before setting the order customer. - With
quick_pay_itemorplan_id,customer_collection.customer_idlinks the new order and payment to an existing customer record. prefilled_customer_infofills the form ahead of time (email, phone, billing and shipping address); the buyer can still edit. For a linked customer, Flint fills any omitted billing or shipping address from the customer's effective address. A saved default supplies that effective address when one is selected. Explicit prefill values win for that checkout.require_email,require_phone, andrequire_billing_addressmake fields mandatory before payment.enable_address_autocompleteturns on address suggestions as the buyer types.
The fewer fields you require, the faster buyers get through checkout. Require only what fulfillment actually needs.
Tips#
{
"tip": {
"enabled": true,
"tip_percentages": [10, 15, 20],
"default_tip_percentage": 15,
"is_custom_tip_enabled": true
}
}
Percentages are whole numbers: 15 means 15%. See Tips & Fees for how tips land on the order.
Discounts and Tax#
checkout.promotion_code_entry_enabled is the merchant default for hosted checkout promotion-code entry. Set promotion_config.codes_enabled on a session when this checkout should explicitly show or hide promotion-code entry instead of inheriting that default. promotions.codes_enabled is the merchant redemption policy: explicit false rejects promotion-code application even when the page asks to show code entry.
promotion_config.automatic_enabled controls automatic promotions on the session. coupon.enabled is the legacy coupon-code entry gate and should not be used for new promotion-code integrations. Codes are applied to the order before payment. tax.enabled calculates and displays tax at checkout based on your tax settings; address-dependent tax appears once the buyer provides an address. See Sales Tax for how that calculation works.
Lifecycle Rules#
One Open Session per Order#
An order has at most one open session at a time. Creating another session for that order without replacement intent returns 409 CHECKOUT_SESSION_ALREADY_EXISTS; the error includes existing_checkout_session_id. Flint does not invalidate the existing buyer URL just because another create request arrived.
To intentionally regenerate the buyer URL, send the current session ID as replace_checkout_session_id in the same create call:
{
"order_id": "ord_1kmn0aExample",
"replace_checkout_session_id": "cs_1kmn0aCurrent"
}
Replacement is atomic. It succeeds only if that ID is still the current open session and no payment is resolving. A stale ID returns CHECKOUT_SESSION_CURRENT_CHANGED with current_checkout_session_id; active payment work returns retryable CHECKOUT_PAYMENT_RESOLVING. Reuse the original Idempotency-Key after a timeout to replay the same successor. replace_checkout_session_id is available only with order_id; invoice-owned sessions are managed through the invoice checkout endpoint.
The replaced session becomes invalidated with terminal_reason: "superseded" and a read-only superseding_checkout_session_id pointing at its successor, on both GET and the checkout_session.invalidated webhook, so anything holding the old session can follow the chain forward.
The Order Locks While Checkout Is Open#
A merchant-side financial change invalidates the order's open checkout so the old URL cannot collect a stale total. If payment has already started, Flint rejects the competing change with retryable CHECKOUT_PAYMENT_RESOLVING and leaves both the order and checkout unchanged. Buyer-driven changes on the hosted page, like tips and coupons, remain part of that checkout.
After a merchant-side change succeeds, create a new session and send the buyer its URL. The invalidation can land moments after the change commits, so a create that races it may return CHECKOUT_SESSION_ALREADY_EXISTS; retry, or pass the returned existing_checkout_session_id as replace_checkout_session_id.
Expiration#
Generic sessions expire 24 hours after creation by default. Tighten that when the price or order context goes stale sooner:
{
"order_id": "ord_1kmn0aExample",
"expiration": {
"expires_in_seconds": 1800,
"expiration_url": "https://example.com/quote-expired"
}
}
The computed deadline comes back as expires_at on every read. Invoice-owned checkout sessions are the exception: their fixed deadline comes from the active invoice link, so the invoice remains usable across its collection window without repeatedly replacing the buyer's session.
What the Credential Can Still Do After a Session Ends#
Checkout authentication does not stop working the moment a session leaves open. It narrows.
| Session status | What the checkout credential can still do |
|---|---|
paid, partially_paid | Read the session and its order, and resend the receipt. No mutations. |
closed, expired, invalidated | Nothing. The credential no longer authenticates. |
Support and back-office views must use merchant authentication, because they need to work after the buyer's session is long gone.
Recovering an Attempt After the Session Expires#
An open session can pass its expiry while a payment attempt is still live. Failing there would strand a buyer mid-authentication with money possibly moving, so the session enters a restricted recovery mode instead of dying.
In recovery, the credential authenticates for exactly three operations, all pinned to the one attempt that was in flight:
GET /v1/orders/{order_id}GET /v1/orders/{order_id}/payment-attempts/{payment_attempt_id}POST /v1/orders/{order_id}/pay, to resume that attempt
Anything else returns CHECKOUT_RECOVERY_RESTRICTED, and naming a different attempt returns CHECKOUT_RECOVERY_ATTEMPT_MISMATCH. Reads expose the state as recovery_mode, with recovery_payment_attempt_id and recovery_expires_at.
Treat recovery as a way to finish one payment, not as extended checkout authority. Once the attempt reaches a terminal state, start over with a new session.
Closing a Session#
Close a session yourself when it's no longer valid: the buyer abandoned it, the quote was withdrawn, or you need to set the order's customer (financial edits invalidate the checkout on their own).
curl -X POST https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample/close \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"reason": "order updated"}'
The session becomes closed and the reason is stored on closed_reason for your audit trail.
Retrieve and List Sessions#
Fetch one session by ID, optionally expanding related records in the same call (expand=customer,invoice,order,payment_intents,payment_link). List sessions with filters for reconciliation and support tooling:
curl "https://api.withflintpay.com/v1/checkout-sessions?status=open&order_id=ord_1kmn0aExample" \
-H "Authorization: Bearer YOUR_API_KEY"
The list endpoint supports status, order_id, customer_id, payment_link_id, origin, created/updated/expires time bounds, and a free-text query over metadata and payment notes, with standard cursor pagination. PATCH /v1/checkout-sessions/{id} updates metadata on a live session without disturbing it.
Common Mistakes#
- Reusing one session URL for many buyers. A session is one checkout for one buyer. For a shareable URL, use Payment Links.
- Treating the success redirect as payment confirmation. Redirects get lost. Fulfill from
checkout_session.completedor a backend fetch. - Rebuilding the URL from the session ID. The
#checkout_tokenfragment authenticates the buyer; a reconstructed URL drops it and the page can't load the session. - Creating another session without explicit replacement. Handle
CHECKOUT_SESSION_ALREADY_EXISTS, or pass its current ID asreplace_checkout_session_idwhen you deliberately need a new buyer URL. - Editing an order while payment is resolving. Wait and retry after
CHECKOUT_PAYMENT_RESOLVING; Flint will not let a competing order change interrupt payment ownership.
Next Steps#
- Accept Your First Payment: the full order-to-paid walkthrough with test cards.
- Checkout Sessions API Reference: every field on every endpoint.
- Webhooks: durable payment notifications for fulfillment.
- Embedded Payments with Stripe Elements: collect payment inside your own UI instead of redirecting.
- Payment Links vs Checkout Sessions vs Invoices: choosing the right hosted surface.
