- Published on
Payment Gateway Timeout Chaos — When Stripe Takes 30 Seconds and You Don't Know If the Charge Went Through
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Payment gateway timeouts are different from ordinary HTTP timeouts because the side effect — the charge — may or may not have happened. A regular API timeout means "try again." A payment timeout means "figure out if you already charged them before you try again." Teams that don't handle this correctly end up either double-charging customers (trust-destroying) or shipping orders without payment (revenue-destroying). The solution is idempotency keys and webhook-based reconciliation.
- The Payment Timeout Problem
- Fix 1: Idempotency Keys — Every Charge Must Be Unique and Safe to Retry
- Fix 2: Webhook Reconciliation — Trust Events, Not API Responses
- Fix 3: Payment State Machine — Never Guess the Order Status
- Fix 4: Timeout Configuration That Makes Sense
- Payment Reliability Checklist
- Conclusion
The Payment Timeout Problem
Payment attempt timeline (without idempotency):
T+0: User clicks "Pay Now"
T+0.1s: App calls stripe.paymentIntents.create({ amount: 9999 })
T+30s: Stripe times out — network issue between your server and Stripe
T+30s: App receives timeout error
T+30s: App doesn't know: did Stripe create the charge?
T+30s: App shows user "Payment failed, please try again"
T+31s: User clicks "Pay Now" again (frustrated)
T+32s: App calls stripe.paymentIntents.create({ amount: 9999 }) AGAIN
T+35s: Second charge succeeds
T+35s: First charge (which succeeded) also processes
Result: Customer charged twice.
You have two orders for one payment.
Customer calls bank and disputes.
Fix 1: Idempotency Keys — Every Charge Must Be Unique and Safe to Retry
import Stripe from 'stripe'
import { randomUUID } from 'crypto'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
// ✅ Generate and STORE an idempotency key before calling Stripe
// Use the same key on retry — Stripe will return the same result, not charge twice
async function createPaymentIntent(
orderId: string,
amount: number,
currency: string
): Promise<Stripe.PaymentIntent> {
// Idempotency key is deterministic: same order → same key
// If this request is retried (timeout, network error), Stripe returns the SAME PaymentIntent
const idempotencyKey = `order-${orderId}-payment`
// Store the idempotency key BEFORE calling Stripe
// This allows recovery: if we crash after Stripe succeeds, we know the key
await db.query(`
INSERT INTO payment_attempts (order_id, idempotency_key, status, created_at)
VALUES ($1, $2, 'pending', NOW())
ON CONFLICT (order_id) DO NOTHING
`, [orderId, idempotencyKey])
try {
const paymentIntent = await stripe.paymentIntents.create(
{
amount,
currency,
metadata: { orderId },
},
{
idempotencyKey, // ← Stripe deduplication key
timeout: 10_000, // Fail fast, not 30 seconds
}
)
await db.query(`
UPDATE payment_attempts
SET status = 'created', stripe_payment_intent_id = $1
WHERE order_id = $2
`, [paymentIntent.id, orderId])
return paymentIntent
} catch (err) {
if (err instanceof Stripe.errors.StripeConnectionError ||
err instanceof Stripe.errors.StripeAPIError) {
// Timeout or network error: safe to retry with same idempotency key
throw new PaymentTimeoutError(orderId, idempotencyKey)
}
throw err
}
}
Fix 2: Webhook Reconciliation — Trust Events, Not API Responses
// Don't rely on API response to confirm payment
// Rely on Stripe webhooks — they're the source of truth
import { Router } from 'express'
import Stripe from 'stripe'
const router = Router()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
// Stripe webhooks are delivered reliably even if your API call timed out
router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature']!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return res.status(400).send('Webhook signature invalid')
}
// Idempotent processing: use event ID to prevent double-processing
const alreadyProcessed = await db.query(
'SELECT id FROM processed_webhook_events WHERE event_id = $1',
[event.id]
)
if (alreadyProcessed.rows.length > 0) {
return res.json({ received: true }) // Already handled
}
switch (event.type) {
case 'payment_intent.succeeded': {
const intent = event.data.object as Stripe.PaymentIntent
const orderId = intent.metadata.orderId
await db.transaction(async (trx) => {
await trx.query(`
UPDATE orders SET status = 'paid', paid_at = NOW(), stripe_payment_intent_id = $1
WHERE id = $2 AND status = 'pending'
`, [intent.id, orderId])
await trx.query(
'INSERT INTO processed_webhook_events (event_id, processed_at) VALUES ($1, NOW())',
[event.id]
)
})
await fulfillOrder(orderId) // Ship it
break
}
case 'payment_intent.payment_failed': {
const intent = event.data.object as Stripe.PaymentIntent
const orderId = intent.metadata.orderId
await db.query(
"UPDATE orders SET status = 'payment_failed' WHERE id = $1",
[orderId]
)
await notifyUserPaymentFailed(orderId)
break
}
}
res.json({ received: true })
})
Fix 3: Payment State Machine — Never Guess the Order Status
// Explicit state machine for orders — no ambiguous states
type OrderStatus =
| 'cart' // In cart, not submitted
| 'pending_payment' // Submitted, payment in progress
| 'payment_processing' // Stripe is processing
| 'paid' // Webhook confirmed payment
| 'payment_failed' // Webhook confirmed failure
| 'payment_timeout' // API timed out, status unknown — needs reconciliation
| 'fulfilled' // Shipped
| 'refunded' // Refunded
const validTransitions: Record<OrderStatus, OrderStatus[]> = {
cart: ['pending_payment'],
pending_payment: ['payment_processing', 'payment_failed'],
payment_processing: ['paid', 'payment_failed', 'payment_timeout'],
payment_timeout: ['paid', 'payment_failed'], // Resolved by webhook
paid: ['fulfilled', 'refunded'],
payment_failed: ['pending_payment'], // Allow retry
fulfilled: ['refunded'],
refunded: [],
}
async function transitionOrder(orderId: string, newStatus: OrderStatus): Promise<void> {
const order = await db.query('SELECT status FROM orders WHERE id = $1', [orderId])
const currentStatus = order.rows[0].status as OrderStatus
if (!validTransitions[currentStatus].includes(newStatus)) {
throw new Error(`Invalid transition: ${currentStatus} → ${newStatus} for order ${orderId}`)
}
await db.query(
'UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2',
[newStatus, orderId]
)
}
// Reconciliation job: resolve payment_timeout orders
async function reconcileTimedOutPayments() {
const timedOut = await db.query(`
SELECT o.id, pa.stripe_payment_intent_id
FROM orders o
JOIN payment_attempts pa ON pa.order_id = o.id
WHERE o.status = 'payment_timeout'
AND o.updated_at < NOW() - INTERVAL '5 minutes'
`)
for (const order of timedOut.rows) {
if (!order.stripe_payment_intent_id) continue
const intent = await stripe.paymentIntents.retrieve(order.stripe_payment_intent_id)
if (intent.status === 'succeeded') {
await transitionOrder(order.id, 'paid')
await fulfillOrder(order.id)
} else if (intent.status === 'canceled' || intent.status === 'requires_payment_method') {
await transitionOrder(order.id, 'payment_failed')
}
// If still processing, check again on next run
}
}
Fix 4: Timeout Configuration That Makes Sense
// Default Stripe SDK timeout is 80 seconds — way too long
// Set a shorter timeout and handle the timeout explicitly
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
timeout: 10_000, // 10 seconds, not 80
maxNetworkRetries: 2, // Stripe retries idempotent operations automatically
})
// For critical payment path: short timeout + explicit fallback
async function chargeWithFallback(
orderId: string,
amount: number
): Promise<{ status: 'charged' | 'pending' | 'failed' }> {
try {
const intent = await createPaymentIntent(orderId, amount, 'usd')
if (intent.status === 'succeeded') {
return { status: 'charged' }
}
return { status: 'pending' } // Needs confirmation
} catch (err) {
if (err instanceof PaymentTimeoutError) {
// Set order status to payment_timeout — reconcile via webhook or cron
await transitionOrder(orderId, 'payment_timeout')
return { status: 'pending' } // Don't tell user "failed" — we don't know yet
}
await transitionOrder(orderId, 'payment_failed')
return { status: 'failed' }
}
}
Payment Reliability Checklist
- ✅ Every Stripe call uses a deterministic idempotency key (based on order ID, not random)
- ✅ Idempotency key stored before calling Stripe — recoverable if process crashes
- ✅ Webhook handler is the source of truth for payment status — not API response
- ✅ Webhook events are processed idempotently (event ID deduplication)
- ✅ Orders use an explicit state machine — no implicit "I think it worked" states
- ✅ Timeout status is explicit: orders stay in
payment_timeoutuntil reconciled - ✅ Reconciliation job resolves timed-out payments by querying Stripe directly
- ✅ Stripe SDK timeout is short (10s) — never wait 80 seconds for a payment
Conclusion
Payment timeout chaos comes from treating payment API calls like regular HTTP calls. They're not — the side effect persists whether or not your process crashes. The defense is idempotency keys (same key → same charge, no matter how many retries), webhooks as the authoritative state source (not the API response), and an explicit state machine that acknowledges "I don't know yet" as a legitimate order state. A system that handles payment uncertainty correctly is worth more than one that handles the happy path elegantly.