Payment failures happen — card declines, expired methods, insufficient funds. In Stripe, a failed charge doesn't email you automatically, and most teams discover failures only when customers complain. You need three layers: dashboard visibility for spot-checks, API queries for analytics, and webhooks for real-time alerts.
View and Query Failed Payments in Stripe
Start with the dashboard for quick visibility, then add API queries for programmatic monitoring.
Navigate to the Payments dashboard to spot failures
Open your Stripe dashboard and go to Payments > All transactions. Click the Status filter and select Failed to see all declined charges. You'll immediately see the failure reason (card declined, expired, lost card), customer, amount, and timestamp.
Query failed charges via the API
For dashboards, exports, or analytics pipelines, fetch failed charges programmatically using the Charges API. Filter by status: 'failed' and paginate through results. Each charge includes the failure_code (e.g., card_declined, expired_card) and failure_message.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// List all failed charges from the past 30 days
const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
const failedCharges = await stripe.charges.list({
limit: 100,
status: 'failed',
created: { gte: thirtyDaysAgo },
});
failedCharges.data.forEach((charge) => {
console.log(`Charge ${charge.id}: ${charge.failure_code} - ${charge.failure_message}`);
console.log(`Customer: ${charge.customer}, Amount: ${charge.amount / 100}`);
});requires_payment_method or canceled represent failures. For complete monitoring, query both.Set Up Webhooks for Real-Time Failure Alerts
Polling the API is manual and slow. Webhooks notify you instantly when a payment fails, so you can recover immediately.
Create a secure webhook endpoint on your server
Build an HTTPS endpoint that receives and verifies webhook signatures. Always validate the Stripe-Signature header using your webhook secret — never trust an unverified request.
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
const secret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, secret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.sendStatus(400);
}
// Process the event (see next step)
console.log('Verified event:', event.type);
res.sendStatus(200); // Always respond 200 immediately
});
app.listen(3000, () => console.log('Webhook server running on port 3000'));Register your webhook endpoint in the Stripe dashboard
Go to Developers > Webhooks and click Add endpoint. Paste your endpoint URL (must be HTTPS and publicly accessible). Select the events you want: payment_intent.payment_failed and charge.failed. Save and copy your Signing Secret to .env.
Listen for failure events and alert your team
When Stripe sends a failure webhook, extract the payment intent or charge object. Log it, send a Slack alert, create a PagerDuty incident, or flag it in your database so you can retry later. Always return HTTP 200 immediately, then process asynchronously.
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
if (event.type === 'payment_intent.payment_failed') {
const paymentIntent = event.data.object;
const { customer, last_payment_error, amount } = paymentIntent;
console.error(`Payment failed for customer ${customer}:`, last_payment_error.message);
// Send alert (Slack, email, PagerDuty, etc.)
sendAlert({
customer,
amount: amount / 100,
reason: last_payment_error.code,
paymentIntentId: paymentIntent.id
});
}
if (event.type === 'charge.failed') {
const charge = event.data.object;
console.error(`Charge ${charge.id} failed: ${charge.failure_message}`);
}
res.sendStatus(200);
});Implement Automatic Retry Logic
Detecting failures is only half the battle. You recover revenue by retrying with a fresh payment method.
Identify which failures are worth retrying
Not all failures can be recovered. Transient failures like network timeouts or rate limits often succeed on retry, but permanent failures (lost card, closed account, fraud) will fail again. Check the failure_code on the charge or payment intent before retrying. Codes like card_declined, expired_card, and incorrect_cvc may be retryable if the customer provides a new card.
Retry with a fresh payment method and idempotency key
Create a new Payment Intent with the customer's updated or saved payment method. Always pass an idempotencyKey to prevent duplicate charges if your request fails and you retry. Set confirm: true to immediately attempt the charge.
const customerId = 'cus_...';
const originalPaymentIntentId = 'pi_...';
const amountInCents = 5000; // $50.00
const idempotencyKey = `retry_${originalPaymentIntentId}_${Date.now()}`;
const retryPaymentIntent = await stripe.paymentIntents.create(
{
customer: customerId,
amount: amountInCents,
currency: 'usd',
payment_method: newPaymentMethodId, // Customer's updated card
confirm: true,
},
{ idempotencyKey } // Prevents duplicate if request is retried
);
if (retryPaymentIntent.status === 'succeeded') {
console.log('Retry succeeded:', retryPaymentIntent.id);
// Update order status, send confirmation email
} else if (retryPaymentIntent.status === 'requires_action') {
// Customer must complete 3D Secure or other challenge
console.log('Customer action required for:', retryPaymentIntent.client_secret);
} else {
console.error('Retry failed:', retryPaymentIntent.last_payment_error);
}Space out retries with exponential backoff
Don't retry immediately after a failure — wait. Space retries using exponential backoff: retry after 5 minutes, then 15 minutes, then 1 hour. This gives customers time to fix their payment method and avoids hammering Stripe rate limits or angering customers with repeated charge attempts.
stripe.paymentIntents.create() with confirm: true charges the customer immediately. Verify the payment method is valid before confirming, or you risk multiple failed charges in a row.Common Pitfalls
- Conflating
payment_intent.payment_failedandcharge.failedevents — they use different data structures and failure codes. Always handle both. - Skipping webhook signature verification — you risk processing spoofed events and accidentally crediting fraudulent customers or charging the wrong person.
- Retrying too fast without backoff — Stripe may throttle your requests, and customers hate seeing repeated charges. Use exponential backoff and cap retry attempts.
- Assuming all saved payment methods are still valid — cards expire, get reported lost, or change issuer. Always be ready for a second failure and provide a recovery path.
Wrapping Up
You now have a multi-layer strategy: dashboard visibility to spot failures manually, API queries for dashboards and analytics, webhooks for real-time alerts, and retry logic to recover revenue. Combine all three. If you want to track failed payments alongside subscription churn, LTV, and other business metrics across your entire stack, Product Analyst can help.