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):

JSON
{
  "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_resumable tells you whether the same attempt can continue. true means finish the pending action, then resume by payment_attempt_id. false means you cannot resume it.
  • status tells you whether the attempt is finished. Most of the time is_resumable is 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, its last_payment_error tells 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.

statusFinished?is_resumableWhat you do
requires_actionnotrueA buyer action (usually 3D Secure) is pending. Run it, then resume.
processingnotrueConfirmation is in flight. Resume to reconcile.
requires_retrynotrueAn unknown-outcome failure (a provider timeout mid-confirmation). Resume so Flint reconciles from durable state instead of risking a double charge.
finalizingnofalseThe money outcome is settled, but Flint is still applying it to the order. Wait and re-read the attempt. See Finalizing.
requires_captureyesfalseAn authorization succeeded and is waiting for capture.
partially_succeededyesfalseSome legs settled, some did not. The order keeps its outstanding balance.
succeededyesfalseThe attempt settled its selected legs.
failedyesfalseA known-terminal outcome, such as a hard decline. Start a new attempt.
canceled / expiredyesfalseThe 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:

JSON
{
  "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:

codeWhat to tell the buyer
card_declinedThe card was declined. Try another card.
insufficient_fundsThe card has insufficient funds. Try another card.
expired_cardThe card has expired. Check the expiry or use another card.
incorrect_cvcThe security code was incorrect. Re-enter it.
authentication_requiredAuthentication is required. Complete the challenge (see 3D Secure).
processing_errorSomething 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:

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-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:

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 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.

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"}'

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:

Bash
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
  -H "Authorization: Bearer YOUR_API_KEY"
JSON
{
  "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:

  1. Keep the settled legs. They are already applied to the order. Nothing needs to be replayed or reversed.
  2. Show the buyer which leg failed and why. Each entry in payment_intents[] carries its own status, and a failed one carries its own last_payment_error. A generic "payment failed" is wrong here, because most of the payment did not fail.
  3. Start a new attempt for the remaining balance only. Re-read the order, take the current outstanding amount, and use it as expected_outstanding_money on the next PayOrder. Collect a fresh credential for the replacement leg; a ctoken_... 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:

JSON
{
  "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_ACTIVE anyway, since a finalizing attempt still holds the order.
  • Do not resume it. There is nothing to resume; is_resumable is false.
  • 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 succeeded in the normal case. Back off between reads (start around 500ms, grow to a few seconds) rather than polling in a tight loop.
Bash
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."

HTTP
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.

Bash
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:

HTTP
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:

  1. is_resumable: true? Resume by payment_attempt_id. That covers 3D Secure, in-flight confirmations, and unknown outcomes.
  2. status: "finalizing"? Wait and re-read. The payment succeeded; Flint is finishing the order.
  3. 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.

Rate this doc