6 min read

How to Track Failed Payments in Stripe

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.

javascript
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);
});
Verify the webhook signature and log the failure details
Watch out: Always verify the webhook signature. Stripe sends 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.

javascript
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
  });
});
Retrieve failed charges with failure reason and customer ID

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.

javascript
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'})`);
});
View all charges for a customer to identify failure patterns
Tip: The Stripe API pagination limit is 100 items per request. For high-volume queries, use the Reporting API or export data via Exports in the Stripe Dashboard.

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.

javascript
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)
  });
};
Map failure codes to actions and log to analytics
Watch out: Don't retry 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_declined and expired_card won'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 uses charge.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.

Track these metrics automatically

Product Analyst connects to your stack and surfaces the insights that matter.

Try Product Analyst — Free