Embedded Payments with Stripe Elements

Use this flow when checkout lives on your domain. Your frontend mounts Stripe Elements with collection guidance from Flint, creates a payment source token, and submits that token to PayOrder. Flint owns confirmation and order settlement.

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 sourceBrowserstripe.createPaymentMethod returns a pm_... token
5. Pay the orderYour backendSubmit the selected leg and token to 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.

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 a Payment Source Token#

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

const { error, paymentMethod } = await stripe.createPaymentMethod({
  elements,
  params: { billing_details: { email: buyerEmail } },
});
if (error) {
  showMessage(error.message);
  return;
}

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

The pm_... value is a one-time start credential in this flow. 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 its token in the same entry.

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 sends only payment_attempt_id. Do not resend the token, selected legs, expected balance, or completion behavior.

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": {"token": "pm_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 token.

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