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.
Use Embedded payments with Stripe Elements for payment collection, Declines and payment attempts for recovery, and Checkout sessions for 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 calls the Flint API. The one Flint page it loads is the gift card challenge, in an iframe, and only after repeated failed codes.
What you own, and what Flint owns#
| Concern | Owner |
|---|---|
| Page, layout, fields, navigation, receipt UI | You |
| Buyer authentication, browser session, CSRF, CSP, origin checks | You |
| Card and wallet data collection | Stripe Elements, on Stripe's domain |
| Order total, tax, discounts, outstanding balance | Flint |
| Payment confirmation, authentication state, attempt state machine | Flint |
| Settlement and the fulfillment signal | Flint |
Card details never pass through your servers or Flint's.
Before you start#
- Confirm
GET /v1/capabilities?capability=accept_card_paymentsreportsready. Handlemerchant_payments_disabled,capability_pending, andrequirements_dueby explaining the problem. Do not render an empty payment form. - Serve checkout over HTTPS and register every checkout hostname with
POST /v1/payment-method-domainsbefore expecting Apple Pay or Google Pay. Domain registration is a Stripe wallet requirement; it does not grant your origin access to Flint. See Apple Pay and Google Pay setup. - Register a webhook endpoint, verify signatures against the raw body, and deduplicate on
webhook-id. - Use a least-privilege backend key:
commerce.orders.readandcommerce.orders.writefor order checkout, pluscheckouts.checkout_sessions.readandcheckouts.checkout_sessions.writeif you manage sessions with merchant auth. Provision wallet domains with a separate administrative key.
This walkthrough covers card and wallet payments. An embedded checkout also takes Affirm and ACH debit on the same Orders path. Affirm sends the buyer away to approve a plan, so a session that offers it needs redirects.success_redirect_url, and an ACH debit settles days after the buyer finishes. Affirm and ACH debit covers both.
The shape of the integration#
- Browser sends add to cart to Your backend
- Your backend sends create or update the order to Flint
- Flint returns order and collection details to Your backend
- Your backend returns buyer-safe state to Browser
- Browser sends mount Elements to Stripe.js
- Stripe.js returns ctoken_... to Browser
- Browser sends submit to Your backend
- Your backend sends POST /v1/orders/{order_id}/pay to Flint
- Flint returns order and attempt to Your backend
- Your backend returns attempt state to Browser
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. Gift cards are one more choice that moves the amount due. Settle the cart and promotion codes before the buyer selects delivery: a promotion code releases the delivery selection, and a cart change made with your API key ends the session.
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_contact.email on POST /v1/orders/{order_id}/pay, or save it on the session first. Flint links the order to the customer with that email, creating one if needed, only after the payment settles the order. The link gives the checkout credential no access to that customer's saved payment methods. When the checkout acts for a customer because the buyer confirmed their email with a code, Flint links the order to that customer instead of the customer for buyer_contact.email.
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 POST /v1/orders/{order_id}/pay with your API key.
Create an embedded session when you need checkout-scoped buyer authority, Flint delivery selection, checkout expiration, or redirect return routing.
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",
"page_origin": "https://shop.example.com",
"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. Its checkout_session has no url, because there is no Flint-hosted page to send anyone to.
page_origin is the origin of the page that renders this checkout, such as https://shop.example.com. It is optional, but without it the session cannot show a gift card challenge. Only that origin can frame the challenge and receive its result. It grants no API access: CORS is unchanged, and the browser still never calls the Flint API. You set it when you create the session. It is stored on the session and returned on reads. The session's checkout credential can't set or change it, and PATCH /v1/checkout-sessions/{checkout_session_id} doesn't accept it.
- Send the origin exactly as a browser serializes it: scheme,
://, host, and a port only when it isn't the default. No path (not even a trailing/), query, fragment, or credentials. Flint rejects other forms rather than correcting them, so an uppercase host or:443fails. - In live mode, use
httpsand a lowercase DNS name. IP addresses,localhost, and names ending in.localhostorwithflintpay.comare not accepted. - In test mode,
localhost, names ending in.localhost, and127.0.0.1also work, overhttporhttps. - It is accepted only when
surfaceisembedded. Leave it out of hosted sessions.
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.
POST /v1/checkout-sessions is for orders you sell. An invoice and a return or exchange balance each have their own launch route, described in Collect an invoice or a return balance.
5. Store the credential, relay the calls#
Checkout-scoped requests authenticate with two headers:
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 or X-API-Key, 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.
Save the buyer's contact as they type#
Relay the email and phone the buyer enters to the session as buyer_contact, with the checkout headers. Save a field once it is valid, for example when it loses focus, so a reload can prefill it:
PATCH /v1/checkout-sessions/cs_1kmn0aExample
X-Checkout-Session-ID: cs_1kmn0aExample
X-Checkout-Session-Secret: ckat_1kmn0aExample
Content-Type: application/json
{"buyer_contact": {"email": "buyer@example.com"}}
Omitted fields keep their saved value, and null clears one. The phone must be in E.164 format. A failed save should never block the buyer. When the pay request omits buyer_contact.email or buyer_contact.phone, Flint uses the saved values. Flint clears the saved contact 30 days after the session ends. Checkout sessions covers the full behavior. Flint's checkout reminder emails link to hosted checkout, so an embedded session never sends one.
Send the buyer's time zone#
For receipts Flint sends to use the buyer's time zone, read it in the browser with Intl.DateTimeFormat().resolvedOptions().timeZone, and relay it to the open session with the checkout headers:
PATCH /v1/checkout-sessions/cs_1kmn0aExample
X-Checkout-Session-ID: cs_1kmn0aExample
X-Checkout-Session-Secret: ckat_1kmn0aExample
Content-Type: application/json
{"timezone": "America/Chicago"}
timezone is optional and must be an IANA time zone name, or the request returns INVALID_TIMEZONE. Only the checkout headers can set it: with your API key the request returns CHECKOUT_SESSION_UPDATE_FIELD_NOT_ALLOWED. null is not accepted; leave the field out to keep the value you sent before.
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.
A payment request does not save a shipping address on the order. The buyer's delivery selection writes the order's delivery_destination, and while that selection is active you can't set the destination directly. An order with items to ship, deliver, or pick up can't be paid without a selection: paying returns FULFILLMENT_SELECTION_REQUIRED.
Quote the buyer's address#
Buyer-side requests use the checkout ID and secret headers. Send null to assert that no selection exists yet.
POST /v1/checkout-sessions/cs_01K1P6G4M7H2N8Q9R3S5T6V7WX/delivery-quotes
X-Checkout-Session-ID: cs_01K1P6G4M7H2N8Q9R3S5T6V7WX
X-Checkout-Session-Secret: CHECKOUT_SECRET
Idempotency-Key: quote-order-42-address-1
Content-Type: application/json
{
"expected_delivery_selection_id": null,
"destination_address": {
"line1": "120 Kent Avenue",
"city": "Brooklyn",
"state": "NY",
"postal_code": "11249",
"country": "US"
}
}
{
"data": {
"audience": "buyer",
"delivery_quote_id": "dqt_01K1P6G4M7H2N8Q9R3S5T6V7WX",
"status": "active",
"evaluation_status": "complete",
"choice_groups": [{
"delivery_choice_group_id": "dcgrp_01K1P6G4M7H2N8Q9R3S5T6V7WX",
"availability_status": "ready",
"method_types": ["shipment"],
"options": [{
"delivery_option_id": "dopt_01K1P6G4M7H2N8Q9R3S5T6V7WX",
"type": "shipment",
"name": "Standard shipping",
"amount_money": {"amount": 900, "currency": "USD"}
}]
}],
"buyer_reasons": [],
"expires_at": "2026-08-03T16:00:00Z"
},
"request_id": "req_01K1P6G4M7H2N8Q9R3S5T6V7WX"
}
Each choice group's availability_status is ready when it has options to choose from, needs_input when the buyer must first supply what its input_requirements list, and unavailable when no option can serve it.
method_types lists how a group can be delivered: shipment, pickup, or local_delivery, in the order the merchant displays the methods. It is there before any option is priced, so a checkout that offers only local delivery can say "Delivery" while it asks for the address. A type stays listed when its methods need more input or cannot serve the current address; options says what the buyer can choose now.
Quotes expire. expires_at is on every quote, and selecting from an expired or outdated quote fails with DELIVERY_QUOTE_EXPIRED or DELIVERY_QUOTE_STALE rather than charging an outdated rate. The error's remediation names what to do, which is to create a new quote. Surface that instead of a generic error: the buyer's address is still valid, only the price is stale. Once the buyer has selected, problems[] on the session reports delivery_selection_stale, with the same remediation, when the selection or the quote it came from is no longer valid, for example because it expired.
If Flint cannot read the session's delivery, reading the session still succeeds. fulfillment is omitted, and problems[] reports delivery_selection_stale with the error's code in remediation.next_actions[0].reason_code, such as DELIVERY_SERVICE_UNAVAILABLE. When remediation.retryable is true, read the session again after a short delay. Otherwise, create a new quote.
Select the option#
Use IDs from the quote. Do not reconstruct them from method data.
The selected option decides which recipient details are required. A shipment normally needs a recipient name, and it may require an email or phone for carrier notifications. Local delivery often requires a phone. Those requirements come back on the option in recipient_requirements, so read them rather than assuming.
You can select before you have every required field. Apple Pay and Google Pay reveal the buyer's name and phone only after the buyer approves the payment sheet, so a wallet flow selects delivery from the partial address first. A sheet can request a phone only when it opens, so decide from the quote you have then: its input_requirements names recipient.phone, with purpose selection, for each option or waiting method that requires it, even before the buyer gives an address. The selection's input_requirements lists each required field it is still missing. Payment fails with DELIVERY_RECIPIENT_REQUIRED until a new selection supplies them: select the same choices from the same quote with the full recipient, and send the current selection's ID as expected_delivery_selection_id.
POST /v1/checkout-sessions/cs_01K1P6G4M7H2N8Q9R3S5T6V7WX/delivery-selections
X-Checkout-Session-ID: cs_01K1P6G4M7H2N8Q9R3S5T6V7WX
X-Checkout-Session-Secret: CHECKOUT_SECRET
Idempotency-Key: select-order-42-shipping
Content-Type: application/json
{
"delivery_quote_id": "dqt_01K1P6G4M7H2N8Q9R3S5T6V7WX",
"expected_delivery_selection_id": null,
"recipient": {"name": "Morgan Lee", "email": "morgan@example.com", "phone": "+12125550123"},
"choices": [{
"delivery_choice_group_id": "dcgrp_01K1P6G4M7H2N8Q9R3S5T6V7WX",
"delivery_option_id": "dopt_01K1P6G4M7H2N8Q9R3S5T6V7WX"
}]
}
{
"data": {
"audience": "buyer",
"delivery_selection_id": "dsel_01K1P6G4M7H2N8Q9R3S5T6V7WX",
"delivery_quote_id": "dqt_01K1P6G4M7H2N8Q9R3S5T6V7WX",
"status": "selected",
"choices": [{
"delivery_choice_group_id": "dcgrp_01K1P6G4M7H2N8Q9R3S5T6V7WX",
"delivery_option_id": "dopt_01K1P6G4M7H2N8Q9R3S5T6V7WX",
"name": "Standard shipping",
"total_money": {"amount": 900, "currency": "USD"}
}],
"recipient": {"name": "Morgan Lee", "email": "morgan@example.com", "phone": "+12125550123"},
"input_requirements": []
},
"request_id": "req_01K1P6G4M7H2N8Q9R3S5T6V7WX"
}
With input_requirements empty, checkout can collect payment. Flint commits the selection to the order atomically with successful payment processing.
Re-read the order#
A delivery selection changes what the buyer owes. The shipping charge is added, and because shipping is taxable in many jurisdictions, the tax total can move too.
Read the order after selecting and render the amounts it returns. Do not add the shipping price to a total you already had. If you are collecting payment yourself, the outstanding balance from this read is also what belongs in expected_outstanding_money on the pay request, so a stale total is caught before the card is charged rather than after.
Keep the selection through later changes#
A delivery price depends on the cart, not on the tip or the tax location. A tip or a tax location sent with the checkout headers keeps the buyer's selection, and so does saving the buyer's contact: the delivery charge stays on the order, and when the order's tax changes, the tax on the delivery charge changes with it. A declined payment keeps the selection too, so the buyer can try another card without choosing delivery again.
Applying or removing a promotion code, or changing a line item's modifiers, releases the selection. The next read of the order or the session removes the delivery charge, and paying returns FULFILLMENT_SELECTION_REQUIRED until the buyer selects from a new quote. Re-read the order after every change. A change made with your API key, such as a new quantity, invalidates the session instead, as described in Handle replacement.
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.
Offer to save the card#
Read the checkout session with the checkout headers. When save_payment_method_offered is true, you can show an unchecked option under the card fields, such as "Save my details for faster checkout at Cedar & Stone Coffee". It is false when checkout.saved_payment_details is off, customer accounts are merchant hosted, card is not an available payment option, or the checkout collects an invoice, a subscription, or a return. A read with your API key omits it.
Flint saves the card only for the customer the checkout acts for: the customer you created the session for, or the customer whose email the buyer confirms with a code. A typed email is not enough. When save_payment_method_requires_verification is true, the checkout acts for no customer yet, so confirm the buyer's email before the payment, as described in Confirm the buyer's email. When an emailed code from Recognize a returning buyer is already out for the buyer's email, confirming it does the same, so don't request a second code. It is false once the checkout acts for a customer, and once the buyer confirms a texted code, which saves a new card with that number.
When save_payment_method_phone_offered is true, the buyer can save the card with a mobile phone number instead, confirmed by a texted code after paying, as described in Save with a mobile phone number. A buyer who hasn't confirmed an email skips the code before paying this way.
The processor saves the card from the ConfirmationToken, so the token carries the buyer's choice:
- Create the Elements group for the card fields without
setupFutureUsage. Whenpayment_method_typeslistsus_bank_accountoraffirm, addsetup_future_usage: "none"to their entries inpaymentMethodOptions. Without it, the Payment Element fails to load once saving is turned on. - When the buyer checks the option, call
elements.update({setupFutureUsage: "on_session"}), andelements.update({setupFutureUsage: null})when they uncheck it. Wait for theupdate-endevent before callingstripe.createConfirmationToken. - Keep Apple Pay and Google Pay in a separate Elements group that never sets
setupFutureUsage. A wallet card can't be saved this way. - With
on_sessionset, the Payment Element adds a line saying the buyer allows future charges, which a card saved this way never gets. Create it withterms: {card: "never"}and show your own wording instead.
Then send the choice with the payment:
{
"action": "pay",
"payment_source": {"confirmation_token": "ctoken_1kmn0aExample"},
"expected_outstanding_money": {"amount": 6400, "currency": "USD"},
"buyer_contact": { "email": "buyer@example.com" },
"save_payment_method": true
}
save_payment_method: trueworks only with the checkout headers, in a checkout that acts for a customer, for one card the buyer typed, sent as aconfirmation_tokencreated withon_session. A token created withon_sessionand sent withoutsave_payment_methodreturnsPAYMENT_OPTION_NOT_ALLOWED;save_payment_methodwith a token created without it returnsSAVE_PAYMENT_METHOD_TOKEN_MISMATCH.- Flint saves the card once the payment succeeds, including after 3D Secure, for the customer the checkout acts for. It has
usage: "on_session", so it pays only in later checkouts, andpayment_method.savedfollows. A declined payment saves nothing. - A resume keeps the choice the attempt started with. Don't send
save_payment_methodwithaction: "resume".
Save with a mobile phone number#
A buyer can save their card by giving a mobile phone number with the payment and confirming it with a texted code after paying. Offer it when save_payment_method_phone_offered is true, which it is while Flint can send texts and either:
save_payment_method_requires_verificationistrue: the buyer hasn't confirmed an email, and the number replaces the code before paying.- The buyer confirmed their email in this checkout. The number is optional: without it, the card is saved by email. With it, the number becomes the customer's saved number once the buyer confirms it, replacing any other, so a buyer who changed phones can move their saved details to the new one.
Only US and Canadian mobile phone numbers work, so keep saving optional for everyone else.
Send the number in E.164 format with save_payment_method: true:
{
"action": "pay",
"payment_source": {"confirmation_token": "ctoken_1kmn0aExample"},
"expected_outstanding_money": {"amount": 6400, "currency": "USD"},
"buyer_contact": { "email": "buyer@example.com" },
"save_payment_method": true,
"save_payment_method_phone": "+14155552671"
}
- When the checkout acts for no customer,
buyer_contact.emailis required, and Flint keeps the card for your customer with that email, creating one if needed. When the buyer confirmed their email, Flint keeps it for that customer. Either way the card is recorded once the payment succeeds. When you capture later, that happens once the payment is approved, so the buyer confirms the card on the approved receipt while the checkout stays open. - Nothing is texted before the payment, and a declined payment saves nothing.
- The card works for nothing until the buyer confirms it within 24 hours. A card not confirmed in time is not saved: Flint removes it from the processor, and no one can pay with it.
After the payment, or its approval, read the checkout session with the checkout headers. payment_method_save says where the card stands:
{
"payment_method_save": {
"status": "pending",
"phone_last_digits": "71",
"email_confirmation_required": false,
"expires_at": "2026-09-27T15:04:05Z"
}
}
The card is recorded a few seconds after the payment succeeds or is approved, and the session has no payment_method_save until then. A paid session accepts only these codes. Request a texted code on your confirmation page:
curl -X POST https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample/customer-verifications \
-H "X-Checkout-Session-ID: cs_1kmn0aExample" \
-H "X-Checkout-Session-Secret: ckat_1kmn0aExample" \
-H "Idempotency-Key: save-cart-123-1" \
-H "Content-Type: application/json" \
-d '{"purpose": "confirm_saved_payment_method", "channel": "sms"}'
Send no email: the code goes to the number from the payment, in a text from Flint Pay, so name Flint Pay where the buyer types it. Confirm the code with the confirm route in Confirm the buyer's email. The response carries the checkout session with its payment_method_save, and no checkout_access, because the session keeps its credential:
- The texted code saves the card when the payment created the customer, when the buyer confirmed the customer's email in this checkout, or when the number is already the customer's saved number with an active card saved with it.
statusbecomessaved,saved_withissms, and at the buyer's next checkout a code texted to the number opens it. - Otherwise the customer existed before this payment, and a texted code proves only the number, so
email_confirmation_requiredturnstrue. Tell the buyer to confirm their email too, then request a code with"channel": "email"and confirm it. The card is then saved with the number. - When the text doesn't arrive, offer to email the code instead with
"channel": "email". An emailed code alone saves the card by email, and the number is dropped. - Flint emails a code only when the customer's email is the email the payment was made with. Otherwise the request returns
PAYMENT_METHOD_SAVE_EMAIL_UNAVAILABLE, and only the texted code confirms the card. The response shows the email masked, such asb•••@example.com; show it as returned. - After 24 hours,
statusreadsexpired. The buyer can save a card at their next checkout.
A card saved with a number makes it the customer's saved number. The cards saved with a number it replaces are removed, and payment_method.removed fires for each. payment_method.saved fires once the card is saved. A card that expires sends no event.
Confirm the buyer's email#
A checkout that acts for no customer can save the buyer's card, or pay with cards they saved at your business before, only after the buyer proves they own an email. Flint emails a six-digit code from your business name, and the buyer types it into your checkout. Only the checkout headers can call these routes; your API key gets 403 CHECKOUT_CREDENTIAL_REQUIRED.
Request a code with the purpose that matches what the buyer asked for:
curl -X POST https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample/customer-verifications \
-H "X-Checkout-Session-ID: cs_1kmn0aExample" \
-H "X-Checkout-Session-Secret: ckat_1kmn0aExample" \
-H "Idempotency-Key: verify-cart-123-1" \
-H "Content-Type: application/json" \
-d '{"email": "buyer@example.com", "purpose": "save_payment_method"}'
save_payment_method: the buyer checked the option to save their card. Flint sends a code to any valid address.use_saved_payment_methodswith"channel": "email": the buyer asked for an emailed code to use details they saved at your business. Flint sends a code only when a customer with that email has an active saved card, saved by email or with a mobile phone number. Name the channel: without one, Flint picks it from the saved details, as described in Recognize a returning buyer.
The response is the same either way, so it never tells anyone whether an email belongs to one of your customers:
{
"data": {
"customer_verification_id": "cscv_1kmn0aExample",
"checkout_session_id": "cs_1kmn0aExample",
"email": "buyer@example.com",
"purpose": "save_payment_method",
"expires_at": "2026-09-26T15:19:05Z",
"created_at": "2026-09-26T15:04:05Z"
}
}
Ask the buyer for the code, then confirm it:
curl -X POST https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample/customer-verifications/cscv_1kmn0aExample/confirm \
-H "X-Checkout-Session-ID: cs_1kmn0aExample" \
-H "X-Checkout-Session-Secret: ckat_1kmn0aExample" \
-H "Idempotency-Key: verify-cart-123-1-confirm" \
-H "Content-Type: application/json" \
-d '{"code": "482913"}'
{
"data": {
"checkout_session": {
"checkout_session_id": "cs_1kmn0aExample",
"status": "open",
"save_payment_method_offered": true,
"save_payment_method_requires_verification": false,
"customer_prefill": {
"email": "buyer@example.com",
"shipping_address": {"line1": "400 Congress Ave", "city": "Austin", "state": "TX", "postal_code": "78701", "country": "US"},
"shipping_recipient_name": "Jordan Lee"
}
},
"checkout_access": {
"checkout_auth_token": "ckat_2kmn0aExample"
}
}
}
checkout_access.checkout_auth_tokenis the only credential that acts for that customer. Send it asX-Checkout-Session-Secretfrom now on.- The checkout now acts for the customer with that email. Flint creates the customer when the email has none, and sends
customer.created.customer_prefillholds the customer's email and default addresses for your form, withshipping_recipient_name, the recipient saved with the default shipping address. An address the customer doesn't have is omitted.shipping_recipient_namecomes only withshipping_address, and is omitted when that address was set on the customer without a saved recipient. - To use saved details, list the buyer's cards with
GET /v1/payment-methodsand the new checkout headers, and pay with one by sending itspayment_method_idinpayment_source. To save the card being typed, sendsave_payment_method: truewith the payment. - An emailed code opens only the cards saved by email. When all of the customer's cards were saved with a number, the list is empty: tell the buyer their email is confirmed, and let them pay with a new card. When
save_payment_method_phone_offeredistrue, they can save it with their current number, which replaces the saved one. This is how a buyer who lost their phone gets their saved details back. - Flint links the order to that customer only when the payment succeeds, so confirming changes no item price, tax, or promotion. Delivery conditions on the customer, such as a rate for a customer group, apply to this customer from now on. When that changes what the checkout qualifies for, confirming releases the delivery selection and earlier quotes go stale, so create a new quote and select again. An order that already had a customer keeps being evaluated for that customer.
- Every earlier credential for the session, including the token you created the session with and its hosted checkout link, keeps working for the checkout but acts for no customer. With one, listing cards returns
404, paying with a saved card fails withPAYMENT_SOURCE_OWNERSHIP_MISMATCH, and a read returnssave_payment_method_requires_verification: true. It also can't read the buyer's delivery details: delivery selections, quotes, and the order leave out the recipient, destination address, buyer location, and delivery instructions. If that credential later confirms an email for a different customer, Flint releases the delivery selection and removes the earlier buyer's delivery details from the checkout's quotes and selections, so create a new quote and select again. A buyer who opens the link on another device confirms an email there to use saved details, and from then on only that device's new credential acts for a customer. - The checkout acts for that customer only while the customer keeps the email. If the email changes or the customer is deleted, the new credential also acts for no customer, and a read returns
save_payment_method_requires_verification: trueagain. The buyer can then confirm the customer's new email, or another email, and the checkout acts for that email's customer with a new credential. - If the confirm response is lost, the credential you sent still works. Send the same code with it again while the code is valid: the retry counts as a try and returns a new credential for the same customer. You can also request a new code with it. A retry with the same
Idempotency-Keyreturns the stored response.
A code works for 15 minutes and 5 tries, and a new request replaces the checkout's earlier code. A checkout can request 5 codes, codes can be requested for one email 3 times in 15 minutes and 10 times in 24 hours at your business, and one network can make 20 requests a minute. After 10 wrong tries in 24 hours, or 30 in 7 days, across the codes for one email, Flint sends that email no codes and accepts none of its codes. The responses don't change, so they never say whether an email reached the limit. Neither route works while a payment attempt is in progress.
Recognize a returning buyer#
As soon as the buyer types their email, ask for the code that opens the details they saved at your business. "channel": "auto" is the default for use_saved_payment_methods, so a request without a channel is an auto request too:
curl -X POST https://api.withflintpay.com/v1/checkout-sessions/cs_1kmn0aExample/customer-verifications \
-H "X-Checkout-Session-ID: cs_1kmn0aExample" \
-H "X-Checkout-Session-Secret: ckat_1kmn0aExample" \
-H "Idempotency-Key: verify-cart-123-2" \
-H "Content-Type: application/json" \
-d '{"email": "buyer@example.com", "purpose": "use_saved_payment_methods", "channel": "auto"}'
Flint sends the code the way the buyer saved their details:
- Details saved with a mobile phone number: Flint texts a code to that number, and the response has
"channel": "sms"andphone_last_digits. The text comes from Flint Pay, not your business, and reads "Your Flint Pay verification code is: " followed by the six digits. - Details saved only by email: Flint emails a code to the address, and the response has
"channel": "email". - No saved details, or a cap reached: Flint sends nothing, and the request returns
CUSTOMER_VERIFICATION_NOT_SENT. Show nothing about saved details, and let the buyer pay as usual.
A texted code's response:
{
"data": {
"customer_verification_id": "cscv_2kmn0aExample",
"checkout_session_id": "cs_1kmn0aExample",
"email": "buyer@example.com",
"purpose": "use_saved_payment_methods",
"channel": "sms",
"phone_last_digits": "71",
"expires_at": "2026-09-26T15:14:05Z",
"created_at": "2026-09-26T15:04:05Z"
}
}
- Say who sent the code and where it went, such as "Enter the code Flint Pay texted to ••• 71" or "Enter the code we emailed to b•••@example.com". Flint never returns the full number.
- Request once per email. While an emailed code Flint sent this way still works, another
autorequest for the same email answers with that code and sends nothing. To send another code, name the channel:"channel": "email", or"channel": "sms"for a text. - Beside a texted code, offer to email the code instead with
"channel": "email". An emailed code opens only cards saved by email. For a buyer whose cards were all saved with a number, it opens none, but it confirms the email: the buyer pays with a new card and can save it with their new number, as described in Confirm the buyer's email. - A new text to a number ends the code texted to it for any other checkout, at any business, so only the latest code works.
- Never hold the payment for a code. The buyer can ignore it and pay with a new card.
- Anyone who types an email learns whether it has details saved at your business, and for a text, that number's last two digits. Only a request that names
"channel": "email"answers the same whether or not the email has saved details.
Give the code field autocomplete="one-time-code" and inputmode="numeric", so iOS, and Android keyboards that support it, offer the code from the text above the keyboard. The text has no @your.domain #123456 line, which the WebOTP API needs, so browsers don't fill the code through WebOTP.
Confirm the code with the confirm route in Confirm the buyer's email. An emailed code's credential opens only the cards saved by email. A texted code's credential opens only the cards saved with that number:
GET /v1/payment-methodswith it lists only those cards, and paying with a card saved by email returnsPAYMENT_SOURCE_OWNERSHIP_MISMATCH. Cards saved by email open only with an emailed code, whose credential in turn opens no card saved with a number.- The checkout reads no customer details, so
customer_prefillis omitted. - A card the buyer saves with
save_payment_method: trueis saved with the confirmed number. - A texted code checks once. If the confirm response is lost, request a new code.
A texted code works for 10 minutes and 5 tries, and a new request within those 10 minutes texts the same code again. An emailed code works for 15 minutes and 5 tries. A checkout can make 6 text requests and get 3 texts, and request 5 emailed codes, before paying. One number gets 3 texts in 10 minutes and 10 in 24 hours, one email gets 3 codes in 15 minutes and 10 in 24 hours, and your business's checkouts can send 1,000 texts in 24 hours. One network can have 10 texts sent and 10 codes emailed by auto requests an hour, and make 30 text and auto requests together an hour. An auto request over any cap answers CUSTOMER_VERIFICATION_NOT_SENT, the same as an email with no saved details. After 10 wrong tries in 24 hours, or 30 in 7 days, across the codes texted to one number, Flint texts that number no codes and accepts none of its codes. A request over a cap on texts answers CUSTOMER_VERIFICATION_TEXT_UNAVAILABLE, the same as an email with no number, so the caps never say whether an email has one.
In a sandbox, Flint sends no texts. A texted code's request answers as if Flint texted the number. To confirm it, 000000 is a wrong code, 999999 returns CUSTOMER_VERIFICATION_UNAVAILABLE, and any other six digits confirm it. The number must still be a valid US or Canadian mobile phone number.
10. Start the payment#
Re-read the order, then use that response's outstanding amount as the concurrency fence. Pay under the session's checkout credential:
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \
-H "X-Checkout-Session-ID: cs_1kmn0aExample" \
-H "X-Checkout-Session-Secret: ckat_1kmn0aExample" \
-H "Idempotency-Key: pay-cart-123-attempt-1" \
-H "Content-Type: application/json" \
-d '{
"action": "pay",
"payment_source": {
"confirmation_token": "ctoken_1kmn0aExample"
},
"expected_outstanding_money": {"amount": 6400, "currency": "USD"},
"buyer_contact": { "email": "buyer@example.com" }
}'
A delivery selection belongs to the checkout session until payment commits it to the order, so an order with a selection must be paid with the checkout headers. Paying it with your API key returns FULFILLMENT_SELECTION_REQUIRED, because that request carries no session. Use Authorization: Bearer YOUR_API_KEY only for the card-only backend checkout in step 4, where there is no session and no Flint delivery selection.
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. POST /v1/orders/{order_id}/pay 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 action: "resume" and order_payment_attempt_id.
Declines and 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_succeededattempt 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}/send-receipt works under the checkout credential, and keeps working while the session is paid or partially_paid. Omit email to use the address on file. A different address returns ORDER_RECEIPT_EMAIL_ON_FILE. If the order has no email on file, send email to choose an address. Checkout credentials can send receipts to at most three distinct addresses over the order's lifetime. Receipts sent for this order through this route count toward this limit, including failed deliveries and receipts sent with a secret API key. A fourth address returns ORDER_RECEIPT_RECIPIENT_LIMIT_REACHED. Send to an address already used, or send the receipt with a secret API key. Addresses are trimmed and lowercased, preserving plus-addressing. You can send to an address already used once every five minutes.
Refunds, disputes, and support workflows use merchant authentication. Checkout credentials are scoped to their own session and order.
Show how to reach the merchant#
A session read with the checkout headers includes merchant_support: the support email, phone, and help page the merchant set. Show it on your receipt, and wherever your checkout tells the buyer to contact the merchant, such as an expired or closed session, or a checkout with no payment method the buyer can use.
"merchant_support": {
"email": "help@example.com",
"phone": "+15125550142",
"url": "https://example.com/support"
}
Each field is omitted when the merchant hasn't set it, and merchant_support is omitted when they set none of them. Link the email with mailto: and the phone with tel:. url is an absolute http or https URL. A read with your API key omits merchant_support. The merchant sets these under Settings > Business profile in the dashboard, or with support_email, support_phone, and support_url on PATCH /v1/merchant.
Apply gift cards#
A buyer types a gift card code into your page. Your backend applies it to the order with the checkout headers, and the order's gift_card_estimate says how much the cards cover and what the processor still charges. Orders covers the request, the estimate, and the accepted_gift_card_allocation you send with POST /v1/orders/{order_id}/pay.
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/gift-cards \
-H "X-Checkout-Session-ID: cs_1kmn0aExample" \
-H "X-Checkout-Session-Secret: ckat_1kmn0aExample" \
-H "Idempotency-Key: apply-gift-card-cart-123-1" \
-H "Content-Type: application/json" \
-d '{"gift_card_code": "ABCD-EFGH-JKMN-PQRS", "order_revision": "1790760000"}'
Apply codes the buyer typed with the checkout headers, never with an API key. An API key is not challenged or counted, so a storefront that used one would let anyone guess codes through your backend as fast as it can send them.
The selection reserves and debits nothing. A code that doesn't work returns the same GIFT_CARD_UNAVAILABLE whether it is unknown, belongs to another merchant, or is inactive.
Recover from a challenge#
Flint counts failed codes for the checkout session, for the client IP, and for your merchant environment. After enough failures in an hour, every lookup needs a fresh proof from the buyer's browser, even with a valid code. Gift cards lists the thresholds. The apply request then fails with 400 GIFT_CARD_CHALLENGE_REQUIRED:
{
"error": {
"type": "validation_error",
"code": "GIFT_CARD_CHALLENGE_REQUIRED",
"message": "complete the gift card challenge, then send its proof in Flint-Gift-Card-Challenge",
"param": "Flint-Gift-Card-Challenge",
"reason": "proof_required",
"remediation": {
"retryable": false,
"next_actions": [
{
"action_type": "complete_gift_card_challenge",
"url": "https://checkout.withflintpay.com/gift-card-challenge/gccf_example"
}
]
}
}
}
The same url is on the session as gift_card_challenge.url while the session is open. Treat it as opaque, and never build it yourself.
- Your backend tells the browser a challenge is needed and passes along the
url. - The browser loads
urlin an iframe on your checkout page. It stays on your page, and the code stays in your form. - The challenge page posts a message to your page when the buyer passes. Your page sends the
proofin it to your backend, over your CSRF-protected session. - Your backend repeats the same apply request with
Flint-Gift-Card-Challenge: <proof>. Reuse the sameIdempotency-Key: an apply error is safe to retry, and a replay of a recorded success returns it without spending the new proof.
A proof authorizes one lookup, for its own checkout session, for five minutes. It is spent before the lookup runs, so a code that turns out to be unavailable still uses it. While a challenge is required, every code needs a new proof, so load the iframe again (remount it) for each attempt. The requirement can last up to an hour after the failures that triggered it.
The reason field says what happened:
reason | Meaning | What to do |
|---|---|---|
proof_required | No proof was sent. | Show the challenge at remediation.next_actions[0].url. |
proof_rejected | The proof is malformed, unknown, expired, already used, or was issued for another session, merchant, environment, or mode. | Show the challenge again for a new proof. |
page_origin_required | The session is embedded and has no page_origin, so it can't show the challenge. There is no next action. | Create or replace the session with page_origin. |
Show the challenge#
Allow Flint's checkout origin in your page's frame-src: https://checkout.withflintpay.com in production. You need no script-src change, because your page loads no Flint script. Don't set a sandbox attribute; if you must, include allow-scripts and allow-same-origin. The iframe needs about 65 pixels of height and at least 300 pixels of width.
Your page must be the session's page_origin, and it can't be framed by another origin, because every ancestor has to be allowed. Pages that send Cross-Origin-Embedder-Policy: require-corp aren't supported.
function showGiftCardChallenge({ challengeUrl, checkoutSessionId, onProof, onFailure }) {
const frameOrigin = new URL(challengeUrl).origin;
const frame = document.createElement("iframe");
frame.src = challengeUrl;
frame.title = "Gift card verification";
frame.style.cssText = "border:0;width:100%;min-width:300px;height:65px";
function onMessage(event) {
if (event.origin !== frameOrigin || event.source !== frame.contentWindow) return;
const message = event.data;
if (!message || message.checkout_session_id !== checkoutSessionId) return;
if (message.type === "flint.gift_card_challenge.completed") {
cleanup();
onProof(message.proof);
} else if (message.type === "flint.gift_card_challenge.failed") {
cleanup();
onFailure(message.reason);
}
}
// A frame that never answers (blocked, offline) must not leave the buyer waiting.
const timer = setTimeout(() => { cleanup(); onFailure("timeout"); }, 60_000);
function cleanup() {
clearTimeout(timer);
window.removeEventListener("message", onMessage);
frame.remove();
}
window.addEventListener("message", onMessage);
document.querySelector("#gift-card-challenge").append(frame);
}
The page sends one message and then does nothing more. It never receives messages, and it posts only to your page_origin, never to *. To get another proof, load the URL again.
Message type | Fields | When |
|---|---|---|
flint.gift_card_challenge.completed | checkout_session_id, proof, expires_at (RFC 3339 UTC, five minutes after issue) | The buyer passed. |
flint.gift_card_challenge.failed | checkout_session_id, reason | verification_failed: the challenge didn't verify, so load the page again. unavailable: the challenge can't run now, because the session isn't open, verification isn't configured, the provider is unreachable, or the buyer is rate limited. |
Accept a message only when event.origin equals the origin of gift_card_challenge.url, event.source is your iframe's contentWindow, type is one of these two, and checkout_session_id is the session on your page. Ignore any other type: Flint can add messages and fields. The check can ask the buyer to interact, so allow it at least a minute before you give up.
Send the proof only to your own backend endpoint for this checkout. Don't store it or log it.
Test it in a sandbox#
A sandbox session shows the same challenge page, and it always passes. Proofs are still bound to the session, single use, and good for five minutes, so you can test the whole flow, including a rejected or reused proof. In test mode, page_origin can be http://localhost:3000.
Your backend's IP#
Flint counts failures against the IP address your backend calls from, not the buyer's, and it accepts no buyer IP from you. Buyers who go through one backend share the ten-failure IP count, and other merchants on the same hosting egress can share it too. That only causes more challenges, never fewer.
Affirm and ACH debit#
Add affirm or ach_debit to payments.enabled_payment_options, or leave the field out to inherit your checkout settings. Both use the same steps as a card: read payment_collection, mount the Payment Element, create a ConfirmationToken, and pay the order with the checkout headers. Turn each on first, as Affirm payments and ACH debit payments describe.
Affirm sends the buyer away#
The buyer approves the plan on Affirm's site or app, then comes back to your checkout. Tell Flint where to send them with redirects.success_redirect_url when you create the session:
{
"surface": "embedded",
"order_id": "ord_1kmn0aExample",
"payments": {"enabled_payment_options": ["card", "affirm"]},
"redirects": {"success_redirect_url": "https://shop.example.com/checkout/cart_123/return"}
}
- An embedded session that offers Affirm without this URL fails with
EMBEDDED_PAYMENT_RETURN_URL_REQUIRED. - The URL must be an absolute HTTPS URL, or the request returns
INVALID_URL. In test mode,http://localhostandhttp://127.0.0.1with any port also work. - The URL can't include credentials, a query string, or a fragment, or the request returns
PAYMENT_RETURN_URL_INVALID. Put your own checkout ID in the path, as above, so the return page knows which checkout came back. - When Affirm is offered,
payment_collection.stripe.return_urlis Flint's return relay. Create the ConfirmationToken with that exactreturn_url, not your own page. - Run the attempt's pending
client_actionwithstripe.handleNextAction, as for 3D Secure. For Affirm, it sends the buyer to Affirm. - Affirm returns the buyer to Flint's relay, which sends them on to
success_redirect_urlwith nothing about the outcome in the URL. On that page, read the order and the attempt from your backend and continue as in step 11. - Affirm needs at least 15 minutes left in the session's payment window. Inside the last 15 minutes, the payment fails with
PAYMENT_ACTION_WINDOW_TOO_SHORTbefore the buyer is sent anywhere, so offer card instead.
Affirm payments covers eligibility, amount limits, declines, and refunds.
ACH debit settles later#
Bank verification and the debit authorization run inside the Payment Element, so there is nothing extra to build there. Create the ConfirmationToken with the buyer's billing name and email: without them the payment fails with ACH_BILLING_DETAILS_REQUIRED.
After the buyer submits, the attempt stays processing until the buyer's bank answers, which takes days. It is not resumable meanwhile, and the order is not paid.
- Show the buyer that the payment is processing. Don't tell them it failed, and don't start another payment for the order.
- Fulfill from
order.paid, never from the pay response. - An order with a line item that tracks inventory is card only, because Flint does not hold stock while a debit is unresolved.
ACH debit payments covers settlement, failures, and bank returns.
Collect an invoice or a return balance#
An invoice and a Return resolution each own collection for their order, so each has its own route for launching a checkout session. POST /v1/checkout-sessions refuses both: an invoiced order fails with INVOICE_LOCKED_ORDER_FINANCIALS, and a return's replacement order fails with 409 RETURN_CHECKOUT_REQUIRED, whose return_resolution_id names the resolution to launch from instead.
| Collects | With your API key | With a customer session |
|---|---|---|
| An order | POST /v1/checkout-sessions | Not available |
| An invoice | POST /v1/invoices/{invoice_id}/checkout-session | POST /v1/me/invoices/{invoice_id}/checkout-session |
| A return or exchange balance | POST /v1/return-resolutions/{return_resolution_id}/checkout-session | POST /v1/me/return-resolutions/{return_resolution_id}/checkout-session |
All three take the same surface, redirects, and page_origin, answer with the same fields, and follow the same rules for reuse.
Request an embedded session#
Send "surface": "embedded" to collect in your own checkout. Without it, the session is hosted.
curl -X POST https://api.withflintpay.com/v1/invoices/inv_1kmn0aExample/checkout-session \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: pay-inv-1kmn0a-embedded" \
-H "Content-Type: application/json" \
-d '{
"surface": "embedded",
"redirects": {"success_redirect_url": "https://shop.example.com/invoices/inv_1kmn0aExample/return"}
}'
redirects is the same object order checkout takes, with the same rule: an embedded session whose payment options include Affirm needs redirects.success_redirect_url, or the request fails with EMBEDDED_PAYMENT_RETURN_URL_REQUIRED. An invoice request can also send return_url. When it sends both, they must match, or the request fails with INVALID_RETURN_URL.
Send page_origin too, with the origin of the page that renders the checkout, to show a gift card challenge. It follows the rules in Decide whether you need a checkout session. Unlike on POST /v1/checkout-sessions, an invoice or return launch can change it after creation:
- An explicit
page_originreplaces the origin on a reused open embedded session. - Without
page_origin, a reused session keeps its origin. A new embedded session that replaces an earlier one for the same invoice or return balance, such as after it expired or ended, inherits that session's origin if it is still valid. - Send
"surface": "embedded"on every launch. A launch withsurfaceomitted orhostednever inherits an origin, and sendingpage_originwith it fails withINVALID_PAGE_ORIGIN.
Read the response#
{
"data": {
"invoice": {"invoice_id": "inv_1kmn0aExample", "status": "open"},
"invoice_payment_attempt": {"invoice_payment_attempt_id": "invpa_1kmn0aExample", "status": "open"},
"checkout_session": {
"checkout_session_id": "cs_1kmn0aExample",
"surface": "embedded",
"status": "open",
"order_id": "ord_1kmn0aExample",
"invoice_id": "inv_1kmn0aExample",
"payment_collection": {
"stripe": {
"account_id": "acct_1kmn0aExample",
"publishable_key": "pk_test_1kmn0aExample",
"elements": {"mode": "payment"}
}
}
},
"checkout_access": {
"checkout_auth_token": "ckat_1kmn0aExample"
},
"reused_existing": false
}
}
checkout_access.checkout_auth_tokenis the checkout credential. Store and relay it as in step 5.checkout_session.urlis present only for a hosted session.checkout_session.payment_collectionis where the Stripe account, publishable key, and Elements options live. Mount Elements from it, as in step 9.reused_existingistruewhen Flint returned a session that already existed. For order checkout, that happens only when you retry with the originalIdempotency-Key.- An invoice response also carries
invoiceandinvoice_payment_attempt.
From here the session works like an order checkout: read the order in checkout_session.order_id with the checkout headers, collect the credential, and pay that order with POST /v1/orders/{order_id}/pay. For an invoice with a payment schedule, payment_collection describes the installment being collected. Read the amounts from the order rather than from the invoice.
Reuse and switching#
An invoice or a return has at most one open checkout session at a time. A launch request finds it and decides:
- Same surface, session open. Flint returns that session with a new credential, and
reused_existingistrue. - Other surface, no payment in progress. Flint closes the open session and creates one with the surface you asked for.
reused_existingisfalse. - Other surface, payment in progress. The request fails with
409 CHECKOUT_SURFACE_CHANGE_NOT_ALLOWED. The error names the open session inexisting_checkout_session_idand itssurface.
A payment attempt never moves between surfaces. To switch, finish or cancel the payment on the existing session, or request its current surface.
Invoicing and Return resolutions describe what each launch route checks before it creates a session.
Start a subscription#
For a known customer and an existing plan, create an embedded checkout with subscription_plan_id, collect a payment credential, and submit it to the checkout's order. Flint saves the payment method and creates the subscription as part of completing that order.
Create the checkout on your backend:
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: signup-customer-123" \
-H "Content-Type: application/json" \
-d '{
"surface": "embedded",
"subscription_plan_id": "plan_1kmn0aExample",
"customer_collection": {"customer_id": "cus_1kmn0aExample"},
"payments": {"enabled_payment_options": ["card"]}
}'
Use data.checkout_session.order_id and relay the checkout credential. Read the session and order with checkout authentication before collection. The order's settlement_amounts.outstanding_money determines which path to use:
| Amount due now | Collection guidance | Order pay request |
|---|---|---|
| Greater than zero | payment_collection | action: "pay" with payment_source |
| Zero, including a trial without an initial charge | setup_collection | action: "setup" with setup_payment_source |
Use the chosen collection block's Stripe account and Elements options to create the credential. A trial with a setup fee or another initial charge uses the paid path. Follow the returned collection guidance rather than inferring the amount from the plan price.
Show the renewal terms next to your pay button. The session's subscription_terms holds the terms the subscription bills on: recurring_total_money per billing_interval_count × billing_interval, before tax, plus trial_period_days, contract_term_months and early_termination_fee_money when the plan has them. Flint freezes them when it creates the checkout's order, so a later plan change doesn't alter them. Many US states require the price, how often it renews, and how to cancel to appear beside the button the buyer presses to subscribe.
For an initial charge, use the one-shot payment request. The subscription charges its renewals without the buyer present, so the payment keeps the card for them:
- Create both Elements groups, the card fields and the Apple Pay and Google Pay group, with
setupFutureUsage: "off_session". Only a card or a wallet card can pay a subscription, and the collection guidance lists no other payment method for one. - The Payment Element then shows a line saying the buyer allows future charges, which is what the subscription does.
- A backend that pays with a Stripe payment method ID in
payment_source.tokenneeds nothing more: Flint confirms the payment withsetup_future_usage: "off_session".
When the payment succeeds, Stripe attaches the card to the subscription's customer, and Flint saves it with usage: "off_session" and creates the subscription with it.
Zero-balance collection returns next_step: "collect_setup_payment_source" and Elements mode: "setup". After elements.submit() succeeds, call stripe.createPaymentMethod({elements}) and send the returned payment method ID in setup_payment_source.token:
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: signup-customer-123-attempt-1" \
-H "Content-Type: application/json" \
-d '{
"action": "setup",
"setup_payment_source": {"token": "pm_1kmn0aExample"},
"expected_outstanding_money": {"amount": 0, "currency": "USD"}
}'
Use the currency and outstanding amount from the fresh order read. Browser requests through your backend relay use checkout authentication. Flint owns confirmation for both paths. Run any returned client action and resume the same attempt with action: "resume" and order_payment_attempt_id, without resending the credential.
A checkout created without customer_collection.customer_id saves the card to the customer whose email the buyer confirmed with a code, or else to your customer with the buyer_contact.email, creating one if needed. For an initial charge, Flint picks that customer when the payment starts, because Stripe attaches the card to it when the payment succeeds; a zero-balance setup picks it after the setup succeeds. Flint links the subscription and the order to that customer only after the payment or setup succeeds, as it does when a paid order settles. The link gives the checkout credential no access to that customer's other saved payment methods or details. To save the card to a known customer from the start, create the checkout with their customer_collection.customer_id.
After the attempt succeeds, read the order's subscription_id and the subscription. Verify the customer binding, subscription status (active or trialing), and active saved payment method. Grant access from subscription webhooks or an authoritative backend read. Do not call POST /v1/subscriptions after this signup; the completed order already created it.
To show the new subscription on your confirmation page, your relay can read it with the checkout headers instead of your API key:
GET /v1/subscriptions/sub_1kmn0aExample
X-Checkout-Session-ID: cs_1kmn0aExample
X-Checkout-Session-Secret: ckat_1kmn0aExample
The checkout credential reads only the subscription its own checkout created, and only once the session is paid. Before then the read returns 403 CHECKOUT_SESSION_PAYMENT_REQUIRED. The response is the same Subscription schema without the fields only your API key can read, and expand is not available with checkout authentication.
Develop locally#
A sandbox key works against the same API host as a live key, so your backend can run on your machine:
- In test mode,
redirects.success_redirect_urland the invoice and returnreturn_urlaccepthttp://localhostandhttp://127.0.0.1with any port. Live mode needs HTTPS. - Apple Pay doesn't show on
localhost. It needs an HTTPS hostname registered withPOST /v1/payment-method-domains, so test it through an HTTPS tunnel to your machine, or on a staging hostname, after registering that hostname. A missing Apple Pay button onlocalhostdoesn't mean wallets are broken. See Apple Pay and Google Pay setup.
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, a tip and a promotion code applied after delivery is selected, 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. If you take gift cards, also test the challenge: five failed codes, a valid code that needs a proof, a reused proof, and a frame that never answers. If you offer them, also test Affirm approval, decline, and abandonment, an ACH debit that settles and one that the bank rejects, and switching an invoice or return checkout between surfaces.
See Testing for the card matrix.
Related#
- Embedded payments with Stripe Elements: payment collection.
- Declines and payment attempts: the recovery model in full.
- Checkout sessions: session lifecycle, states, and what a checkout credential may do.
- Securing a headless checkout: the trust boundary, the controls that become yours, and PCI scope.
- Affirm payments and ACH debit payments: the two payment options that don't finish in one request.
- Invoicing and Return resolutions: the resources behind invoice and return balance checkout.
