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:

JSON
{
  "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_..."
}
  • status says where the attempt is. The ten values are below.
  • is_resumable says whether action: "resume" can move the attempt forward. It is true only 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 own status, and a leg that did not settle carries last_payment_error.
  • pending_actions[] appears only while the status is requires_action. It holds what the browser has to do before you resume.
  • failure_code and failure_message summarize why a final attempt did not succeed. failure_code uses lowercase snake_case for declines and other failures, such as payment_attempt_expired and payment_blocked. Unmapped codes appear as attempt_failed. For a decline these fields repeat the leg's error. Branch on the leg's last_payment_error.code, which is a closed set.
  • expected_outstanding_money is the balance the attempt started against. It stays fixed for the life of the attempt.
  • mode is payment for a charge, setup when a zero-balance subscription order saves a card, and settlement when 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.

Payment attempt statusStartFinal
  • 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 attempt
Final values do not change again
  • processing
    Flint is confirming the payment. A card normally moves past this inside the pay request. A bank debit stays here until the bank answers, which can take days, and is not resumable meanwhile. Resume when is_resumable is true. Otherwise wait.
  • requires_action
    The buyer has to authenticate, usually with a 3D Secure challenge. Run the pending action in the browser, then resume. An attempt still waiting 30 minutes after it started is canceled.
  • requires_retry
    Flint does not know the outcome of every leg yet, for example because the processor timed out mid-confirmation. Resume, and Flint reads the result from the processor instead of charging again. When is_resumable is false, Flint is resolving the outcome itself. Wait and re-read.
  • finalizing
    The payment outcome is known and Flint is applying it to the order. Not resumable and not final. Wait and re-read.
  • requires_capture
    A manual capture authorization succeeded. Capture or cancel it with the order_payment_attempt_id. The attempt holds the order until you act or the authorization expires.
  • succeededFinal
    Every leg in the attempt settled.
  • partially_succeededFinal
    Some legs settled and the rest did not. The order still owes the part that failed. See When only some legs settled.
  • failedFinal
    No money moved. Each declined leg is back at requires_payment_method with last_payment_error set. Collect a new credential and start a new attempt.
  • canceledFinal
    Every leg was canceled, by a cancel request or because a 3D Secure challenge was abandoned. Start a new payment.
  • expiredFinal
    An authorization expired before it was captured, or a card-saving attempt was never completed. Start a new payment.

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

TypeScript
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 summary
  • card_declined
    The issuer declined the card. This includes declines for suspected fraud and for cards reported lost or stolen, so never show the buyer a specific reason. Ask for another card.
  • insufficient_funds
    The card or bank account does not have enough funds. Ask for another payment method.
  • expired_card
    The card has expired. Ask the buyer to check the expiry date or use another card.
  • incorrect_cvc
    The security code is wrong. Ask the buyer to re-enter it.
  • payment_method_unavailable
    The details cannot be used for this payment: an incorrect card number, expiry date, or postal code, or a card type or currency the processor does not support. Ask the buyer to check the details or use another method.
  • processing_error
    The issuer or processor could not complete the charge, for example because the issuer was unreachable. The buyer can try the same card again.
  • authentication_required
    The buyer failed or did not complete 3D Secure. Ask them to try again and finish the challenge, or to use another card.
  • payment_blocked
    A Flint risk rule or Stripe's fraud screening blocked the payment. Ask for another payment method without saying why.
  • payment_method_declined
    The provider of a non-card method, such as Affirm, declined the buyer. Ask for another payment method.
  • payment_method_temporarily_unavailable
    The payment method is temporarily unavailable. Ask the buyer to try again later or choose another method.
  • payment_not_completed
    The buyer left a redirect flow, such as Affirm, without finishing it. Ask them to try again or choose another method.
  • payment_action_expired
    A pending buyer action, such as a redirect, expired before the buyer finished it. Start a new attempt.
  • bank_account_closed
    The bank account is closed. Ask for another account or payment method.
  • bank_account_not_found
    The bank could not find the account. Ask the buyer to check the account and routing numbers.
  • bank_debit_not_authorized
    The account holder told their bank the debit was not authorized. Ask for another payment method.
  • bank_account_restricted
    The account cannot accept debits. Ask for another payment method.
  • bank_debit_limit_exceeded
    The debit exceeds a limit on the account. Ask for another payment method.
  • payment_failed
    Any failure without a more specific code, including duplicate-transaction declines and Stripe's generic test-mode decline. Ask the buyer to try again or use another method.

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

TypeScript
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
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 token or confirmation_token the browser just created, or a saved card's payment_method_id. Browser credentials are single-use, and one that was already used returns PAYMENT_SOURCE_UNAVAILABLE.
  • The key is new. The first request's Idempotency-Key would replay the declined response.
  • The balance is checked. If the order changed since you quoted it, the call returns ORDER_CHANGED_REFRESH_REQUIRED before charging. Re-read the order and show the buyer the new total. With a checkout session credential, expected_outstanding_money is required, and leaving it out returns EXPECTED_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:

Resuming after 3D SecureResponse
BrowserYour backendFlintpay with the new credentialrequires_action with pending_actions[]the pending action's client_actionstripe.handleNextActionchallenge finishedpay with action: "resume"the attempt's next status
  1. Your backend sends pay with the new credential to Flint
  2. Flint returns requires_action with pending_actions[] to Your backend
  3. Your backend sends the pending action's client_action to Browser
  4. Browser sends stripe.handleNextAction to Browser
  5. Browser sends challenge finished to Your backend
  6. Your backend sends pay with action: "resume" to Flint
  7. Flint returns the attempt's next status to Your backend
Response
{
  "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.

JavaScript
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
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 returns ORDER_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 requestWhat the retry returns
FinishedIts stored response, with the Idempotency-Replayed: true header.
Is still running409 with IDEMPOTENCY_KEY_IN_PROGRESS. Wait a moment and retry with the same key.
Stopped before it finishedThe 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
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "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:

JSON
{
  "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.
  • processing on 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 with PAYMENT_ATTEMPT_STILL_PROCESSING once 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
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:

  1. Keep the settled legs. They are already applied to the order, so there is nothing to replay or reverse.

  2. Tell the buyer which part failed. Each leg has its own status, and the one that failed has its own last_payment_error. A generic "payment failed" is wrong here, because part of the payment went through.

  3. Retry only the failed leg. It is back at requires_payment_method, and its amount is the remaining balance. Collect a new credential and select the leg with confirm_payment_intents, as in Retry after a decline.

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.

POST/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}/cancel

Ends the attempt, cancels each of its legs, and releases what it held on the order. Accepts an optional cancellation_reason.

  • cancellation_reason is requested_by_customer (the default), duplicate, fraudulent, or abandoned. A checkout session credential can send only requested_by_customer or abandoned.
  • 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 returns ORDER_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:

  1. What happens: The issuer asks for 3D Secure
  2. Flint sends: payment_intent.requires_action
    Your pay response already carries the pending action, so the browser flow does not wait for this.
  3. What happens: A card declines
  4. Flint sends: payment_intent.payment_failed
    One per declined leg, carrying last_payment_error.
  5. What happens: A retry succeeds
  6. Flint sends: payment_intent.succeeded
    The leg settled.
  7. Flint sends: order.payment_captured
    One per settled leg, with the amount applied to the order.
  8. Flint sends: order.paid
    Once, when the order is fully paid. Fulfill from this event.

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#

  • HTTP 409
    expected_outstanding_money does not match the order's balance, or on a resume the amount frozen on the attempt. Re-read the order and show the buyer the current amount before paying.
  • HTTP 400
    A checkout session credential started a payment without expected_outstanding_money. Send the balance the buyer approved.
  • HTTP 409
    Another attempt holds the order. The error carries its order_payment_attempt_id, is_resumable, and payment_attempt_status. Resume it, wait, capture, or cancel it instead of starting a new payment.
  • HTTP 409
    The attempt is final, past its 30 minutes, or waiting for capture. Read its status and do what that status allows.
  • HTTP 409
    The attempt already finished. A failed or expired attempt holds nothing to cancel, and settled money needs a refund instead.
  • HTTP 409
    An attempt more than 30 minutes old still holds the order, for example a bank debit waiting on the bank. Wait, re-read the order, and retry after the attempt finishes.
  • HTTP 400
    A one-shot pay was sent while the order has a leg that no open attempt owns, usually one that declined. Select a leg from selectable_payment_intents with confirm_payment_intents.
  • HTTP 409
    The credential was already used. Collect a new one and start a new attempt.
  • HTTP 503
    Payment processing is temporarily unavailable for this account. Retry with backoff.
  • HTTP 409
    A request with this key is still running. Wait a moment and retry with the same key.
  • HTTP 400
    The body has a field the action does not accept, such as payment_source on a resume.

Test it#

In a sandbox, these test cards produce each outcome. Use any future expiry date and any CVC.

CardAttempt statusLeg last_payment_error.code
4000 0000 0000 0002failedcard_declined
4000 0000 0000 9995failedinsufficient_funds
4000 0000 0000 0069failedexpired_card
4000 0000 0000 0127failedincorrect_cvc
4000 0000 0000 0119failedprocessing_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.

Was this helpful?