Handling Declines & Payment Attempts
Real payments fail. Cards decline, buyers abandon a 3D Secure challenge, and a network blip drops the response to a charge that may or may not have gone through. The order-owned payment flow is built so every one of these has a single, explicit recovery path: read the payment attempt, and let it tell you whether to resume, wait, or start again.
The recovery model here sits under Embedded Payments and Flint-hosted checkout. If you have not collected a card yet, start there; this is what to do when the first POST /v1/orders/{order_id}/pay does not simply succeed.
The Payment Attempt#
Every call to POST /v1/orders/{order_id}/pay runs inside a payment attempt: one execution over the order's selected payment legs. The attempt is what you inspect, resume, or cancel when a payment does not complete in one shot.
Paying an order returns it as data.payment_attempt, and the same projection is readable later (see Reading Attempts):
{
"payment_attempt": {
"payment_attempt_id": "opat_1kmn0aExample",
"status": "requires_action",
"is_resumable": true,
"mode": "payment",
"expected_outstanding_money": {"amount": 9900, "currency": "USD"},
"payment_intents": [{
"payment_intent_id": "pi_1kmn0aExample",
"status": "requires_action",
"amount_money": {"amount": 9900, "currency": "USD"},
"tip_money": {"amount": 0, "currency": "USD"},
"last_payment_error": null
}],
"pending_actions": [...]
}
}
Three fields carry the whole recovery model:
is_resumabletells you whether the same attempt can continue.truemeans finish the pending action, then resume bypayment_attempt_id.falsemeans you cannot resume it.statustells you whether the attempt is finished. Most of the timeis_resumableis all you need, but one status (finalizing) is neither resumable nor finished, so the two fields are not redundant.payment_intents[]is the per-leg summary. When a leg fails, itslast_payment_errortells you why, so a declined response is self-describing: you do not fetch the PaymentIntent to learn the reason.
An attempt is either still running (a fresh read may change it) or finished (its result is final). is_resumable splits the running ones into the ones you drive and the one you wait on.
status | Finished? | is_resumable | What you do |
|---|---|---|---|
requires_action | no | true | A buyer action (usually 3D Secure) is pending. Run it, then resume. |
processing | no | true | Confirmation is in flight. Resume to reconcile. |
requires_retry | no | true | An unknown-outcome failure (a provider timeout mid-confirmation). Resume so Flint reconciles from durable state instead of risking a double charge. |
finalizing | no | false | The money outcome is settled, but Flint is still applying it to the order. Wait and re-read the attempt. See Finalizing. |
requires_capture | yes | false | An authorization succeeded and is waiting for capture. |
partially_succeeded | yes | false | Some legs settled, some did not. The order keeps its outstanding balance. |
succeeded | yes | false | The attempt settled its selected legs. |
failed | yes | false | A known-terminal outcome, such as a hard decline. Start a new attempt. |
canceled / expired | yes | false | The attempt was canceled or timed out. |
failed and requires_retry are easy to conflate and are deliberately distinct. failed is a known outcome (the issuer declined), so a new attempt is safe. requires_retry is an unknown outcome (the provider timed out and the charge may have gone through), so you must resume the same attempt rather than start a fresh one.
is_resumable: false does not mean "start a new payment." finalizing is not resumable and not finished: the buyer's money is already settled, so starting another payment would charge them twice. Check that the attempt is finished before you treat it as one to replace.
Handling a Decline#
A hard decline ends the attempt as failed. The declined leg survives on the order and returns to requires_payment_method with its last_payment_error populated, so retrying is a fresh payment start on the same leg with a new card. No new leg is created, and nothing needs canceling.
The failed pay response carries everything you need:
{
"data": {
"order": {
"order_id": "ord_1kmn0aExample",
"status": "open",
"payment_status": "unpaid"
},
"payment_attempt": {
"payment_attempt_id": "opat_1kmn0aExample",
"status": "failed",
"is_resumable": false,
"payment_intents": [{
"payment_intent_id": "pi_1kmn0aExample",
"status": "requires_payment_method",
"amount_money": {"amount": 9900, "currency": "USD"},
"last_payment_error": {
"code": "insufficient_funds",
"message": "The card was declined for insufficient funds."
}
}]
}
}
}
last_payment_error.code is a closed, Flint-normalized set. Provider-specific strings are mapped to these values at the API boundary, so you can branch on them safely and show your own copy:
code | What to tell the buyer |
|---|---|
card_declined | The card was declined. Try another card. |
insufficient_funds | The card has insufficient funds. Try another card. |
expired_card | The card has expired. Check the expiry or use another card. |
incorrect_cvc | The security code was incorrect. Re-enter it. |
authentication_required | Authentication is required. Complete the challenge (see 3D Secure). |
processing_error | Something went wrong processing the card. Try again. |
Two more codes cover cases that are not card-specific: payment_method_unavailable (the selected source could not be used) and payment_failed (a generic fallback for any decline that does not map to a more specific reason). Handle them with a generic "please try another payment method" path. Because the set is closed, a default branch is a safety net, not the common case.
The message is safe to display for the caller's context, but branch on code, never on message text.
Retrying After a Decline#
Collect a new card, then start a fresh payment selecting the same leg:
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_intents": [{
"payment_intent_id": "pi_1kmn0aExample",
"token": "pm_newCardToken"
}],
"expected_outstanding_money": {"amount": 9900, "currency": "USD"}
}'
This starts a new attempt. The declined leg is reused, so retries do not proliferate legs across the order. If the order balance drifted since the amount was quoted, the assertion fails with ORDER_CHANGED_REFRESH_REQUIRED; refresh the order and resend the current balance.
Retrying a one-shot payment. If your first payment used the one-shot payment_source shape (no pre-created leg), a second one-shot is rejected with PAYMENT_LEG_SELECTION_REQUIRED, because the declined leg is still active. The error's structured details carry the declined leg. Retry by selecting that leg explicitly with the new credential (the payment_intents call above), or cancel the leg and one-shot again.
3D Secure and Pending Actions#
When authentication is required, the attempt is requires_action with is_resumable: true and carries 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 call in the browser, then resume the frozen attempt from your backend. Resume needs only payment_attempt_id; do not resend the token, the selected legs, or the completion behavior. You may resend expected_outstanding_money, which is checked against the attempt's frozen expectation rather than the live balance.
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"}'
If the buyer abandons the challenge, the attempt is not resumable forever: it expires. Treat an expired attempt like a decline and start a fresh payment.
Including a start-only field such as payment_intents, payment_source, setup_payment_source, or completion_behavior on a resume is rejected with PAYMENT_ATTEMPT_RESUME_CONFLICT rather than silently ignored. expected_outstanding_money is not a start field and is allowed: on a resume it is compared against the attempt's frozen expectation, and a mismatch returns ORDER_CHANGED_REFRESH_REQUIRED.
Recovering a Lost Response#
The hardest failure is the one where you never got an answer: the connection dropped after you paid the order, and you do not know whether a charge happened. Do not blindly retry, which risks a double charge. Read the order (or checkout session) instead. Whichever attempt was in flight is exposed as active_payment_attempt:
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": {
"payment_attempt_id": "opat_1kmn0aExample",
"status": "requires_retry",
"is_resumable": true
}
}
}
Then apply the one rule: if active_payment_attempt.is_resumable is true, resume it by payment_attempt_id; if it is absent or terminal, the prior attempt finished and you can start new allowed work. A requires_retry attempt is lock-owning, so a fresh payment start is blocked with ORDER_PAYMENT_ATTEMPT_ACTIVE (which names the attempt in its details) until you resume and let Flint reconcile.
active_payment_attempt appears on both order detail and checkout-session detail, so hosted and embedded integrations recover the same way. It clears once the attempt reaches a terminal state.
When Only Some Legs Settled#
A split-tender or multi-leg attempt can end partially_succeeded: real money moved on some legs and not on others. The attempt is finished and not resumable, but the order still has an outstanding balance.
The mistake to avoid is retrying the original amount. The settled legs are settled, and charging the full total again double-charges the buyer.
Do three things instead:
- Keep the settled legs. They are already applied to the order. Nothing needs to be replayed or reversed.
- Show the buyer which leg failed and why. Each entry in
payment_intents[]carries its own status, and a failed one carries its ownlast_payment_error. A generic "payment failed" is wrong here, because most of the payment did not fail. - Start a new attempt for the remaining balance only. Re-read the order, take the current outstanding amount, and use it as
expected_outstanding_moneyon the nextPayOrder. Collect a fresh credential for the replacement leg; actoken_...is single-use and bound to the leg it was collected for.
Partial settlement is the main reason to read payment_attempt rather than trusting an HTTP status. The request succeeded; the payment only partly did.
When an Attempt Is Finalizing#
A payment has two halves: the money moves, and then Flint applies that outcome to the order (settling the balance, moving held inventory to committed along with discounts, releasing the rest). Almost always both finish inside your pay request and you never see finalizing.
When the second half needs more time, the attempt reports finalizing:
{
"payment_attempt": {
"payment_attempt_id": "opat_1kmn0aExample",
"status": "finalizing",
"is_resumable": false,
"payment_intents": [{
"payment_intent_id": "pi_1kmn0aExample",
"status": "succeeded",
"amount_money": {"amount": 9900, "currency": "USD"}
}]
}
}
Read that carefully: the leg says succeeded. The buyer has been charged. The attempt is not resumable because there is no payment work left to redo, and it is not terminal because the order consequences are not committed yet.
So finalizing is the one state where you do nothing but wait:
- Do not start another payment. The money already moved. Flint blocks the attempt with
ORDER_PAYMENT_ATTEMPT_ACTIVEanyway, since a finalizing attempt still holds the order. - Do not resume it. There is nothing to resume;
is_resumableisfalse. - Do not cancel it, and do not tell the buyer the payment failed.
- Re-read the attempt until it reaches a terminal status, which is
succeededin the normal case. Back off between reads (start around 500ms, grow to a few seconds) rather than polling in a tight loop.
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/payment-attempts/opat_1kmn0aExample \
-H "Authorization: Bearer YOUR_API_KEY"
A read that fails or times out is not a payment failure. If you are showing a buyer a spinner, keep showing it and let the read retry.
If you would rather not poll at all, order.paid is the durable signal that finalization completed, and it is the right thing to gate fulfillment on regardless.
Reading What Happened#
Payment attempts are addressable resources, so past attempts stay readable. This is the surface for "the buyer says their payment failed, show me what happened."
GET /v1/orders/{order_id}/payment-attempts/{payment_attempt_id}
GET /v1/orders/{order_id}/payment-attempts
The list returns the order's attempts newest first with standard cursor pagination, each with the same per-leg summaries and last_payment_error you get when you pay the order. A decline followed by a successful retry shows both attempts, in order.
curl "https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/payment-attempts" \
-H "Authorization: Bearer YOUR_API_KEY"
Reads authorize under commerce.orders.read. Checkout-session callers can read and list only the attempts belonging to their own session.
Canceling an Attempt#
Some attempts have no leg to cancel through the PaymentIntent routes, such as a zero-balance setup attempt or an attempt stuck mid-pending-action. Cancel the attempt directly:
POST /v1/orders/{order_id}/payment-attempts/{payment_attempt_id}/cancel
This releases the collection lock and any attempt-owned holds. To cancel a specific authorized leg instead, use the order-scoped PaymentIntent cancel.
The Rule#
Everything above reduces to one decision, taken in this order:
is_resumable: true? Resume bypayment_attempt_id. That covers 3D Secure, in-flight confirmations, and unknown outcomes.status: "finalizing"? Wait and re-read. The payment succeeded; Flint is finishing the order.- Otherwise the attempt is finished. Read its status: act on the result, and start new work only if nothing settled.
Every failure carries its reason on the leg in last_payment_error, so you branch on codes and never parse a message to decide what to do.
Related Guides#
- Embedded Payments with Stripe Elements: the collect-and-pay flow this recovers.
- Manual Capture: authorize now, capture or cancel later.
- Error Handling: the error envelope, codes, and structured details.
- Testing: decline and 3D Secure test cards.
- Webhooks:
order.paidas the durable fulfillment signal.
