Embedded payments with Stripe Elements

Collect the card on your own page and let Flint confirm the payment. Your backend creates an order and reads its collection guidance. Your browser code mounts Stripe Elements from that guidance and turns the card into a single-use Stripe credential. Your backend sends the credential to Flint, and Flint confirms the payment, handles 3D Secure, and settles the order. Card details never reach your servers or Flint's.

Use this flow when checkout lives on your domain. If a Flint-hosted page is enough, start with Accept your first payment instead. Delivery, session lifecycle, and the backend-for-frontend boundary are in Build your own checkout.

Before you start#

Get a test API key. Create one on the dashboard's API keys page. It starts with flint_test_ and is bound to a sandbox, so payments run in Stripe test mode and no real money moves. Test and live keys share https://api.withflintpay.com, and the key decides the mode.

Give the key commerce.orders.write. A write permission also reads, so it covers every request below. Registering a wallet domain needs payments.payment_method_domains.write, which belongs on a separate administrative key.

Keep the key on your backend. The browser talks to Stripe.js and to your backend. It never calls the Flint API, though it can frame Flint's gift card challenge page after repeated failed codes. Flint has no publishable key and does not accept requests from merchant browser origins.

Check that your sandbox can take card payments:

cURL
curl "https://api.withflintpay.com/v1/capabilities?capability=accept_card_payments" \
  -H "Authorization: Bearer YOUR_API_KEY"

status: "ready" means the sandbox can charge cards.

Warning:

A 200 response confirms the request worked, not that the sandbox can charge cards. While the capability is pending or blocked, the response's blocked_reasons and requirements name what is still due. Complete it in the dashboard, then run the check again.

Register your checkout hostname for wallets. Apple Pay and Google Pay buttons render only on HTTPS hostnames registered with POST /v1/payment-method-domains. Apple Pay and Google Pay setup covers registering, checking, and testing each hostname.

Who confirms the payment#

Flint does. Every PaymentIntent attached to an order is created, confirmed, captured, and canceled through /v1/orders:

  • POST /v1/orders/{order_id}/pay charges the full balance from a credential in payment_source. It also confirms and resumes payment legs you created earlier.
  • POST /v1/orders/{order_id}/payment-intents creates a payment leg ahead of time. A leg is one PaymentIntent on the order. You need one only for split tender, partial payment, manual capture, or a staged amount.
  • The order-scoped capture and cancel routes settle or release an unsettled leg.
  • The top-level /v1/payment-intents mutation routes are for standalone payments. Calling one for an order's PaymentIntent returns 409 ORDER_PAYMENT_FLOW_REQUIRED. The error identifies the order in details[].blocking_resources and gives the concrete order route in remediation.next_actions[].url, with required_scope: "commerce.orders.write". Follow that URL with POST and send any required_fields in the body. Capture always lists order_payment_attempt_id, and cancel lists it when the PaymentIntent is authorized and waiting for capture.

Collect split tender in your own payment UI. Hosted checkout pays an order with one payment method, so it collects at most one unpaid leg, meaning a leg still waiting for a payment method or for its first confirmation. Creating a hosted checkout session for an order with two or more unpaid legs returns 409 CHECKOUT_SPLIT_PAYMENT_UNSUPPORTED. While a hosted session is open, creating a leg on an order that already has an unpaid one returns 409 ORDER_PAYMENT_LEG_CHECKOUT_ACTIVE. Both errors list the unpaid legs in payment_intent_ids, and POST /v1/orders/{order_id}/payment-intents/{payment_intent_id}/cancel releases one. An embedded checkout session has no such limit, because your UI collects each leg.

Never call stripe.confirmPayment for an order's PaymentIntent. The order's payment_collection tells the browser how to collect a card. It never contains a PaymentIntent client secret, so there is nothing for the browser to confirm. The only client secret Flint returns is scoped to one authentication step, in step 6.

The flow#

Embedded payment flowResponse
BrowserYour backendFlint1. create an order2. read the orderoutstanding balance and payment_collection3. mount Elements from payment_collection4. create the credential named by next_steppm_... or ctoken_...5. pay with payment_sourcepayment attempt, with a client action if authentication is needed6. client actionauthentication completeresume the attempt7. order.paid webhook, or read the order
  1. Your backend sends 1. create an order to Flint
  2. Your backend sends 2. read the order to Flint
  3. Flint returns outstanding balance and payment_collection to Your backend
  4. Browser sends 3. mount Elements from payment_collection to Browser
  5. Browser sends 4. create the credential named by next_step to Browser
  6. Browser sends pm_... or ctoken_... to Your backend
  7. Your backend sends 5. pay with payment_source to Flint
  8. Flint returns payment attempt, with a client action if authentication is needed to Your backend
  9. Your backend returns 6. client action to Browser
  10. Browser sends authentication complete to Your backend
  11. Your backend sends resume the attempt to Flint
  12. Flint returns 7. order.paid webhook, or read the order to Your backend

Step 1: create an order#

An order is Flint's record of the sale and the thing the payment settles against. Amounts are integers in the currency's minor unit, so 9900 is $99.00.

cURL
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"}
    }]
  }'
Response
{
  "data": {
    "order_id": "ord_1kmn0aExample",
    "status": "open",
    "payment_status": "unpaid",
    "settlement_amounts": {
      "outstanding_money": {"amount": 9900, "currency": "USD"}
    }
  },
  "request_id": "req_..."
}

status tracks the order's workflow and payment_status tracks collection. An unpaid order is open. Once the full balance is collected it becomes closed and paid.

Step 2: read the collection guidance#

Read the order immediately before you mount Elements. The response carries data.payment_collection, which says what to collect and under which Stripe account. Flint recalculates it on every read. If delivery, tax, or a promotion changes the total, read again and show the buyer the new amount before they pay.

cURL
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "data": {
    "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_..."
}

Send payment_collection to the browser as returned. It holds no secrets: the publishable key and account ID exist for Stripe.js. Reading the order creates no payment leg and holds no funds.

next_step names the Stripe.js call the browser makes and, with it, the field your backend sends to Flint:

next_stepStripe.js callField on payment_source
collect_payment_sourcestripe.createPaymentMethodtoken, a pm_...
create_confirmation_tokenstripe.createConfirmationTokenconfirmation_token, a ctoken_...

Which value you get depends on how the read was authenticated. A merchant API key returns collect_payment_source. A checkout session credential returns create_confirmation_token. That is guidance, not a restriction: the pay request accepts either credential under either authentication. Dispatch on the returned value rather than on how you authenticated, so one browser implementation serves both.

Step 3: mount Stripe Elements#

Initialize Stripe.js with the publishable key and connected account from the guidance, then create Elements in deferred mode from the amount, currency, and payment method types.

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. The browser only creates a credential. Flint confirms it in step 5.

Step 4: create the credential#

Call elements.submit() first, then the Stripe.js call named by next_step, then post the result to your backend.

JavaScript
const {error: submitError} = await elements.submit();
if (submitError) {
  showMessage(submitError.message);
  return;
}

let credential;
if (guidance.next_step === "create_confirmation_token") {
  const {error, confirmationToken} = await stripe.createConfirmationToken({
    elements,
    params: {
      payment_method_data: {billing_details: {email: buyerEmail}},
    },
  });
  if (error) {
    showMessage(error.message);
    return;
  }
  credential = {confirmation_token: confirmationToken.id};
} else if (guidance.next_step === "collect_payment_source") {
  const {error, paymentMethod} = await stripe.createPaymentMethod({elements});
  if (error) {
    showMessage(error.message);
    return;
  }
  credential = {token: paymentMethod.id};
} else {
  throw new Error(`Unsupported payment collection step: ${guidance.next_step}`);
}

await fetch("/checkout/pay", {
  method: "POST",
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify({credential}),
});

The credential is single-use and tied to one payment. Send it to your backend over TLS and nowhere else: not to logs, analytics, or browser storage. If the payment needs authentication in step 6, you resume the same attempt without a new credential.

Step 5: pay#

Your backend sends the credential in payment_source with action: "pay". Flint creates a full-balance, automatic-capture payment leg and starts the payment attempt in the same request. This example uses the pm_... from a merchant-authenticated read.

cURL
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 '{
    "action": "pay",
    "payment_source": {"token": "pm_1kmn0aExample"},
    "expected_outstanding_money": {"amount": 9900, "currency": "USD"},
    "buyer_contact": { "email": "ada@example.com" }
  }'

expected_outstanding_money is the balance the buyer approved. If the order's outstanding balance no longer matches it, Flint returns ORDER_CHANGED_REFRESH_REQUIRED before charging. Re-read the order, show the new total, and ask the buyer to approve it. Do not resubmit silently.

buyer_contact.email identifies a guest buyer. For a signed-in buyer, set customer_id on the order before payment instead, so their saved payment methods resolve.

Each action accepts only its own fields, and an extra one returns UNKNOWN_FIELD. pay takes payment_source, expected_outstanding_money, buyer_contact.

A successful response returns the updated order and the finished attempt:

Response
{
  "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": {
      "order_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_..."
}

Read the outcome from payment_attempt, not from the HTTP status. A 200 also carries declines and pending authentication.

Step 6: handle 3D Secure#

When the card needs authentication, the attempt pauses with status: "requires_action", is_resumable: true, and one pending action:

JSON
{
  "payment_attempt": {
    "order_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 Stripe.js call named by stripe_js_call in the browser, using the client secret from the action. Then resume the attempt from your backend with action: "resume" and order_payment_attempt_id.

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;
}
cURL
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 '{"action": "resume", "order_payment_attempt_id": "opat_1kmn0aExample"}'

A resume takes order_payment_attempt_id and nothing else from the start request. Sending payment_source again returns UNKNOWN_FIELD. You may include expected_outstanding_money as a guard. On a resume it is compared to the amount frozen on the attempt rather than the live balance, and a mismatch returns ORDER_CHANGED_REFRESH_REQUIRED.

Two rules for what to do with an attempt:

  • Resume only while is_resumable is true. A failed, canceled, or expired attempt is finished. Start a new payment with a new credential.
  • finalizing is neither. The buyer has been charged and Flint is still applying the payment to the order. Wait and re-read the attempt. Do not start another payment.

Declines, requires_retry, last_payment_error, and recovering a lost response through active_payment_attempt are in Declines and payment attempts.

Step 7: verify and fulfill#

Fulfill from the order.paid webhook or from a fresh read of the order. A buyer can close the tab before your page hears the result, so do not fulfill because the browser reported success.

cURL
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
  -H "Authorization: Bearer YOUR_API_KEY"

Fulfill only when payment_status is paid. status: "closed" means nothing is left to do on the order, and a later refund does not reopen it or change payment_status. An order can also close without payment, so check payment_status, not status.

Other credentials#

All of these use action: "pay" and the same attempt and recovery flow:

  • A ConfirmationToken goes in payment_source.confirmation_token. This is what a checkout-authenticated read asks for, and a merchant key accepts it too. A card-only backend checkout can collect a ConfirmationToken and pay with the API key and no checkout session.
  • A saved Flint payment method goes in payment_source.payment_method_id. A saved payment method already belongs to a customer, so no extra customer field is needed.

Collect under a checkout session#

Create an embedded checkout session when the buyer's side of checkout needs a credential of its own for Flint delivery selection, checkout expiration, the checkout customer's saved payment methods, or a receipt resend. Create it on your backend with surface: "embedded". The response includes checkout_access.checkout_auth_token. Its checkout_session has no url, because there is no Flint page to send the buyer to.

cURL
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: checkout-pro-annual-001" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "surface": "embedded"
  }'

Requests made with the checkout credential send two headers and no API key:

HTTP
X-Checkout-Session-ID: cs_1kmn0aExample
X-Checkout-Session-Secret: ckat_1kmn0aExample

Send exactly one authentication mode per request. Combining either checkout header with Authorization or X-API-Key returns 400 AMBIGUOUS_AUTH. In the SDKs, the checkout auth mode sends these headers for you.

The checkout credential can read its session and order, apply the buyer's delivery, promotion, tip, and tax choices, list the checkout customer's active saved payment methods, start or resume payment, and resend the order's receipt. Refunds, disputes, and other merchant operations use the API key.

Under the checkout credential, the order read returns next_step: "create_confirmation_token". The browser calls stripe.createConfirmationToken, and your backend sends the result in payment_source.confirmation_token with the same two headers.

Keep the credential on your backend, tied to your own signed browser session. Route every checkout call through your backend with your own CSRF and origin checks, and never place the credential in local storage, URLs, logs, or analytics. Securing a headless checkout covers the boundary, including what changes for PCI scope once the payment page runs your JavaScript.

Manual capture#

To authorize now and capture later, create one payment leg with "capture_method": "manual" through POST /v1/orders/{order_id}/payment-intents, then confirm it with action: "confirm_payment_intents" and the leg's ID in payment_intents. The order stays open and unpaid while the attempt reports requires_capture.

Capture with POST /v1/orders/{order_id}/payment-intents/{payment_intent_id}/capture and release the hold with the matching cancel route. Both take the active order_payment_attempt_id while the leg belongs to an active attempt, which includes an open authorization. A staged or declined leg with no active attempt can be canceled without one, so a one-shot integration can discard a declined leg before paying again. Manual capture supports one leg per attempt, and split delayed capture is rejected. Amounts, partial capture, authorization expiry, and the full request sequence are in Manual capture.

Common errors#

  • HTTP 409
    Follow remediation.next_actions[].url with POST using commerce.orders.write. The owning order is in details[].blocking_resources; provide any required_fields
  • HTTP 409
    Re-read the order and show the buyer the new outstanding amount before paying again
  • HTTP 400
    Remove the field named by param; each action accepts only its own fields
  • HTTP 400
    Set action to pay, confirm_payment_intents, setup, or resume
  • HTTP 400
    Create the leg through the order instead of selecting a standalone PaymentIntent
  • HTTP 400
    Select existing order-owned legs, cancel stale legs, or send payment_source for a one-shot payment
  • HTTP 409
    Cancel the legs in payment_intent_ids so hosted checkout collects the balance in one payment, or collect the split payment in your own UI
  • HTTP 409
    Cancel the leg in payment_intent_ids, or close the hosted session in existing_checkout_session_id and collect the split payment in your own UI
  • HTTP 400
    Send either the checkout session headers or your API key, never both.

Was this helpful?