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-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 source | Browser | stripe.createPaymentMethod returns a pm_... token |
| 5. Pay the order | Your backend | Submit the selected leg and token to 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.
<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 a Payment Source Token#
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.
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 sends only payment_attempt_id. Do not resend the token, selected legs, expected balance, or completion behavior.
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": {"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#
| 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 |
