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#
| Pain point | Stripe | Flint |
|---|---|---|
| Request times out | StripeUnknown outcome your code must resolve | FlintSame-key retry replays or continues the attempt |
| New key per retry or click | StripeSecond charge | FlintPAYMENT_ATTEMPT_ACTIVE, or ACTION_NOT_ALLOWED once paid |
| Key reused for a new purchase | StripeOld response replays, or the request errors | FlintEach purchase is its own order, so nothing replays |
| Retry after 24 hours | StripeKey may be removed, so a new request can run | FlintPay keys last as long as the payment |
| Total changed after the buyer saw it | StripeCharges the new amount unless you check | FlintVERSION_CONFLICT when you send expected_outstanding_money |
| Already paid? | StripeYour cart flag, set by a webhook | FlintA paid order refuses another payment |
| Declines | StripeMostly generic_decline, and you map advice_code | FlintOne of 22 codes with a retry hint |
| 3D Secure | StripeYou track requires_action and the return URL | FlintThe 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.
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:
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
catchretries declines too, and Stripe warns issuers "might see creating additional retries as potential fraud."
Save the key and body when the buyer presses Pay. The Node SDK retries with that key, and a lost response the retries didn't settle arrives as an SdkError with outcome: "unknown":
import { Client, SdkError } from "@flintpay/node";
const flint = new Client({
baseUrl: "https://api.withflintpay.com",
apiKey: process.env.FLINT_API_KEY!,
});
// Created and saved when the buyer pressed Pay, before the first request.
const payJob = await db.payJobs.get(jobId);
try {
const { order_payment_attempt } = await flint.orders.pay(
{ order_id: payJob.orderId, body: payJob.body },
{ idempotencyKey: payJob.idempotencyKey },
);
return order_payment_attempt;
} catch (error) {
if (!(error instanceof SdkError)) throw error;
if (error.outcome === "unknown") {
// Never a new key. Send the identical request once more with the same key.
const replay = await flint.orders
.pay(
{ order_id: payJob.orderId, body: payJob.body },
{ idempotencyKey: error.idempotencyKey ?? payJob.idempotencyKey },
)
.catch(() => undefined);
if (replay) return replay.order_payment_attempt;
// Still unknown: read the order. An active attempt holds it, or the
// order's payment_status says whether it's already paid.
const order = await flint.orders.get(payJob.orderId);
return order.active_payment_attempt ?? { payment_status: order.payment_status };
}
throw error;
}
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.
| Stripe | Flint | |
|---|---|---|
| Retry key | StripeYou "generate and persist" one for the operation, send it on every attempt across SDK calls and restarts, and keep declines out of the retry loop | FlintA retry with the same key and body replays a finished request, marked Idempotency-Replayed: true |
| Request still running or stopped partway | StripeA 409 while it runs; a request that failed partway leaves an outcome you reconcile | Flint409 IDEMPOTENCY_KEY_IN_PROGRESS while it runs; a stopped request continues "rather than starting another" |
| Unknown processor result | StripeYour retry window stops at 24 hours, then you reconcile from the object, the Dashboard, and webhooks | FlintFlint 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/aiissue #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.
| Stripe | Flint | |
|---|---|---|
| Where keys come from | StripeYou persist them per operation, never minted in the retry or the browser | FlintA pay with no Idempotency-Key gets 400 INVALID_REQUEST, and nothing happens |
| New key per retry or click | StripeYour code finds the extra PaymentIntents for a cart, decides which one counts, and refunds the rest | FlintA second payment while one is open gets 409 PAYMENT_ATTEMPT_ACTIVE, whatever key it carries |
| Key reused for a new purchase | StripeYou version cart keys whenever the cart changes | FlintPay keys are scoped to the order, so a reused key on a new order never replays the old purchase |
| Key lifetime | StripeKept at least 24 hours, then removable | FlintHeld 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.
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.
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;
}
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-1" \
-d '{
"action": "pay",
"payment_source": {"credential_type": "token", "token": "pm_1kmn0aExample"},
"expected_outstanding_money": {"amount": 9900, "currency": "USD"}
}'
The response is 200 with the order and the payment attempt, whether the card succeeded, declined, or needs 3D Secure:
{
"data": {
"order": {
"order_id": "ord_1kmn0aExample",
"status": "closed",
"payment_status": "paid",
"settlement_amounts": {
"paid_money": {"amount": 9900, "currency": "USD"},
"outstanding_money": {"amount": 0, "currency": "USD"}
}
},
"order_payment_attempt": {
"order_payment_attempt_id": "opat_1kmn0aExample",
"status": "succeeded",
"is_resumable": false,
"expected_outstanding_money": {"amount": 9900, "currency": "USD"}
}
}
}
| Stripe | Flint | |
|---|---|---|
| Already paid | Stripecart.paidAt comes from a webhook, so it says unpaid until the event arrives, and Stripe doesn't guarantee event order | FlintA paid order is closed, and another payment returns 409 ACTION_NOT_ALLOWED with reason terminal_status |
| Two requests at once | StripeRead, then write, with no lock: both can see no PaymentIntent and both create one | FlintOne payment attempt holds the order at a time, and the 409 PAYMENT_ATTEMPT_ACTIVE error names it |
| Payment in progress | Striperequires_action and processing mean money may be moving, and a check for succeeded alone invites a second payment | FlintThe attempt's status, not the HTTP status, tells you what happened |
| Changed total | StripeNothing checks that the buyer approved it | FlintWhen 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."
| Stripe | Flint | |
|---|---|---|
| Retry rule | StripeYour code reads advice_code from the charge's outcome and maps it to a retry rule | Flint200 with a failed attempt, never 402, and every processor reason maps onto one of 22 codes, each with a remediation.retry |
| Buyer message | StripeYour code maps advice_code to a buyer message | FlintBuyer credentials see card_declined for lost, stolen, fraud, and do-not-honor declines, while your API key sees the real code |
| Retry count | StripeYou 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 transaction | StripeA 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:
"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 code | Retry the same card? | What to ask the buyer |
|---|---|---|
lost_card, stolen_card, fraudulent | No | Another card, with a plain decline message and no reason |
payment_blocked | No | Another payment method, without saying why |
insufficient_funds | Not now | Another payment method |
expired_card, incorrect_cvc, payment_method_unavailable | Only with corrected details | Check the expiry, security code, or card details |
processing_error | Yes | Try the same card again |
authentication_required | Yes, with the challenge finished | Complete 3D Secure, or use another card |
card_declined, do_not_honor | Not without a change | Another card, or contact their bank |
payment_failed | Yes | Try 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 -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 -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"}'
| Stripe | Flint | |
|---|---|---|
| Second click | StripeYour handler reads requires_action as a failure, so the buyer tries again and a new PaymentIntent puts two payments in flight | FlintThe order is held while the challenge waits, so a second click can't start another payment |
| Failed challenge | StripeNeeds a new payment method or a fresh confirm on the same intent, not a new intent | FlintA failed or closed challenge returns as failed with authentication_required, so you retry as after a decline |
| Abandoned challenge | StripeA buyer who closes the tab before the return URL is found only by a webhook or a later read | FlintAn attempt still at requires_action is canceled 30 minutes after it started, releasing what it held, such as inventory |
| Reading the challenge | StripeYour return_url page retrieves the PaymentIntent again after the redirect | Flintpending_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.
- Advanced error handling: network errors don't tell you whether the request succeeded, keys are kept at least 24 hours, retries limited to 24 hours then reconciled, persisting keys across SDK calls and restarts, and deriving keys from a cart ID.
- Idempotent requests: saved responses including
500errors, parameter comparison, and pruning after 24 hours. - The Payment Intents API: storing the PaymentIntent ID on the cart, reusing it, and basing the key on the cart or session ID.
- Card declines: most declines are
generic_decline, network limits on reattempts, the eight-retry recommendation, issuers seeing extra retries as potential fraud, andadvice_codevalues. - Decline codes: the codes described as "declined for an unknown reason" and
duplicate_transaction. - Authenticate with 3D Secure:
requires_action, the return URL flow, and failed authentication returning torequires_payment_method. - Receive Stripe events in your webhook endpoint: no guaranteed event order.
- Visa: Updates to rules for declined transaction resubmission (Article AI10325): no reattempts after Category 1 declines, and up to 15 reattempts in 30 days for Category 2.
- GitHub: stripe/ai #402, agent-level retries creating duplicate charges.
- DEV Community: My script charged a customer twice after a timeout and Your payment code can still charge twice (even with idempotency keys).
Flint behavior comes from the Declines and payment attempts, Idempotency, Error handling, Embedded payments, SDKs, and Subscription billing guides, and the Orders API reference.
