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#

text
create session -> render or redirect -> buyer pays -> return + webhook -> verify from your backend

A session moves through a small set of states:

StatusMeaning
openThe checkout is active and the buyer can pay through its hosted or embedded surface. Sessions are created in this state.
paidThe buyer completed payment (or subscription signup). Terminal.
partially_paidSome 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.
expiredThe session passed expires_at without payment. Generic sessions default to 24 hours. Terminal.
closedYou closed it with the close endpoint. Terminal.
invalidatedThe 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:

FieldUse when
order_idYour app already created an order and owns order state, pricing, or customer context. The default for most integrations.
quick_pay_itemYou want to charge a name and an amount without building an order first.
plan_idYou want hosted signup for a subscription plan.
payment_intent_idYou 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.

Bash
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"
    }
  }'
JSON
{
  "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.

AllowedNot allowed
Read the owning session and orderRead or write any other order
Create and cancel an order payment intentGeneral customer administration
Start, resume, read, list, and cancel the owning payment attemptManaging payment methods beyond listing the checkout customer's active ones
Quote, select, read, and clear delivery; query pickup availabilitySubscription administration
Replace order line-item modifiersMerchant, settings, or key administration
Preview, apply, reprice, and remove promotions and discountsCreating a manual discount (CHECKOUT_MANUAL_DISCOUNT_NOT_ALLOWED)
Set and clear a requested tip; apply billing-location taxRefunds, 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.

Bash
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.

Bash
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 appends csId and orderId query parameters so your landing page knows which session and order completed.
  • cancel_redirect_url: where the buyer goes if they back out. The session stays open until 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.completed fires when a session becomes paid. The payload carries checkout_session_id, order_id, and payment_intent_id; fetch the session or order when you need more, and treat this event as the natural trigger for fulfillment.
  • order.paid fires 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_paid with terminal_reason: "payment_partially_succeeded". Flint emits order.partially_paid; it does not emit checkout_session.completed because the order still has a balance.
  • subscription.created and subscription.activated fire for plan sessions.
  • checkout_session.closed, checkout_session.expired, and checkout_session.invalidated fire when a session terminalizes without payment. Each carries a reason (api for closed, expired for expired, and order_mutated, superseded, invoice_paid_elsewhere, invoice_voided, or invoice_uncollectible for invalidated) plus superseding_checkout_session_id when a replacement took over. See the webhook events catalog.

You can also read the state directly whenever you need it:

Bash
curl "https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample?expand=order" \
  -H "Authorization: Bearer YOUR_API_KEY"
JSON
{
  "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.

FieldWhat it controls
customer_collectionWhich buyer details are collected, required, or prefilled.
redirectsSuccess, cancel, and post-expiration destinations.
expirationHow long the session stays payable.
tipWhether tipping is offered and which presets show.
couponLegacy coupon-code entry gate.
promotion_configObject-level promotion behavior, including automatic promotions and promotion-code entry for the session.
taxWhether tax is calculated and shown at checkout.
paymentsWhich payment methods and wallets are offered, plus your own payment reference.
legalTerms of service, refund and shipping policy links, and whether terms must be accepted.
custom_textMerchant copy on the page, like an order summary message.
themeBranding for the hosted page.
delivery_method_idsDelivery methods Flint evaluates for shipment, pickup, and local delivery choices.
metadataYour 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:

JSON
{
  "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. Set customer_id before creating checkout. Checkout accepts an omitted or matching customer_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_item or plan_id, customer_collection.customer_id links the new order and payment to an existing customer record.
  • prefilled_customer_info fills 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, and require_billing_address make fields mandatory before payment.
  • enable_address_autocomplete turns 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#

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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 statusWhat the checkout credential can still do
paid, partially_paidRead the session and its order, and resend the receipt. No mutations.
closed, expired, invalidatedNothing. 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).

Bash
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:

Bash
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.completed or a backend fetch.
  • Rebuilding the URL from the session ID. The #checkout_token fragment 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 as replace_checkout_session_id when 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#

Rate this doc