Payment failures happen—bad cards, insufficient funds, rate limits. If you're only finding out about them when customers complain, you're already behind. Stripe gives you the tools to track failures in real time and investigate why they happened, but you need to wire them up.
Listen to Payment Failures with Webhooks
Webhooks let you react to payment failures as they happen. You'll catch charge.failed and payment_intent.payment_failed events before customers notice.
Enable webhooks in your Stripe account
Go to Developers > Webhooks in the Stripe Dashboard. Click Add Endpoint. Enter your app's webhook URL (e.g., https://yourapp.com/webhooks/stripe). Select events: check charge.failed and payment_intent.payment_failed. Click Add Endpoint to save.
Handle the webhook in your app
When Stripe sends a webhook, verify the signature to ensure it came from Stripe. Extract the event type and payment object. Log the failure with the customer ID, amount, and failure code.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const express = require('express');
const app = express();
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.sendStatus(400);
}
if (event.type === 'charge.failed' || event.type === 'payment_intent.payment_failed') {
const charge = event.data.object;
console.log(`Payment failed for customer ${charge.customer}:`, {
amount: charge.amount,
currency: charge.currency,
failure_code: charge.failure_code,
failure_message: charge.failure_message
});
}
res.sendStatus(200);
});stripe-signature in the header—use stripe.webhooks.constructEvent() to validate. Never trust the raw body.Query Failed Payments Historically
Webhooks catch failures in real time, but sometimes you need to audit the past. Use the Stripe API to find failed charges or payment intents.
List failed charges from the API
Call the Charges API with status: 'failed' to fetch all failed charges. Filter by date range with created to narrow results. Each charge object includes failure_code and failure_message to understand why it failed.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// Fetch failed charges from the last 7 days
const now = Math.floor(Date.now() / 1000);
const sevenDaysAgo = now - (7 * 24 * 60 * 60);
const failedCharges = await stripe.charges.list({
limit: 100,
status: 'failed',
created: { gte: sevenDaysAgo, lte: now }
});
failedCharges.data.forEach(charge => {
console.log(`Failed: ${charge.id}`, {
customer: charge.customer,
amount: charge.amount / 100,
reason: charge.failure_code,
message: charge.failure_message
});
});Filter by customer to debug specific issues
If a customer reports payment problems, fetch their payment history with the customer parameter. You'll see all charges (succeeded and failed) for that customer. Sort by date to see the pattern.
const customerId = 'cus_1234567890';
const customerCharges = await stripe.charges.list({
customer: customerId,
limit: 50
});
customerCharges.data.forEach(charge => {
console.log(`${charge.created} - ${charge.status.toUpperCase()}: $${(charge.amount / 100).toFixed(2)} (${charge.failure_code || 'success'})`);
});Understand Failure Codes and Act on Them
Not all failures are equal. Stripe categorizes them—temporary (retry later) vs. permanent (customer action needed). Your response depends on the code.
Map failure codes to customer-friendly messages
Each failed charge has a failure_code like insufficient_funds, expired_card, or card_declined. Check the Stripe docs for the full list. Use this code to decide: retry automatically, email the customer, or escalate.
Log failures to your analytics system
Every failed charge should flow into your analytics dashboard. Track failure rate by failure code, by customer, by invoice. Set up alerts if failure rate spikes—it might indicate a platform issue or fraud.
const failureCodeActions = {
'insufficient_funds': { shouldRetry: true, delay: '24h' },
'expired_card': { shouldRetry: false, needsCustomer: true },
'card_declined': { shouldRetry: false, needsCustomer: true },
'lost_card': { shouldRetry: false, needsCustomer: true },
'stolen_card': { shouldRetry: false, blockAccount: true },
'processing_error': { shouldRetry: true, delay: '1h' }
};
const logPaymentFailure = async (charge) => {
const action = failureCodeActions[charge.failure_code];
await analytics.track({
event: 'payment_failed',
customerId: charge.customer,
amount: charge.amount / 100,
failureCode: charge.failure_code,
failureMessage: charge.failure_message,
shouldRetry: action?.shouldRetry,
timestamp: new Date(charge.created * 1000)
});
};card_declined, expired_card, or lost_card failures—they need customer action. Stripe's automatic retry logic handles processing_error and similar transient issues. Always check charge.outcome.type to see what Stripe already attempted.Common Pitfalls
- Relying on webhooks alone—webhooks can fail or be delayed. Always reconcile with the API during your daily batch process.
- Not verifying webhook signatures—unverified webhooks are a security vulnerability. Use
stripe.webhooks.constructEvent()every time. - Retrying failures that need customer action—
card_declinedandexpired_cardwon't succeed on retry. Contact the customer instead. - Missing the difference between charges and payment intents—newer flows use
payment_intent.payment_failed, older code usescharge.failed. Both can fail; listen to both.
Wrapping Up
Tracking failed payments in Stripe means listening to webhooks, querying the API for history, and understanding failure codes well enough to respond appropriately. Most failures are temporary or customer-driven, but visibility is the first step to improving your recovery rate. If you want to track this automatically across tools, Product Analyst can help.