We've all been there: a customer initiates a payment, the network hiccups, and suddenly you're unsure whether the charge went through. Payment retries and timeouts: what developers should know covers the practical patterns that separate resilient payment systems from fragile ones. In this post, we'll walk through how timeouts, idempotency, state queries, and user feedback work together to build confidence in your Stripe integration.
Why timeouts and retries matter
Payment processing sits at the boundary between your application and external networks. Unlike a database query that lives entirely within your infrastructure, a payment request to Stripe travels across the internet, introducing latency, packet loss, and connection resets. A request can fail for legitimate reasons—server is unreachable, DNS resolution times out—or for transient ones—a momentary network blip that would succeed if retried.
The danger isn't just a poor user experience. Without proper timeout and retry logic, you risk charging customers multiple times or failing to charge them at all, leading to refund support tickets and eroded trust. We've seen teams discover this the hard way in production, so let's build the right patterns now.
Timeouts — Set client timeouts on API calls
A timeout is a guard rail. If Stripe doesn't respond within a reasonable window, we stop waiting and move on rather than hanging indefinitely. The key is choosing a timeout that's long enough to handle legitimate network delays but short enough to fail fast.
For Stripe API calls, we recommend a 30-second timeout as a starting point. This gives Stripe's infrastructure time to process the request even under load, but prevents your application from blocking for minutes on end. If a request takes longer than 30 seconds, it's likely a network issue rather than Stripe being slow.
Here's how to set timeouts in a Node.js/NestJS environment using the official Stripe SDK:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
httpClient: {
timeout: 30000, // 30 seconds in milliseconds
},
maxNetworkRetries: 0, // We'll handle retries ourselves
});
// Example: Create a PaymentIntent with explicit timeout handling
async function createPaymentIntent(amount: number, currency: string) {
try {
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
automatic_payment_methods: {
enabled: true,
},
});
return { success: true, paymentIntent };
} catch (error) {
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
// Network timeout: we don't know if the request reached Stripe
return { success: false, timedOut: true, error };
}
// Other errors (validation, authentication, etc.)
throw error;
}
}
Notice we set maxNetworkRetries: 0. This prevents the Stripe SDK from retrying automatically, which would hide the timeout from us. We want explicit control over retry logic because we need to check state before retrying.
Retry safely — Idempotency keys make retries safe
An idempotency key is a unique identifier that tells Stripe: "If you've already processed a request with this key, return the same result instead of processing it again." Without idempotency keys, retrying a payment request could charge the customer twice.
Think of it like submitting a form online. If you accidentally double-click the submit button, you want the form to be processed only once, not twice. An idempotency key is the mechanism that ensures this.
In the Stripe API, you pass an idempotency key via the Idempotency-Key HTTP header. The Stripe SDK handles this for you if you use the idempotencyKey option:
import crypto from 'crypto';
function generateIdempotencyKey(): string {
return crypto.randomUUID();
}
async function createPaymentIntentWithIdempotency(
amount: number,
currency: string,
customerId: string,
) {
const idempotencyKey = generateIdempotencyKey();
try {
const paymentIntent = await stripe.paymentIntents.create(
{
amount,
currency,
customer: customerId,
automatic_payment_methods: {
enabled: true,
},
},
{
idempotencyKey, // Stripe stores this for 24 hours
},
);
// Store the idempotency key with the order for later reference
await db.orders.update({
stripe_idempotency_key: idempotencyKey,
stripe_payment_intent_id: paymentIntent.id,
});
return paymentIntent;
} catch (error) {
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
// Timeout occurred, but if we retry with the same idempotency key,
// Stripe will return the PaymentIntent it created (if any)
return { timedOut: true, idempotencyKey };
}
throw error;
}
}
The idempotency key must be globally unique for each logical operation. If you're retrying the same payment, reuse the same key. If you're processing a different payment, generate a new key.
Unknown state — Query the PaymentIntent before retrying
A timeout doesn't tell you whether the request was processed or not. The request might have reached Stripe, the PaymentIntent might have been created, and then the response got lost on its way back. Retrying blindly—even with an idempotency key—means you might create two PaymentIntents when you only needed one.
The right approach is to query Stripe for the PaymentIntent before retrying. If it exists, we know the original request succeeded. If not, we can safely retry.
async function getOrCreatePaymentIntent(
customerId: string,
amount: number,
currency: string,
): Promise {
const idempotencyKey = crypto.randomUUID();
// Attempt 1: Create the PaymentIntent
try {
return await stripe.paymentIntents.create(
{
amount,
currency,
customer: customerId,
automatic_payment_methods: { enabled: true },
},
{ idempotencyKey },
);
} catch (error) {
// Network error: unknown state
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
console.warn('Timeout creating PaymentIntent, checking for existing one');
// Attempt 2: Query for PaymentIntent linked to this customer and amount
const existingIntents = await stripe.paymentIntents.list({
customer: customerId,
limit: 10,
});
const matchingIntent = existingIntents.data.find(
(pi) => pi.amount === amount && pi.currency === currency &&
pi.status === 'requires_payment_method',
);
if (matchingIntent) {
console.info('Found existing PaymentIntent, reusing it');
return matchingIntent;
}
// Attempt 3: Retry creation with the same idempotency key
console.warn('No existing PaymentIntent found, retrying creation');
return await stripe.paymentIntents.create(
{
amount,
currency,
customer: customerId,
automatic_payment_methods: { enabled: true },
},
{ idempotencyKey }, // Same key ensures Stripe returns the one it created before
);
}
throw error;
}
}
This three-step pattern—attempt, query, retry—handles the most common scenarios. In practice, if your API latency is reasonable (under 5-10 seconds), the query will almost always find the existing PaymentIntent, and you'll avoid duplicate attempts.
User feedback — Show processing, not failure, while unsure
While your backend is retrying and querying, your frontend must show the customer something sensible. Showing "Payment failed" when you don't actually know the status is worse than a timeout—it confuses customers and triggers duplicate payment attempts from their end.
The pattern is simple: show "Payment processing" until you know the outcome. Only show success or failure once you're certain.
// Frontend component (React example)
export function PaymentForm() {
const [status, setStatus] = useState<'idle' | 'processing' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
async function handlePayment() {
setStatus('processing');
setErrorMessage('');
try {
// Call your backend endpoint
const response = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 9999,
currency: 'usd',
}),
});
if (!response.ok) {
// Backend returned an error (validation, auth, etc.)
const error = await response.json();
setStatus('error');
setErrorMessage(error.message || 'Payment failed');
return;
}
const data = await response.json();
if (data.timedOut) {
// Backend timed out but will retry and query
// Keep showing "processing" and poll for status
await pollForPaymentStatus(data.paymentIntentId);
return;
}
// Backend has a confirmed PaymentIntent
setStatus('success');
} catch (error) {
setStatus('error');
setErrorMessage('Network error. Please try again.');
}
}
async function pollForPaymentStatus(paymentIntentId: string) {
// Poll your backend for the final status
let attempts = 0;
while (attempts < 30) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const response = await fetch(`/api/payment-status/${paymentIntentId}`);
const data = await response.json();
if (data.status === 'succeeded') {
setStatus('success');
return;
} else if (data.status === 'requires_payment_method') {
// Still processing or needs action
attempts++;
continue;
} else {
setStatus('error');
setErrorMessage('Payment declined');
return;
}
}
// Polling timed out; show error but suggest checking back
setStatus('error');
setErrorMessage('Payment status unclear. Please check your email or contact support.');
}
return (
<div>
{status === 'processing' && 'Processing...'}
{status === 'idle' && 'Pay now'}
{status === 'success' && 'Success!'}
{status === 'error' && 'Try again'}
{errorMessage && <p>{errorMessage}</p>}
</div>
);
}
On the backend, you'll want a status endpoint that returns the current state of the PaymentIntent:
app.get('/api/payment-status/:paymentIntentId', async (req, res) => {
try {
const paymentIntent = await stripe.paymentIntents.retrieve(req.params.paymentIntentId);
res.json({
id: paymentIntent.id,
status: paymentIntent.status,
amount: paymentIntent.amount,
});
} catch (error) {
res.status(404).json({ error: 'PaymentIntent not found' });
}
});
Common mistakes
Mistake 1: Retrying without idempotency keys. If your retry logic runs twice due to a bug or misconfiguration, you'll charge the customer twice. Always use idempotency keys.
Mistake 2: Showing failure immediately on timeout. Customers see "Payment failed" and try again with a different card, leading to multiple PaymentIntents and confusion. Keep showing "processing" until you know the actual state.
Mistake 3: Ignoring network errors in production. Testing with a reliable local network won't catch timeout issues. Simulate network latency and failures using tools like toxiproxy or network throttling in your staging environment.
Mistake 4: Storing idempotency keys per row but not reusing them on retry. If you generate a new idempotency key on each retry, you defeat the purpose. Store the original key and reuse it.
Mistake 5: Setting timeouts too short. A 5-second timeout might work for your local tests but fail under production load when Stripe or your network is slower. 30 seconds is a safe baseline.
Key takeaways
- Set a 30-second timeout on Stripe API calls and handle timeout errors explicitly.
- Use idempotency keys on every payment creation request; store and reuse them on retries.
- After a timeout, query for the PaymentIntent before retrying to avoid duplicates.
- Show "processing" to customers until you know the final state; avoid premature failure messages.
- Test timeout and retry logic in staging with network simulation tools.
- Log all timeouts and retries so you can debug issues in production.






Comments
Loading comments…