Published on

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

Authors

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

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