Embedded Payments with Stripe Elements
Use this flow when checkout lives on your own domain: your page, your styling, your form. Your frontend renders the Stripe Payment Element with credentials the Flint API returns, and your backend confirms the payment against the order. If you'd rather redirect to a Flint-hosted page and skip the frontend work entirely, start with Accept Your First Payment.
Flint processes card payments on Stripe. You don't need your own Stripe account; Flint provisions and manages the processing account for you, and every credential your frontend needs comes back from the Flint API.
Use https://api.withflintpay.com for both sandbox and live traffic. Your flint_test_... key runs against Stripe test mode and stays isolated to its sandbox, so you can run everything on this page without moving real money.
How the Flow Works#
One rule shapes this whole integration: your server confirms the payment, not the browser.
In a standalone Stripe integration, the browser calls stripe.confirmPayment and the charge happens from the client. Flint order payments are confirmed server side instead, through the pay endpoint, so the charge amount is validated against the live order balance, coupons and inventory are checked, and the order settles in the same operation as the payment. The browser has exactly three jobs: render the Payment Element, turn card details into a PaymentMethod token, and complete 3D Secure when asked.
| Step | Runs on | What happens |
|---|---|---|
| 1. Create an order | Your backend | POST /v1/orders records what you're selling |
| 2. Create a payment intent | Your backend | POST /v1/payment-intents attaches payment to the order and returns Stripe.js credentials |
| 3. Mount the Payment Element | Browser | The buyer enters card details in Stripe's secure fields |
| 4. Create a PaymentMethod | Browser | stripe.createPaymentMethod returns a pm_... token |
| 5. Pay the order | Your backend | POST /v1/orders/{order_id}/pay confirms with that token |
| 6. Authenticate if required | Browser | stripe.handleNextAction runs 3D Secure, then repeat step 5 |
| 7. Verify and fulfill | Your backend | Webhook or GET /v1/orders/{order_id} |
Do not call stripe.confirmPayment in this flow, and do not call POST /v1/payment-intents/{payment_intent_id}/confirm. Order-linked payment intents require server confirmation: Stripe rejects browser-side confirmation for them, and the standalone confirm endpoint returns ORDER_LINKED_PAYMENT_INTENT. The pay endpoint in step 5 is the one confirmation path.
What You Need#
- A test API key (
flint_test_...). Create one on the dashboard's API keys page, or use the API setup flow. - Stripe.js in the browser: either the
https://js.stripe.com/v3/script tag used below, or the@stripe/stripe-jsnpm package. The calls are identical. - Your Flint API key stays on your backend. The only values that ever reach the browser are the publishable key, the Stripe account id, and the amount.
Confirm your sandbox can take card payments before you start:
curl "https://api.withflintpay.com/v1/capabilities?capability=accept_card_payments" \
-H "Authorization: Bearer YOUR_API_KEY"
A 200 with "status": "ready" means you're set.
The POST examples below send an Idempotency-Key header. On this flow it's more than a good habit: if a pay request times out mid-flight, retrying with the same key replays the original result instead of risking a second charge attempt. See Idempotency.
Step 1: Create an Order#
The order is the commerce record everything else attaches to: totals, the payment, and later refunds. 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",
"pricing_amounts": {
"subtotal_money": {"amount": 9900, "currency": "USD"},
"total_money": {"amount": 9900, "currency": "USD"}
},
"line_items": [...]
}
}
Save data.order_id. The order is open, meaning it has a balance to collect. Line items here are ad hoc (a name and a price); to sell from your catalog, reference a product instead, and pass customer_id to tie the order to a customer record.
Step 2: Create the Order's Payment Intent#
The payment intent is the payment attempt attached to the order. Creating it also returns the Stripe.js credentials your frontend needs.
curl -X POST https://api.withflintpay.com/v1/payment-intents \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: pi-order-pro-annual-001" \
-d '{
"order_id": "ord_1kmn0aExample"
}'
{
"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",
"source": "api"
},
"client_confirmation": {
"stripe": {
"account_id": "acct_1AbcPlaceholder",
"client_secret": "pi_3AbcPlaceholder_secret_XyzPlaceholder",
"publishable_key": "pk_test_51AbcPlaceholder"
}
}
}
}
Rules that apply because this intent belongs to an order:
- Don't send
amount_money. Flint derives the amount from the order's outstanding balance, so the browser can never invent its own price. If the order later changes (a tip, a coupon, tax), the intent's amount is re-synced server side automatically. - Don't send
auto_confirm. It's rejected withAUTO_CONFIRM_NOT_ALLOWED_WITH_ORDER; the pay call in step 5 is what confirms. - One active intent per order. Creating a second one returns
409 ACTIVE_PAYMENT_INTENT_EXISTS. Reuse the active intent instead: its id is on the order atdata.payment_intent_ids.
Store payment_intent_id with your own checkout state, and send your frontend:
client_confirmation.stripe.publishable_keyclient_confirmation.stripe.account_idpayment_intent.amount_money(the amount and currency the Payment Element will display)
Keep your Flint API key on the backend, always.
You won't use client_confirmation.stripe.client_secret in this flow. The browser never confirms an order-linked intent, so it never needs the intent's secret up front; when 3D Secure is required, the pay call returns a fresh action payload with its own client secret (step 6). The credentials are returned only on create. If you lose them, replay the create request with its original Idempotency-Key to get the original response back.
Step 3: Mount the Payment Element#
Render the form with Stripe.js, initializing it with the values from step 2. The Payment Element runs in Stripe-hosted iframes, so raw card data never touches your servers and your PCI burden stays minimal.
<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>
// From your backend (step 2): publishable_key, account_id, amount_money.
const stripe = Stripe(publishableKey, {
stripeAccount: accountId,
});
const elements = stripe.elements({
mode: "payment",
amount: 9900,
currency: "usd",
// Required: lets stripe.createPaymentMethod collect from these Elements
// instead of the browser confirming the payment itself.
paymentMethodCreation: "manual",
// Only offer methods this server-confirmed flow completes.
paymentMethodTypes: ["card"],
});
const paymentElement = elements.create("payment");
paymentElement.mount("#payment-element");
Two options here are load-bearing:
paymentMethodCreation: "manual"is required. Without it, Stripe.js refuses thecreatePaymentMethodcall in step 4.paymentMethodTypes: ["card"]keeps the form to methods Flint completes through this flow. Leaving it unset lets Stripe offer dynamic methods, including redirect-based ones this flow doesn't support.
Note that the Element mounts without any client secret. It only needs the amount and currency for display; the payment intent stays on your server until the pay call.
Step 4: Turn Card Details into a PaymentMethod#
On submit, validate the Element, create a PaymentMethod, and hand the resulting pm_... id to your backend. This token references the card inside Stripe; it is not sensitive card data.
const form = document.querySelector("#payment-form");
form.addEventListener("submit", async (event) => {
event.preventDefault();
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;
}
// Your endpoint. It runs the pay call in step 5 with your Flint API key.
const result = await fetch("/checkout/pay", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_method_id: paymentMethod.id }),
}).then((response) => response.json());
handlePayResult(result); // success, requires_action, or failure (steps 5 and 6)
});
If the buyer's card is declined later, in step 5, their input is still in the form. Show the error inline and let them retry with another card; don't rebuild the page.
Step 5: Pay the Order from Your Backend#
Your backend confirms the payment by paying the order with the PaymentMethod token. payment_source_tokens maps each payment intent id to the token that funds it.
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_intent_ids": ["pi_1kmn0aExample"],
"payment_source_tokens": {"pi_1kmn0aExample": "pm_1kmn0aExample"},
"expected_amount_money": {"amount": 9900, "currency": "USD"},
"buyer_email": "ada@example.com"
}'
expected_amount_money is the total your buyer saw when they hit pay. If the order changed underneath them (another tab added a tip, tax landed after an address change), the call fails with ORDER_CHANGED_REFRESH_REQUIRED instead of charging an amount the buyer never approved. It's optional for API-key callers, but always send it. buyer_email sets the receipt email for the payment.
Most cards settle right here:
{
"data": {
"order_id": "ord_1kmn0aExample",
"status": "paid",
"settlement_amounts": {
"paid_money": {"amount": 9900, "currency": "USD"},
"amount_due_money": {"amount": 0, "currency": "USD"}
},
"payment_intent_ids": ["pi_1kmn0aExample"]
}
}
status: "paid" with nothing due means you're done; return success to the browser and jump to step 7.
If the bank demands authentication, the order instead stays open and the response carries pending_actions:
{
"data": {
"order_id": "ord_1kmn0aExample",
"status": "open",
"pending_actions": [{
"payment_intent_id": "pi_1kmn0aExample",
"action_type": "payment_authentication",
"client_action": {
"stripe": {
"action": "handle_next_action",
"client_secret": "pi_3AbcPlaceholder_secret_XyzPlaceholder",
"account_id": "acct_1AbcPlaceholder",
"publishable_key": "pk_test_51AbcPlaceholder"
}
}
}]
}
}
Forward client_action.stripe to the browser and continue to step 6. When no authentication is needed, pending_actions is simply absent, so treat a missing array as empty when you branch.
A card decline surfaces as an error response on this call, with a message describing the decline. Pass it back to the browser for the inline retry described in step 4; declines are a normal outcome, not a broken integration.
Step 6: Handle 3D Secure with handleNextAction#
In the browser, run the action Stripe asked for. handleNextAction opens the bank's authentication challenge and resolves when the buyer finishes or gives up.
async function handleAuthentication(stripeAction) {
const { error, paymentIntent } = await stripe.handleNextAction({
clientSecret: stripeAction.client_secret,
});
if (error) {
showMessage(error.message);
return;
}
if (
paymentIntent.status === "succeeded" ||
paymentIntent.status === "requires_confirmation"
) {
// Authentication finished. Ask your backend to run the pay call again.
const result = await fetch("/checkout/finalize", { method: "POST" })
.then((response) => response.json());
handlePayResult(result);
return;
}
// Still requires_action with no error: the buyer closed the challenge
// without failing it. Leave the form intact and let them try again.
showMessage("Verification wasn't completed. Try again to finish your payment.");
}
Reuse the stripe instance from step 3; the action payload repeats the publishable key and account id in case authentication happens on a different page. Seeing requires_confirmation after authentication is expected: confirmation is server side in this flow, so your backend finishes it by paying the order again, this time without tokens (the payment method is already attached):
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-2" \
-d '{
"payment_intent_ids": ["pi_1kmn0aExample"],
"expected_amount_money": {"amount": 9900, "currency": "USD"}
}'
Use a fresh Idempotency-Key: this is a different operation from the first pay call, and reusing the first key with a different body returns IDEMPOTENCY_KEY_REUSED. The response is the same paid shape as step 5.
Step 7: Verify and Fulfill#
The browser's success state is UX, not truth. A buyer can close the tab between authentication and your finalize call, and a network blip can eat a response. Before fulfilling, rely on one of two durable signals:
- Webhooks (recommended): subscribe to
order.payment_succeededorpayment_intent.succeededand fulfill from the event.payment_intent.payment_failedandpayment_intent.requires_actionare also available if you track attempts. See Webhooks for signature verification and replay handling. - A backend fetch of the order:
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
-H "Authorization: Bearer YOUR_API_KEY"
The order is settled when data.status is paid and data.settlement_amounts.amount_due_money.amount is 0. If it's still open, check the payment intent with GET /v1/payment-intents/{payment_intent_id}: requires_action means the buyer hasn't finished authenticating, and processing means the payment is in flight and a webhook will land shortly.
When the Order Total Changes#
Orders are live objects: tips, coupons, tax, and edited line items all move the balance. This flow absorbs that without client-side bookkeeping:
- The intent's amount re-syncs to the order balance server side, so you never patch amounts yourself.
expected_amount_moneyguarantees the buyer is only ever charged a total they saw. OnORDER_CHANGED_REFRESH_REQUIRED, re-fetch the order, update the displayed total and the Element's amount (elements.update({amount})), and let the buyer confirm the new total before you retry the pay call.
Test the Integration#
Test keys run against Stripe test mode, so Stripe's standard test cards exercise each branch of this flow (any future expiry, any CVC):
| Card number | What it exercises |
|---|---|
4242 4242 4242 4242 | Success without authentication (step 5 returns paid) |
4000 0025 0000 3155 | Authentication required (step 5 returns pending_actions, step 6 runs) |
4000 0000 0000 9995 | Decline, insufficient funds (inline retry path in step 4) |
While your backend is still headless, you can skip the browser entirely: Stripe test mode accepts the shorthand tokens pm_card_visa and pm_card_threeDSecure2Required as payment_source_tokens values, so two curls take an order from open to paid. See Testing for the full card matrix.
Errors You Will Hit#
| Status | Code | What to do |
|---|---|---|
| 409 | ACTIVE_PAYMENT_INTENT_EXISTS | Reuse the order's active intent from data.payment_intent_ids instead of creating another. |
| 400 | AUTO_CONFIRM_NOT_ALLOWED_WITH_ORDER | Drop auto_confirm; the pay call confirms order payments. |
| 400 | ORDER_NOT_PAYABLE | The order isn't open. Fetch it; it may already be paid or closed. |
| 409 | ORDER_LINKED_PAYMENT_INTENT | You called standalone confirm, cancel, or capture on an order-linked intent. Use the order's pay and capture endpoints. |
| 409 | ORDER_CHANGED_REFRESH_REQUIRED | The total moved. Refresh the order, show the new total, retry with updated expected_amount_money. |
| 400 | PAYMENT_INTENT_LIMIT_REACHED | The order hit its intent cap. Stop creating intents in a retry loop; reuse the active one. |
The Error Handling guide covers the envelope these arrive in and the request ids to log.
Variations#
- Saved cards. For returning customers, skip Elements and pay with a stored Flint payment method: pass
payment_method_ids(mapping intent id to apm_...id from Payment Methods) instead ofpayment_source_tokens. - Authorize now, capture later. Create the intent with
"capture_method": "manual". The pay call then places an authorization instead of settling, and you capture it withPOST /v1/orders/{order_id}/payment-intents/{payment_intent_id}/capturewhen you're ready. - Apple Pay and Google Pay. Express wallets are supported on the Flint-hosted page today; use a checkout session when you need them.
Related Guides#
- Webhooks: durable payment notifications for fulfillment.
- Checkout Sessions: the hosted alternative to this page.
- Idempotency: retry semantics for every POST in this flow.
- Testing: the full test card matrix, including disputes.
- Error Handling: the error envelope and the codes you'll see when a call fails.
- Payments API reference: every payment intent field and endpoint.
