Skip to content

Retry Payments Without Double Charging: Keys, Declines, 3DS

A timed-out charge leaves you guessing, and a naive retry charges twice. Idempotency key mistakes, soft vs hard declines, 3D Secure, and an order-first fix.
Payments12 min readBy Flint Pay, Product & API Team

A timed-out charge may have succeeded, failed, or still be running, and a naive retry charges the card twice. Idempotency keys protect one request, but on a charge-centric API, knowing whether the customer already paid is your code's job.

Tip: The short answer

  • An idempotency key protects one request, so a new key, a second click, or an expired key can still charge twice.
  • On Flint, you pay an order's outstanding balance: one payment attempt holds the order at a time, and a paid order refuses another charge.
  • You stop building cart paid flags, row locks, PaymentIntent state machines, and reconciliation jobs that refund duplicates.

At a glance#

Request times outStripeUnknown outcome your code must resolveFlintSame-key retry replays or continues the attempt
New key per retry or clickStripeSecond chargeFlintPAYMENT_ATTEMPT_ACTIVE, or ACTION_NOT_ALLOWED once paid
Key reused for a new purchaseStripeOld response replays, or the request errorsFlintEach purchase is its own order, so nothing replays
Retry after 24 hoursStripeKey may be removed, so a new request can runFlintPay keys last as long as the payment
Total changed after the buyer saw itStripeCharges the new amount unless you checkFlintVERSION_CONFLICT when you send expected_outstanding_money
Already paid?StripeYour cart flag, set by a webhookFlintA paid order refuses another payment
DeclinesStripeMostly generic_decline, and you map advice_codeFlintOne of 22 codes with a retry hint
3D SecureStripeYou track requires_action and the return URLFlintThe waiting attempt holds the order until you resume it or 30 minutes pass

Retry a timed-out payment#

Stripe's advanced error handling guide says it for network errors:

Restoring connectivity doesn't tell you whether the original request succeeded.

Stripe, Advanced error handling guide

A retry policy that treats a timeout as a failure is a double-charge policy. A DEV Community post from October 2026 describes the result: "the first request actually succeeded, so the customer was charged twice."

A common first version wraps a saved-card charge in a loop:

TypeScript
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

async function chargeCart(cart: Cart) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      return await stripe.paymentIntents.create(
        {
          amount: cart.totalCents,
          currency: "usd",
          customer: cart.stripeCustomerId,
          payment_method: cart.paymentMethodId,
          confirm: true,
          off_session: true,
        },
        { idempotencyKey: crypto.randomUUID() }, // a new key on every attempt
      );
    } catch (err) {
      if (attempt === 3) throw err;
    }
  }
}
  • Attempt one charges the card and its response is lost. Attempt two has a fresh key, so Stripe charges again.
  • The catch retries declines too, and Stripe warns issuers "might see creating additional retries as potential fraud."

An active_payment_attempt at requires_retry means the outcome is still unknown, for example after a processor timeout. Resume it with {"action": "resume", "order_payment_attempt_id": "opat_1kmn0aExample"} and a new key, and Flint reads the result from the processor instead of charging again. If is_resumable is false, Flint is resolving it, so read again later.

When a response is lost
Retry keyStripeYou "generate and persist" one for the operation, send it on every attempt across SDK calls and restarts, and keep declines out of the retry loopFlintA retry with the same key and body replays a finished request, marked Idempotency-Replayed: true
Request still running or stopped partwayStripeA 409 while it runs; a request that failed partway leaves an outcome you reconcileFlint409 IDEMPOTENCY_KEY_IN_PROGRESS while it runs; a stopped request continues "rather than starting another"
Unknown processor resultStripeYour retry window stops at 24 hours, then you reconcile from the object, the Dashboard, and webhooksFlintFlint resolves requires_retry from the processor's result, not a new charge

failed and requires_retry look alike and need opposite responses. Start a new payment only from failed, canceled, or expired, and after partially_succeeded pay only the failed leg. Declines and payment attempts has a nextStep function that maps every attempt status, including ones added later, to its next step.

Idempotency key mistakes that still charge twice#

Stripe's idempotent requests reference saves a key's first response, and "Retries with the same key return the saved response, including 500 errors." That protects you only if one business action always sends one key:

  • A new key per retry. Agents do it too. Stripe's stripe/ai issue #402 reports that when an agent framework retries a tool call, "a new session starts with a new auto-generated key. The result is a second charge."
  • A key per click or tab. A browser key from crypto.randomUUID() on each click turns two clicks, a reload, or a second tab into two payments.
  • One key reused across purchases. Stripe suggests deriving keys from "the ID of a shopping cart." If the buyer changes the cart after a decline, Stripe errors because the parameters differ. If they buy the same thing again that day, Stripe replays the first response, and your code fulfills an order nobody paid for.
  • A key that expired. Stripe keeps v1 keys "for at least 24 hours and can remove them after that. Reusing a key after it's removed can execute a new request." A Monday replay of Friday's failures can charge again.
When the key is wrong
Where keys come fromStripeYou persist them per operation, never minted in the retry or the browserFlintA pay with no Idempotency-Key gets 400 INVALID_REQUEST, and nothing happens
New key per retry or clickStripeYour code finds the extra PaymentIntents for a cart, decides which one counts, and refunds the restFlintA second payment while one is open gets 409 PAYMENT_ATTEMPT_ACTIVE, whatever key it carries
Key reused for a new purchaseStripeYou version cart keys whenever the cart changesFlintPay keys are scoped to the order, so a reused key on a new order never replays the old purchase
Key lifetimeStripeKept at least 24 hours, then removableFlintHeld on money-moving routes "for as long as the command's effects exist"

Keys still matter on Flint, since a correct key returns the original response instead of an error. A wrong key just can't charge the same order twice.

Has this cart already been paid?#

A September 2026 post on duplicate charges puts it plainly:

Idempotency keys stop duplicate requests, not duplicate intents.

Feezan Khattak, DEV Community

Two tabs or a dunning job racing a manual retry are different, valid requests. Stripe's Payment Intents guide has you store the PaymentIntent ID on the cart and base the key on it, so the paid check is your code.

On Flint, the order exists before any payment. You pay its settlement_amounts.outstanding_money with POST /v1/orders/{order_id}/pay instead of creating a charge with an amount.

TypeScript
async function payCart(cartId: string) {
  const cart = await db.carts.findById(cartId);
  if (cart.paidAt) return { alreadyPaid: true }; // set by your webhook handler

  let intent = cart.paymentIntentId
    ? await stripe.paymentIntents.retrieve(cart.paymentIntentId)
    : null;

  if (!intent || intent.status === "canceled") {
    intent = await stripe.paymentIntents.create(
      { amount: cart.totalCents, currency: "usd", customer: cart.stripeCustomerId },
      { idempotencyKey: `cart-${cart.id}-v${cart.version}` },
    );
    await db.carts.update(cartId, { paymentIntentId: intent.id });
  }

  if (intent.amount !== cart.totalCents) {
    intent = await stripe.paymentIntents.update(intent.id, { amount: cart.totalCents });
  }
  return intent;
}
What stops a second payment
Already paidStripecart.paidAt comes from a webhook, so it says unpaid until the event arrives, and Stripe doesn't guarantee event orderFlintA paid order is closed, and another payment returns 409 ACTION_NOT_ALLOWED with reason terminal_status
Two requests at onceStripeRead, then write, with no lock: both can see no PaymentIntent and both create oneFlintOne payment attempt holds the order at a time, and the 409 PAYMENT_ATTEMPT_ACTIVE error names it
Payment in progressStriperequires_action and processing mean money may be moving, and a check for succeeded alone invites a second paymentFlintThe attempt's status, not the HTTP status, tells you what happened
Changed totalStripeNothing checks that the buyer approved itFlintWhen the pay sends expected_outstanding_money and the balance no longer matches, Flint returns VERSION_CONFLICT before charging

Soft vs hard declines: which to retry#

A decline is a known outcome, so a new attempt can't charge twice. Whether it can succeed depends on the decline:

  • Soft declines are temporary, such as insufficient funds, an unreachable issuer, or authentication needed.
  • Hard declines won't change: the card is lost, stolen, closed, or barred from this purchase.

Visa's rules for declined transaction resubmission forbid reattempts after a Category 1 ("Issuer will never approve") code and allow Category 2 reattempts "up to 15 times in 30 days." But most declines don't say which kind they are. Stripe's card declines guide says "Card issuers categorize most declines as generic (generic_decline)," and its decline code table describes eleven card codes, two of them deprecated, only as "The card was declined for an unknown reason."

Handling a decline
Retry ruleStripeYour code reads advice_code from the charge's outcome and maps it to a retry ruleFlint200 with a failed attempt, never 402, and every processor reason maps onto one of 22 codes, each with a remediation.retry
Buyer messageStripeYour code maps advice_code to a buyer messageFlintBuyer credentials see card_declined for lost, stolen, fraud, and do-not-honor declines, while your API key sees the real code
Retry countStripeYou keep retries within network limits, with Stripe recommending "a maximum of eight retries for charges that permit retries"FlintFlint retries subscription renewals itself, by default four retries over 16 days
Duplicate transactionStripeA duplicate_transaction path, where Stripe's next step is "Check to see if a recent payment already exists"FlintA paid order refuses another payment

The failed leg carries last_payment_error:

JSON
"last_payment_error": {
  "code": "insufficient_funds",
  "message": "The payment method has insufficient funds. Ask the customer to use a different payment method.",
  "failed_at": "2026-09-22T18:04:12Z",
  "remediation": {"retry": "none"}
}

Flint's guide gives each card code a next step:

Flint codeRetry the same card?What to ask the buyer
lost_card, stolen_card, fraudulentNoAnother card, with a plain decline message and no reason
payment_blockedNoAnother payment method, without saying why
insufficient_fundsNot nowAnother payment method
expired_card, incorrect_cvc, payment_method_unavailableOnly with corrected detailsCheck the expiry, security code, or card details
processing_errorYesTry the same card again
authentication_requiredYes, with the challenge finishedComplete 3D Secure, or use another card
card_declined, do_not_honorNot without a changeAnother card, or contact their bank
payment_failedYesTry again, or use another method

Card decline codes maps each code to Visa's categories and to Stripe, Adyen, Square, and Braintree. Subscription billing covers renewal retry settings.

To retry after a decline, select the declined leg with a new credential and a new key. The old key would replay the decline, and reusing the leg keeps payment intents from piling up:

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-ord-1kmn0a-attempt-2" \
  -d '{
    "action": "confirm_payment_intents",
    "payment_intents": [{
      "credential_type": "token",
      "payment_intent_id": "pi_1kmn0aExample",
      "token": "pm_1kmn0aNewCardExample"
    }],
    "expected_outstanding_money": {"amount": 9900, "currency": "USD"}
  }'

A second one-shot pay after a decline is rejected with INVALID_REQUEST, and details[0].selectable_resources names the open leg to select instead.

3D Secure without a second payment#

On Stripe, a PaymentIntent that needs authentication moves to requires_action. Per Stripe's 3D Secure guide, your return_url page retrieves it again after a redirect, and a failed challenge leaves it at requires_payment_method.

On Flint, the attempt pauses with status: "requires_action", is_resumable: true, and a pending_actions[] entry carrying the Stripe.js call to run. After the challenge, resume the same attempt with its own key:

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-ord-1kmn0a-resume-1" \
  -d '{"action": "resume", "order_payment_attempt_id": "opat_1kmn0aExample"}'
Handling a 3D Secure challenge
Second clickStripeYour handler reads requires_action as a failure, so the buyer tries again and a new PaymentIntent puts two payments in flightFlintThe order is held while the challenge waits, so a second click can't start another payment
Failed challengeStripeNeeds a new payment method or a fresh confirm on the same intent, not a new intentFlintA failed or closed challenge returns as failed with authentication_required, so you retry as after a decline
Abandoned challengeStripeA buyer who closes the tab before the return URL is found only by a webhook or a later readFlintAn attempt still at requires_action is canceled 30 minutes after it started, releasing what it held, such as inventory
Reading the challengeStripeYour return_url page retrieves the PaymentIntent again after the redirectFlintpending_action_id stays stable across reads, so you can avoid running one challenge twice

The resume needs its own key: sending the start request's key with a resume body returns IDEMPOTENCY_KEY_REUSED. Embedded payments has the browser code, and test cards 4000 0025 0000 3155 and 4000 0000 0000 3220 trigger the challenge in a sandbox.

What your code still does on Flint#

  • Create one order per purchase and keep its ID. Two orders are two balances, and Flint charges each one.
  • Save a key with each pay action before the first request, and reuse it only to retry that exact request.
  • Write the buyer's decline copy from code, and keep the reason for lost, stolen, and fraud declines out of it.
  • Fulfill from order.paid, deduplicated on the event ID, because webhook deliveries repeat. Payment webhooks: duplicates and out-of-order events covers that handler.

Rehearse each failure in a sandbox#

Flint is an order-first payments and commerce API for US businesses selling online, with Stripe processing every card payment underneath. The sandbox is free, and live card payments cost 3.79% + 35¢ with no monthly fee.

Sign up for a free sandbox. Create an order and pay it with 4000 0000 0000 9995 for insufficient_funds, 4000 0000 0000 9987 for lost_card, or send the pay request twice for a lost response. See the test cards.

The error handling and idempotency guides cover the other retry rules. For more charge-first pain, see payment API problems developers complain about.

FAQ#

Why did Stripe charge my customer twice?#

Usually the same purchase reached Stripe as two requests: a retry with a new idempotency key, a second click or tab, or a job that ran after the key expired. Stripe deduplicates requests that share a key and parameters, and whether a cart is already paid is up to your code.

How long does a Stripe idempotency key last?#

Stripe keeps API v1 keys for at least 24 hours and can remove them after that, so a reused key can then run a new request. Stripe recommends retrying only within those 24 hours, then reconciling from the object, the Dashboard, and webhooks.

Should I retry a payment after a timeout?#

Yes, but only as the identical request with the same idempotency key, never with a new key. The first request may have charged the card or may still be running.

What is the difference between a soft decline and a hard decline?#

A soft decline, such as insufficient funds or an unreachable issuer, is temporary, so a later or corrected attempt can succeed. A hard decline, such as a lost, stolen, or closed card, won't change, and Visa forbids reattempting its "issuer will never approve" category.

What does requires_action mean in Stripe?#

The payment needs the buyer to finish a step, usually a 3D Secure challenge, before it can complete. After the challenge, read the PaymentIntent again: a failed challenge returns it to requires_payment_method.

How does Flint prevent double charges?#

Flint takes payments against an order's outstanding balance, and one payment attempt holds the order at a time, so a second payment returns 409 PAYMENT_ATTEMPT_ACTIVE and a paid order refuses more money. A retried pay with the same Idempotency-Key continues the original attempt instead of charging again.

Sources#

Everything attributed to Stripe and Visa above comes from their own documentation, and the developer reports from their public posts, checked on 2026-10-28.

Flint behavior comes from the Declines and payment attempts, Idempotency, Error handling, Embedded payments, SDKs, and Subscription billing guides, and the Orders API reference.