Embedded Payments with Stripe Elements
Use this flow when checkout lives on your domain. Your frontend mounts Stripe Elements with collection guidance from Flint and performs the operation named by next_step. Flint owns confirmation and order settlement.
Dispatch on the returned next_step rather than on how you authenticated. The default guidance differs by credential: a checkout-authenticated order read returns create_confirmation_token, and a merchant-authenticated one returns collect_payment_source. That is the guidance, not a restriction on the API. PayOrder accepts confirmation_token under either credential, so a card-only backend checkout can collect a ConfirmationToken and pay with a merchant key and no checkout session at all.
This page is the collection mechanics. For the whole merchant-owned checkout, including prerequisites, the backend-for-frontend boundary, delivery, session lifecycle, and fulfillment, start with Build your own checkout. If you prefer a Flint-hosted page, start with Accept Your First Payment.
Use https://api.withflintpay.com for sandbox and live traffic. A flint_test_... key uses Stripe test mode in your isolated sandbox.
The Ownership Rule#
Order-linked PaymentIntents are created and managed through /v1/orders:
- Create a leg with
POST /v1/orders/{order_id}/payment-intents. - Confirm or resume it with
POST /v1/orders/{order_id}/pay. - Capture an authorization or cancel any unsettled leg through the order-scoped routes.
- Use top-level
/v1/payment-intentsmutation routes only for standalone payments.
Do not call stripe.confirmPayment for an order-linked PaymentIntent. payment_collection is guidance for collecting a source. It is not confirmation authority and never contains the PaymentIntent client secret.
Flow Overview#
| Step | Runs on | What happens |
|---|---|---|
| 1. Create an order | Your backend | Record what the buyer is purchasing |
| 2. Create an order payment leg | Your backend | Freeze an amount and receive Elements guidance |
| 3. Mount Elements | Browser | Collect card or supported wallet details securely |
| 4. Create a payment credential | Browser | Follow next_step to create a pm_... PaymentMethod or single-use ctoken_... ConfirmationToken |
| 5. Pay the order | Your backend | Bind one credential to each selected leg and call PayOrder |
| 6. Complete authentication | Browser and backend | Run the returned client action, then resume by attempt ID |
| 7. Verify and fulfill | Your backend | Act on order.paid or fetch the order |
Step 1: Create an Order#
Amounts are integers in the currency's minor unit, so 9900 is $99.00.
curl -X POST https://api.withflintpay.com/v1/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: order-pro-annual-001" \
-d '{
"line_items": [{
"name": "Pro Plan Annual",
"quantity": 1,
"unit_price_money": {"amount": 9900, "currency": "USD"}
}]
}'
{
"data": {
"order_id": "ord_1kmn0aExample",
"status": "open",
"payment_status": "unpaid",
"settlement_amounts": {
"outstanding_money": {"amount": 9900, "currency": "USD"}
}
},
"request_id": "req_..."
}
status is the workflow axis. payment_status is the collection axis. An unpaid order is normally open; a fully paid order becomes closed and paid.
Step 2: Create an Order Payment Leg#
Create the PaymentIntent through the order. Omit amount_money to allocate the full currently available balance.
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/payment-intents \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: pi-order-pro-annual-001" \
-d '{
"payment_source_selection": {
"card": {"digital_wallets": ["apple_pay", "google_pay"]}
}
}'
{
"data": {
"payment_intent": {
"payment_intent_id": "pi_1kmn0aExample",
"order_id": "ord_1kmn0aExample",
"status": "requires_payment_method",
"amount_money": {"amount": 9900, "currency": "USD"},
"capture_method": "automatic"
},
"payment_collection": {
"stripe": {
"account_id": "acct_1AbcPlaceholder",
"publishable_key": "pk_test_51AbcPlaceholder",
"elements": {
"next_step": "collect_payment_source",
"submit_to": "pay_order",
"mode": "payment",
"amount_money": {"amount": 9900, "currency": "USD"},
"payment_method_types": ["card"],
"digital_wallets": ["apple_pay", "google_pay"],
"payment_method_creation": "manual"
}
}
}
},
"request_id": "req_..."
}
Store payment_intent.payment_intent_id. Send only the payment_collection.stripe fields to the browser. Keep your Flint API key on the backend.
You may create multiple order-owned legs as long as their active amounts fit the order's available collection capacity. Use explicit amount_money when preparing split tender or partial payment.
Step 3: Mount Stripe Elements#
Initialize Stripe.js from the returned guidance.
Apple Pay and Google Pay render in Elements only on domains registered for wallet payments. Register your checkout hostname once with POST /v1/payment-method-domains before testing wallets on your domain; if the wallet buttons stay hidden after registering, work through Apple Pay domain verification failed.
<script src="https://js.stripe.com/v3/"></script>
<form id="payment-form">
<div id="payment-element"></div>
<button id="submit" type="submit">Pay $99.00</button>
<div id="payment-message" role="alert"></div>
</form>
const stripe = Stripe(paymentCollection.stripe.publishable_key, {
stripeAccount: paymentCollection.stripe.account_id,
});
const guidance = paymentCollection.stripe.elements;
const elements = stripe.elements({
mode: guidance.mode,
amount: guidance.amount_money.amount,
currency: guidance.amount_money.currency.toLowerCase(),
paymentMethodCreation: guidance.payment_method_creation,
paymentMethodTypes: guidance.payment_method_types,
});
elements.create("payment").mount("#payment-element");
paymentMethodCreation: "manual" is required because the browser creates a source and Flint confirms it later.
Step 4: Create the Requested Credential#
const { error: submitError } = await elements.submit();
if (submitError) {
showMessage(submitError.message);
return;
}
let credential;
let error;
if (guidance.next_step === "create_confirmation_token") {
const result = await stripe.createConfirmationToken({
elements,
params: {
payment_method_data: {billing_details: {email: buyerEmail}},
},
});
credential = result.confirmationToken && {
confirmation_token: result.confirmationToken.id,
};
error = result.error;
} else if (guidance.next_step === "collect_payment_source") {
const result = await stripe.createPaymentMethod({elements});
credential = result.paymentMethod && {token: result.paymentMethod.id};
error = result.error;
} else {
throw new Error(`Unsupported payment collection step: ${guidance.next_step}`);
}
if (error) {
showMessage(error.message);
return;
}
await fetch("/checkout/pay", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({credential}),
});
Dispatch only on the returned next_step. A ctoken_... value is single-use and bound to one payment leg. Do not persist it as retry authority or resend it on attempt resume.
Step 5: Start Payment#
Your backend selects the order-owned leg and sends the credential in the same entry. This merchant-authenticated example uses the pm_... PaymentMethod returned by collect_payment_source.
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: pay-pro-annual-attempt-1" \
-d '{
"payment_intents": [{
"payment_intent_id": "pi_1kmn0aExample",
"token": "pm_1kmn0aExample"
}],
"expected_outstanding_money": {"amount": 9900, "currency": "USD"},
"completion_behavior": "complete_order",
"buyer_email": "ada@example.com"
}'
expected_outstanding_money is the balance the buyer approved. If the live collection context changed, Flint returns ORDER_CHANGED_REFRESH_REQUIRED before charging.
A successful response contains both the updated order and the terminal attempt:
{
"data": {
"order": {
"order_id": "ord_1kmn0aExample",
"status": "closed",
"payment_status": "paid",
"settlement_amounts": {
"paid_money": {"amount": 9900, "currency": "USD"},
"outstanding_money": {"amount": 0, "currency": "USD"}
}
},
"payment_attempt": {
"payment_attempt_id": "opat_1kmn0aExample",
"status": "succeeded",
"is_resumable": false,
"mode": "payment",
"expected_outstanding_money": {"amount": 9900, "currency": "USD"},
"payment_intents": [{
"payment_intent_id": "pi_1kmn0aExample",
"status": "succeeded",
"amount_money": {"amount": 9900, "currency": "USD"},
"tip_money": {"amount": 0, "currency": "USD"}
}]
}
},
"request_id": "req_..."
}
Step 6: Complete 3D Secure#
When authentication is required, payment_attempt.status is requires_action, is_resumable is true, and the attempt contains a pending action:
{
"payment_attempt": {
"payment_attempt_id": "opat_1kmn0aExample",
"status": "requires_action",
"is_resumable": true,
"pending_actions": [{
"pending_action_id": "pendact_1kmn0aExample",
"subject": {
"payment_intent": {"payment_intent_id": "pi_1kmn0aExample"}
},
"action_type": "payment_authentication",
"client_action": {
"stripe": {
"account_id": "acct_1AbcPlaceholder",
"publishable_key": "pk_test_51AbcPlaceholder",
"payment_intent": {
"stripe_js_call": "handle_next_action",
"client_secret": "pi_3AbcPlaceholder_secret_XyzPlaceholder"
}
}
}
}]
}
}
Run the named Stripe.js action, then resume the frozen attempt. Resume needs only payment_attempt_id. Do not resend credentials, selected legs, or completion behavior; those are payment start fields and a resume that carries them returns PAYMENT_ATTEMPT_RESUME_CONFLICT.
You may resend expected_outstanding_money. On a resume it is checked against the attempt's frozen expectation rather than the live order balance, and a mismatch returns ORDER_CHANGED_REFRESH_REQUIRED. Keeping it is a guard that the client is continuing the amount the buyer approved.
const action = paymentAttempt.pending_actions[0].client_action.stripe;
const {error} = await stripe.handleNextAction({
clientSecret: action.payment_intent.client_secret,
});
if (error) {
showMessage(error.message);
return;
}
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: pay-pro-annual-resume-1" \
-d '{"payment_attempt_id": "opat_1kmn0aExample"}'
Resume the same attempt only when is_resumable is true. A failed or canceled attempt is finished and requires a new allowed payment start.
One status is neither: finalizing means the payment settled and Flint is still applying it to the order. It is not resumable, but the buyer has been charged, so wait and re-read the attempt instead of starting a new payment.
For the full recovery model, including declines, last_payment_error, finalizing, and recovering a lost response through active_payment_attempt, see Handling Declines & Payment Attempts.
Step 7: Verify and Fulfill#
Use order.paid as the durable fulfillment signal. A fetch is also valid:
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
-H "Authorization: Bearer YOUR_API_KEY"
Fulfill only when payment_status is paid. status: "closed" is the terminal workflow state; refunds do not reopen it or change payment_status.
One-Shot and Saved-Method Variants#
For a buyer submit path that does not need a pre-created leg, send a one-shot credential:
{
"payment_source": {"confirmation_token": "ctoken_1kmn0aExample"},
"expected_outstanding_money": {"amount": 9900, "currency": "USD"}
}
Flint creates the full-balance automatic-capture leg and starts the same attempt flow. For a saved Flint payment method, use payment_source.payment_method_id instead of confirmation_token.
Embedded Checkout Authentication#
Create the checkout session on your backend with surface: "embedded". The response includes checkout_access.checkout_auth_token and omits checkout_access.hosted_url. Keep that credential in your backend session and use it only with the returned checkout session ID:
const checkout = Flint.forCheckout({
checkoutSessionId,
checkoutSessionSecret: checkoutAuthToken,
});
Checkout-authenticated requests send X-Checkout-Session-ID and X-Checkout-Session-Secret. Do not send Authorization or X-API-Key on the same request. The checkout credential can read its session and order, apply buyer-owned delivery, promotion, tip, and tax choices, list the checkout customer's active saved methods, start or resume payment, and resend the owning order's receipt.
Order payment collection guidance returned under checkout authentication uses next_step: "create_confirmation_token". Send the resulting ctoken_... as confirmation_token on the selected payment leg. Merchant authentication still defaults to next_step: "collect_payment_source" and the token field, so existing integrations do not change behavior. That default is about the guidance Flint returns, not about what PayOrder accepts: confirmation_token is valid under a merchant key too.
Flint does not support direct merchant-browser API access. Route checkout operations through your backend, protect them with your own session and CSRF controls, and do not place the checkout credential in local storage, URLs, logs, or analytics payloads.
Manual Capture#
Create one order-owned leg with "capture_method": "manual", then start it through PayOrder. An authorized order remains open and unpaid while the attempt reports requires_capture.
- Capture:
POST /v1/orders/{order_id}/payment-intents/{payment_intent_id}/capture - Cancel:
POST /v1/orders/{order_id}/payment-intents/{payment_intent_id}/cancel
Capture requires the active payment_attempt_id in the body. Cancel also requires it when the leg belongs to an active attempt, including an open authorization. A staged or declined leg with no active attempt can be canceled without an attempt ID, which lets a one-shot caller discard a declined leg before submitting a fresh one-shot payment. Multi-leg delayed capture is rejected; use one manual-capture leg.
Common Errors#
| Status | Code | What to do |
|---|---|---|
| 409 | ORDER_PAYMENT_FLOW_REQUIRED | Use the order-scoped create, pay, capture, or cancel route for an order-linked PaymentIntent |
| 409 | ORDER_CHANGED_REFRESH_REQUIRED | Refresh the collection context and show the buyer the new outstanding amount |
| 400 | PAYMENT_ATTEMPT_RESUME_CONFLICT | Resume with payment_attempt_id only |
| 400 | ORDER_OWNED_PAYMENT_INTENT_REQUIRED | Create the leg through the order instead of selecting a standalone PaymentIntent |
| 409 | PAYMENT_LEG_SELECTION_REQUIRED | Select existing order-owned legs, cancel stale legs, or use the valid one-shot path |
| 400 | PAYMENT_START_SHAPE_CONFLICT | Send exactly one start shape |
