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-intents mutation 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#

StepRuns onWhat happens
1. Create an orderYour backendRecord what the buyer is purchasing
2. Create an order payment legYour backendFreeze an amount and receive Elements guidance
3. Mount ElementsBrowserCollect card or supported wallet details securely
4. Create a payment credentialBrowserFollow next_step to create a pm_... PaymentMethod or single-use ctoken_... ConfirmationToken
5. Pay the orderYour backendBind one credential to each selected leg and call PayOrder
6. Complete authenticationBrowser and backendRun the returned client action, then resume by attempt ID
7. Verify and fulfillYour backendAct 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.

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

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

HTML
<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>
JavaScript
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#

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

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

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

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

JavaScript
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;
}
Bash
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:

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

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

TypeScript
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#

StatusCodeWhat to do
409ORDER_PAYMENT_FLOW_REQUIREDUse the order-scoped create, pay, capture, or cancel route for an order-linked PaymentIntent
409ORDER_CHANGED_REFRESH_REQUIREDRefresh the collection context and show the buyer the new outstanding amount
400PAYMENT_ATTEMPT_RESUME_CONFLICTResume with payment_attempt_id only
400ORDER_OWNED_PAYMENT_INTENT_REQUIREDCreate the leg through the order instead of selecting a standalone PaymentIntent
409PAYMENT_LEG_SELECTION_REQUIREDSelect existing order-owned legs, cancel stale legs, or use the valid one-shot path
400PAYMENT_START_SHAPE_CONFLICTSend exactly one start shape
Rate this doc