Declines and payment attempts
Every call to POST /v1/orders/{order_id}/pay runs a payment attempt, and the response returns it. When a payment does not simply succeed, the attempt says what happened and what you can do next: resume it, wait for it, or start a new one. The card may have declined, the bank may want 3D Secure, or the connection may have dropped before the response arrived.
This guide assumes you already collect a card and pay an order as in Embedded payments.
Read the attempt, not the HTTP status#
A declined card is not an API error. The pay call returns 200, and the outcome is in data.payment_attempt:
{
"data": {
"order": {
"order_id": "ord_1kmn0aExample",
"status": "open",
"payment_status": "unpaid"
},
"payment_attempt": {
"order_payment_attempt_id": "opat_1kmn0aExample",
"status": "failed",
"is_resumable": false,
"mode": "payment",
"expected_outstanding_money": {"amount": 9900, "currency": "USD"},
"payment_intents": [{
"payment_intent_id": "pi_1kmn0aExample",
"status": "requires_payment_method",
"amount_money": {"amount": 9900, "currency": "USD"},
"tip_money": {"amount": 0, "currency": "USD"},
"last_payment_error": {
"code": "insufficient_funds",
"message": "The payment method has insufficient funds. Ask the customer to use a different payment method."
}
}],
"failure_code": "insufficient_funds",
"failure_message": "The payment method has insufficient funds. Ask the customer to use a different payment method.",
"started_at": "2026-09-22T18:04:11Z",
"completed_at": "2026-09-22T18:04:12Z"
}
},
"request_id": "req_..."
}
statussays where the attempt is. The ten values are below.is_resumablesays whetheraction: "resume"can move the attempt forward. It istrueonly when a resume can make progress.payment_intents[]holds the attempt's payment legs, the payment intents it is trying to settle. Most orders have one leg. A split payment has several. Each leg has its ownstatus, and a leg that did not settle carrieslast_payment_error.pending_actions[]appears only while the status isrequires_action. It holds what the browser has to do before you resume.failure_codeandfailure_messagesummarize why a final attempt did not succeed.failure_codeuses lowercasesnake_casefor declines and other failures, such aspayment_attempt_expiredandpayment_blocked. Unmapped codes appear asattempt_failed. For a decline these fields repeat the leg's error. Branch on the leg'slast_payment_error.code, which is a closed set.expected_outstanding_moneyis the balance the attempt started against. It stays fixed for the life of the attempt.modeispaymentfor a charge,setupwhen a zero-balance subscription order saves a card, andsettlementwhen a zero-balance order completes without a charge.
A 4xx error means Flint rejected the pay request. For example, the order changed since you quoted it, or another attempt is still open. One rejection also ends an attempt. When one of your risk rules blocks a payment, the pay call returns 200 with a failed leg whose last_payment_error.code is payment_blocked. Read the attempt as you would for a decline. Errors lists what to do for request errors. Treat a 5xx or a timeout as a lost response.
Decide what to do next#
An attempt starts in processing and ends in one of five final statuses. Between those it can stop to wait for the buyer, for you, or for the processor.
- processing moves to finalizing on outcome known
- processing moves to requires_action on 3D Secure
- processing moves to requires_retry on outcome unknown
- processing moves to requires_capture on authorized
- requires_action moves to finalizing on resume
- requires_retry moves to finalizing on resume
- requires_capture moves to finalizing on capture or cancel
- finalizing moves to succeeded
- finalizing moves to partially_succeeded
- finalizing moves to failed
- finalizing moves to canceled
- finalizing moves to expired
status on order payment attemptThe status tells you what to do, and is_resumable settles the cases that depend on it. This function covers every status, including any added later:
type NextStep =
| "done"
| "authenticate" // run pending_actions in the browser, then resume
| "resume"
| "wait" // re-read the attempt, or wait for order.paid
| "capture" // capture or cancel the authorization
| "pay_remaining" // retry only the legs that failed
| "new_payment"; // collect a new credential and start again
function nextStep(attempt: {status: string; is_resumable: boolean}): NextStep {
switch (attempt.status) {
case "succeeded":
return "done";
case "partially_succeeded":
return "pay_remaining";
case "failed":
case "canceled":
case "expired":
return "new_payment";
case "requires_capture":
return "capture";
case "requires_action":
return "authenticate";
default:
// processing, requires_retry, finalizing, and any future status.
// An open attempt is never a reason to start another payment.
return attempt.is_resumable ? "resume" : "wait";
}
}
failed and requires_retry can look alike and call for opposite responses. failed is a known outcome, so a new attempt is safe. requires_retry is an unknown outcome, so you resume the same attempt and let Flint find out what happened.
Warning: is_resumable: false does not mean start over
A finalizing attempt is not resumable, and the buyer has usually been charged already. A second payment would charge them twice, so Flint rejects it with ORDER_PAYMENT_ATTEMPT_ACTIVE. Start a new payment only from failed, canceled, or expired, and pay only the remainder after partially_succeeded.
Handle a decline#
A declined leg stays on the order at requires_payment_method with last_payment_error set, and the attempt ends failed. Nothing needs canceling. Tell the buyer, collect a new card, and retry the same leg.
Decline codes#
last_payment_error.code is one of 18 values. Flint maps every processor reason onto one of them, so you branch on code and never on processor-specific reasons.
code on payment error summaryThe bank_ codes appear only on ACH debit legs.
message is written for you, not the buyer. Most messages end with an instruction such as "Ask the customer to use a different payment method." Write your own buyer copy from code. A handful of codes need specific wording, and a generic message covers the rest:
function buyerMessage(code: string): string {
switch (code) {
case "incorrect_cvc":
return "The security code doesn't match. Check it and try again.";
case "expired_card":
return "This card has expired. Check the date or use another card.";
case "payment_method_unavailable":
return "Check your card details, or use another card.";
case "processing_error":
case "payment_method_temporarily_unavailable":
return "Your payment couldn't be processed. Try again in a moment.";
case "authentication_required":
case "payment_not_completed":
case "payment_action_expired":
return "Your payment wasn't completed. Try again.";
default:
return "Your payment was declined. Try another card or payment method.";
}
}
Retry after a decline#
Collect a new credential in the browser, then start a new attempt that selects the declined leg with action: "confirm_payment_intents":
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 '{
"action": "confirm_payment_intents",
"payment_intents": [{
"payment_intent_id": "pi_1kmn0aExample",
"token": "pm_1kmn0aNewCardExample"
}],
"expected_outstanding_money": {"amount": 9900, "currency": "USD"}
}'
- The leg is reused. Retries do not pile up legs on the order. The declined leg's amount is still the outstanding balance, so it satisfies the default
completion_behavior, which requires the selected legs to cover the balance exactly. - The credential is new. Send the
tokenorconfirmation_tokenthe browser just created, or a saved card'spayment_method_id. Browser credentials are single-use, and one that was already used returnsPAYMENT_SOURCE_UNAVAILABLE. - The key is new. The first request's
Idempotency-Keywould replay the declined response. - The balance is checked. If the order changed since you quoted it, the call returns
ORDER_CHANGED_REFRESH_REQUIREDbefore charging. Re-read the order and show the buyer the new total. With a checkout session credential,expected_outstanding_moneyis required, and leaving it out returnsEXPECTED_AMOUNT_REQUIRED.
Note: Why a second one-shot pay is rejected
After a one-shot action: "pay" declines, its leg is still open on the order. A second pay returns PAYMENT_LEG_SELECTION_REQUIRED, and the error's selectable_payment_intents lists that leg. Select it with confirm_payment_intents as above. Alternatively, cancel the leg with the order-scoped payment intent cancel in Manual capture, then send pay again.
Finish 3D Secure#
When the issuer asks for authentication, the attempt returns requires_action with is_resumable: true and a pending action for each leg that needs it. Embedded payments walks through the browser code. The round trip looks like this:
- Your backend sends pay with the new credential to Flint
- Flint returns requires_action with pending_actions[] to Your backend
- Your backend sends the pending action's client_action to Browser
- Browser sends stripe.handleNextAction to Browser
- Browser sends challenge finished to Your backend
- Your backend sends pay with action: "resume" to Flint
- Flint returns the attempt's next status to Your backend
{
"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"
}
}
}
}]
}
}
Load Stripe.js with the action's publishable_key, and pass account_id as stripeAccount. Then run the call named by stripe_js_call. pending_action_id stays the same across reads of the attempt, so you can use it to avoid running one action twice.
const action = paymentAttempt.pending_actions[0].client_action.stripe;
const {error} = await stripe.handleNextAction({
clientSecret: action.payment_intent.client_secret,
});
// Resume whether or not the challenge succeeded. The resume reports the outcome.
const response = await fetch("/checkout/resume", {method: "POST"});
const attempt = await response.json(); // your backend relays data.payment_attempt
if (attempt.status === "requires_action" && error) {
// The challenge never ran, for example because Stripe.js failed to load.
showMessage(error.message);
}
Then resume from your backend:
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 continues the attempt with the legs and credentials it started with. Flint reads the challenge result from Stripe, and the response carries the attempt's next status. Besides order_payment_attempt_id, the body accepts only these optional fields:
expected_outstanding_money, compared to the amount frozen on the attempt rather than the live balance. A mismatch returnsORDER_CHANGED_REFRESH_REQUIRED.buyer_contact.
Any other field, such as payment_source, returns UNKNOWN_FIELD.
Give each resume its own Idempotency-Key. The start request's key belongs to the start request, and sending it with a resume body returns IDEMPOTENCY_KEY_REUSED.
When the challenge fails or is abandoned#
If the buyer fails the challenge or closes it, handleNextAction returns an error. Resume anyway: the resume returns the attempt as failed, with authentication_required on the leg. Collect another card and retry as after a decline. If you start the retry without resuming first, Flint refreshes the open attempt from Stripe before it judges the new payment, so the retry is not blocked.
If the resume returns requires_action again, the challenge never ran, for example because Stripe.js failed to load. Show the buyer the error from handleNextAction and let them try again.
If the buyer leaves without finishing, the attempt stays at requires_action until 30 minutes after it started. Flint then cancels its legs, the attempt ends canceled, and everything it held on the order is released. A resume after that returns PAYMENT_ATTEMPT_NOT_RESUMABLE, and the next payment starts fresh with action: "pay". To release the order sooner, cancel the attempt.
Recover a lost response#
When a pay request times out or the connection drops, you do not know whether the buyer was charged. Do not start a new payment. Send the same request again, with the same body and the same Idempotency-Key. Flint never runs a key twice:
| The first request | What the retry returns |
|---|---|
| Finished | Its stored response, with the Idempotency-Replayed: true header. |
| Is still running | 409 with IDEMPOTENCY_KEY_IN_PROGRESS. Wait a moment and retry with the same key. |
| Stopped before it finished | The result of the same attempt. Flint continues it rather than starting another. |
A replayed response shows the attempt as it was when the first request finished. If that status is not final, re-read the attempt before acting on it.
Always send an Idempotency-Key on pay. Without one, Flint assigns a random key, and a retry becomes a new request. With the SDKs, a lost response arrives as an SdkError with outcome: "unknown". Handle it the same way.
If you no longer have the key, read the order. active_payment_attempt is the attempt that currently holds the order's payment:
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": {
"order_id": "ord_1kmn0aExample",
"status": "open",
"payment_status": "unpaid",
"active_payment_attempt": {
"order_payment_attempt_id": "opat_1kmn0aExample",
"status": "requires_retry",
"is_resumable": true
}
}
}
Apply nextStep to it. When active_payment_attempt is absent, no attempt is open: the last one finished, or its 30 minutes ran out. Check payment_status, and list the attempts to see how the last one ended. Reading a checkout session with its own credential or a merchant API key also includes active_payment_attempt when one is active, so a headless checkout can recover the same way.
A new payment started while an attempt holds the order is rejected. The error names the open attempt, so even a blind retry leads you back to it:
{
"error": {
"type": "conflict_error",
"code": "ORDER_PAYMENT_ATTEMPT_ACTIVE",
"message": "This order already has an active payment attempt. Read active_payment_attempt and resume or resolve it before starting another payment.",
"order_payment_attempt_id": "opat_1kmn0aExample",
"is_resumable": true,
"payment_attempt_status": "requires_retry",
"request_id": "req_..."
}
}
Wait while an attempt settles#
Three situations call for waiting. In each, the attempt is open and is_resumable is false:
finalizing: the outcome is known and Flint is applying it to the order, settling the balance and committing or releasing held inventory. This almost always finishes inside the pay request, so you rarely see it.processingon a bank debit: the bank has not answered. This can take several business days. See ACH debit.requires_retry: Flint is resolving an unknown outcome itself.
While you wait:
- Do not start another payment. Money may already have moved. Flint rejects the start with
ORDER_PAYMENT_ATTEMPT_ACTIVE, or withPAYMENT_ATTEMPT_STILL_PROCESSINGonce the attempt is more than 30 minutes old. - Do not tell the buyer the payment failed. Keep the spinner up for a card. For a bank debit, tell the buyer the payment is processing.
- Re-read the attempt, starting around 500 ms between reads and backing off to a few seconds. A read that fails or times out says nothing about the payment, so retry the read.
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/payment-attempts/opat_1kmn0aExample \
-H "Authorization: Bearer YOUR_API_KEY"
To skip polling, fulfill from the order.paid webhook. It fires once the order is fully paid, however the payment got there.
When only some legs settled#
An order paid with several legs, such as two cards, confirms them together in one attempt. If some legs settle and others do not, the attempt ends partially_succeeded. The attempt is final, and the order still has an outstanding balance.
Do not retry the original amount. That would charge the buyer again for the legs that settled. Instead:
order.partially_paid fires the first time the order becomes partly paid, and order.paid fires once the rest settles.
Look up past attempts#
Attempts stay readable after they finish. When a buyer says their payment failed, look here.
A decline followed by a successful retry shows two attempts. A finished attempt keeps its legs as they were when it ended, so the first attempt still shows the decline after the same leg succeeds in the second. A checkout session credential can read only the attempts its own session started.
Cancel an attempt#
Cancel an attempt to end it now instead of waiting out its 30 minutes. For example, the buyer walked away from 3D Secure and you want the inventory back on sale, or you want to back out of a zero-balance setup attempt.
/v1/orders/{order_id}/payment-attempts/{order_payment_attempt_id}/cancelRequired API key scope: commerce.orders.writeReference for POST /v1/orders/{order_id}/payment-attempts/{order_payment_attempt_id}/cancelEnds the attempt, cancels each of its legs, and releases what it held on the order. Accepts an optional cancellation_reason.
cancellation_reasonisrequested_by_customer(the default),duplicate,fraudulent, orabandoned. A checkout session credential can send onlyrequested_by_customerorabandoned.- The legs are gone. Their payment intents are canceled and cannot be reused, so the next payment starts with
action: "pay"and a new leg. - Holds are released. That covers held inventory, coupon and promotion redemptions, payment link capacity, and the delivery selection.
- Only an open attempt can be canceled. Canceling a finished attempt returns
PAYMENT_ATTEMPT_NOT_CANCELABLE, except that repeating a cancel returns the canceled attempt unchanged. If a leg already settled, the cancel returnsORDER_PAYMENT_LEG_ALREADY_SETTLED. Refund that money instead.
To release one authorized leg and keep the rest, use the order-scoped payment intent cancel in Manual capture.
Webhooks#
The pay response already tells your backend the outcome. Webhooks carry the same facts to systems that were not part of the request:
There is no order-level payment-failed event. To track declines, subscribe to payment_intent.payment_failed. Webhooks covers signature checks and durable handling.
Errors#
Test it#
In a sandbox, these test cards produce each outcome. Use any future expiry date and any CVC.
| Card | Attempt status | Leg last_payment_error.code |
|---|---|---|
4000 0000 0000 0002 | failed | card_declined |
4000 0000 0000 9995 | failed | insufficient_funds |
4000 0000 0000 0069 | failed | expired_card |
4000 0000 0000 0127 | failed | incorrect_cvc |
4000 0000 0000 0119 | failed | processing_error |
4000 0025 0000 3155 and 4000 0000 0000 3220 both return requires_action with a 3D Secure challenge. Complete the challenge and resume, and the attempt succeeds. Fail it and resume, and the attempt ends failed with authentication_required. Test both branches.
To pay from a terminal or test suite without a browser, use the test tokens in Testing. To rehearse a lost response, send a pay request twice with the same Idempotency-Key and body. The second response carries Idempotency-Replayed: true.
Related guides#
- Embedded payments with Stripe Elements: the collect-and-pay flow these steps recover.
- Headless checkout: the same recovery with a checkout session credential.
- Manual capture: authorize now, capture or cancel later.
- Idempotency: how replays, conflicts, and in-progress keys work.
- Error handling: the error envelope and structured details.
- Testing: every test card and token.
