6 min read

How to Visualize Failed Payments in Stripe

Failed payments are invisible until you know where to look in Stripe. Your customers might be frustrated, your conversion rate dropping, but the dashboard won't scream about it. Here's how to get real visibility into what's actually failing and why.

Access Failed Payments in the Stripe Dashboard

The fastest way to see what's failing right now.

Open the Payments tab and filter for failed charges

In your Stripe account, go to Payments in the left sidebar. You'll see all your transactions. Click the Filters button at the top and select Status: Failed. This immediately shows every charge that didn't go through.

javascript
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

// Fetch failed charges from the API
const failedCharges = await stripe.charges.list({
  status: 'failed',
  limit: 100,
  expand: ['data.dispute', 'data.balance_transaction']
});
This is what the dashboard filter does under the hood

Click a failed charge to see the failure reason

Each row shows the amount, date, and customer. Click on any charge to open its details. The failure_code and failure_message fields tell you exactly why it failed — e.g., card_declined, expired_card, insufficient_funds, or processing_error.

javascript
// Log failure details
failedCharges.data.forEach(charge => {
  console.log({
    id: charge.id,
    amount: charge.amount,
    currency: charge.currency,
    failure_code: charge.failure_code,
    failure_message: charge.failure_message,
    created: new Date(charge.created * 1000)
  });
});

Use Radar to identify patterns in card declines

If you're seeing a spike in card_declined failures, Stripe's Radar for Fraud Teams can help. Navigate to Radar > Blocked transactions to see if a network or issuer is blocking cards. Radar shows aggregated patterns — not just individual failures.

javascript
// Check for charge disputes (which indicate potential fraud blocks)
const chargeWithDispute = failedCharges.data.find(c => c.dispute);

if (chargeWithDispute) {
  console.log('Disputed charge:', {
    chargeId: chargeWithDispute.id,
    disputeReason: chargeWithDispute.dispute.reason,
    disputeStatus: chargeWithDispute.dispute.status
  });
}
Watch out: The dashboard only shows charges from the last 3 months by default. For historical analysis, use the API instead.

Query and Group Failures Programmatically

To build a real dashboard or analyze trends, you need the API. This lets you get all failures, group them, and filter by date range.

Pull failed charges from the API with a date range

Use stripe.charges.list() to fetch failures within a specific window. Include expand: ['data.balance_transaction'] to see the net amount (after fees). This is what you actually received.

javascript
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const startOfMonth = Math.floor(new Date(2025, 2, 1).getTime() / 1000);
const endOfMonth = Math.floor(new Date(2025, 2, 31).getTime() / 1000);

const failedCharges = await stripe.charges.list({
  status: 'failed',
  created: {
    gte: startOfMonth,
    lte: endOfMonth
  },
  limit: 100,
  expand: ['data.balance_transaction']
});

console.log(`Found ${failedCharges.data.length} failed charges in March`);

Group failures by reason to spot the biggest issue

Once you have the data, group by failure_code to see which type of failure is most common. card_declined is usually the biggest bucket, but expired_card and processing_error also tell you something — the first is user-fixable, the second is Stripe's problem.

javascript
const groupedByReason = {};

failedCharges.data.forEach(charge => {
  const code = charge.failure_code;
  if (!groupedByReason[code]) {
    groupedByReason[code] = { count: 0, totalAmount: 0 };
  }
  groupedByReason[code].count += 1;
  groupedByReason[code].totalAmount += charge.amount;
});

const sorted = Object.entries(groupedByReason)
  .sort(([, a], [, b]) => b.count - a.count);

sorted.forEach(([reason, stats]) => {
  console.log(`${reason}: ${stats.count} failures ($${(stats.totalAmount / 100).toFixed(2)})`);
});
Tip: Failures older than 90 days are harder to retrieve via the API. If you need long-term history, log them to your own database as they happen (via webhooks).

Set Up Webhooks for Real-time Visibility

The API shows you what happened in the past. Webhooks let you catch failures as they occur — so you can alert customers, log them, or fix them immediately.

Register a webhook endpoint for charge failures

In Stripe, go to Developers > Webhooks and click Add endpoint. Set your URL (e.g., https://your-app.com/webhooks/stripe) and select the charge.failed event. Stripe will POST to this URL every time a charge fails.

javascript
// Webhook handler (Express.js example)
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('Webhook sig verification failed:', err.message);
    return res.sendStatus(400);
  }

  if (event.type === 'charge.failed') {
    const charge = event.data.object;
    console.log(`Charge ${charge.id} failed: ${charge.failure_code}`);
  }

  res.sendStatus(200);
});

Log failures to your database or monitoring tool

When you receive a charge.failed webhook, extract the failure_code, failure_message, amount, and customer email. Store this in your database so you can query it later. This is also where you'd send a Slack alert or email the customer.

javascript
if (event.type === 'charge.failed') {
  const charge = event.data.object;

  // Log to your database
  await db.insert('failed_payments', {
    stripe_charge_id: charge.id,
    failure_code: charge.failure_code,
    failure_message: charge.failure_message,
    amount: charge.amount,
    customer_id: charge.customer,
    email: charge.billing_details?.email,
    created_at: new Date(charge.created * 1000)
  });

  // Send alert if it's a processing error
  if (charge.failure_code === 'processing_error') {
    await slack.send(`⚠️ Processing error on ${charge.id}`);
  }
}
Tip: Always verify the webhook signature using stripe.webhooks.constructEvent — don't trust POST bodies without it. Test with stripe listen before going live.

Common Pitfalls

  • Confusing failed charges with refunded or disputed charges — they're different statuses and require different follow-ups
  • Relying only on the dashboard and missing older failures — the dashboard shows only recent data; use the API or webhooks for complete history
  • Storing failure reasons but not taking action — knowing 80% of failures are card_declined only helps if you email customers to update their card
  • Assuming all processing_error failures are transient — some indicate issues with your API integration; check Stripe's status page if you see a spike

Wrapping Up

Visualizing failed payments in Stripe is straightforward once you know the three layers: dashboard for quick checks, API for analysis, webhooks for automation. Start with the dashboard, graduate to the API when you need trends, and add webhooks when you're ready to alert or retry. 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