GuideTechnical

How to Detect Expiring Cards in Stripe: Complete Technical Guide

Learn how to detect expiring credit cards in Stripe using webhooks and API queries. Implement pre-dunning to prevent failed payments before they happen.

8 min readJanuary 28, 2026By Rekko Team

In this guide: Complete walkthrough with formulas, examples, and practical tips you can apply today.

Detecting expiring credit cards before they cause payment failures is one of the highest-ROI things you can do for your subscription business. This technical guide shows you how to implement card expiration detection in Stripe.

Two Approaches to Detection

1. Webhook-Based (Recommended)

Stripe sends a customer.source.expiring webhook when a card is about to expire.

Pros:

  • Real-time notifications
  • No polling required
  • Stripe handles the timing

Cons:

  • Only triggers ~30 days before expiry
  • Need webhook infrastructure

2. API Query-Based

Query your customers and check card expiration dates directly.

Pros:

  • Full control over timing
  • Can check any timeframe
  • Works without webhooks

Cons:

  • Requires scheduled jobs
  • More API calls

Recommendation: Use webhooks as primary, with periodic API queries as backup.


Method 1: Webhook Detection

Step 1: Set Up the Webhook

In your Stripe Dashboard:

  1. Go to Developers → Webhooks
  2. Add endpoint: https://yourdomain.com/api/webhooks/stripe
  3. Select event: customer.source.expiring

Step 2: Handle the Webhook

// /api/webhooks/stripe/route.ts

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature');

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return new Response('Webhook signature verification failed', { status: 400 });
  }

  if (event.type === 'customer.source.expiring') {
    const source = event.data.object as Stripe.Card;

    // Extract card details
    const customerId = source.customer as string;
    const last4 = source.last4;
    const expMonth = source.exp_month;
    const expYear = source.exp_year;

    // Get customer details
    const customer = await stripe.customers.retrieve(customerId);

    // Trigger your pre-dunning sequence
    await triggerPreDunningSequence({
      customerId,
      customerEmail: customer.email,
      cardLast4: last4,
      expirationDate: `${expMonth}/${expYear}`,
    });
  }

  return new Response('OK', { status: 200 });
}

Step 3: Trigger Pre-Dunning

async function triggerPreDunningSequence(data: {
  customerId: string;
  customerEmail: string;
  cardLast4: string;
  expirationDate: string;
}) {
  // Option 1: Add to your email queue
  await emailQueue.add('pre-dunning-sequence', {
    ...data,
    sequenceDay: 30, // Start at 30 days
  });

  // Option 2: Create a record for tracking
  await db.preDunningAlert.create({
    customerId: data.customerId,
    cardLast4: data.cardLast4,
    expirationDate: data.expirationDate,
    status: 'pending',
    nextReminderAt: new Date(), // Send first email immediately
  });
}

Method 2: API Query Detection

Step 1: Create a Scheduled Job

Run daily to check for expiring cards.

// /jobs/check-expiring-cards.ts

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function checkExpiringCards() {
  const now = new Date();
  const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);

  const targetMonth = thirtyDaysFromNow.getMonth() + 1; // 1-12
  const targetYear = thirtyDaysFromNow.getFullYear();

  // Get all customers with payment methods
  let hasMore = true;
  let startingAfter: string | undefined;

  while (hasMore) {
    const customers = await stripe.customers.list({
      limit: 100,
      starting_after: startingAfter,
      expand: ['data.default_source'],
    });

    for (const customer of customers.data) {
      // Check default payment method
      const paymentMethods = await stripe.paymentMethods.list({
        customer: customer.id,
        type: 'card',
      });

      for (const pm of paymentMethods.data) {
        const card = pm.card;

        if (card && isExpiringSoon(card.exp_month, card.exp_year, 30)) {
          // Check if we've already alerted this customer
          const existingAlert = await db.preDunningAlert.findFirst({
            where: {
              customerId: customer.id,
              cardLast4: card.last4,
              createdAt: { gte: getMonthStart() },
            },
          });

          if (!existingAlert) {
            await triggerPreDunningSequence({
              customerId: customer.id,
              customerEmail: customer.email,
              cardLast4: card.last4,
              expirationDate: `${card.exp_month}/${card.exp_year}`,
            });
          }
        }
      }
    }

    hasMore = customers.has_more;
    if (hasMore) {
      startingAfter = customers.data[customers.data.length - 1].id;
    }
  }
}

function isExpiringSoon(
  expMonth: number,
  expYear: number,
  daysThreshold: number
): boolean {
  const now = new Date();
  // Card expires at end of exp_month
  const expiryDate = new Date(expYear, expMonth, 0); // Last day of month
  const daysUntilExpiry = Math.floor(
    (expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
  );

  return daysUntilExpiry > 0 && daysUntilExpiry <= daysThreshold;
}

Step 2: Schedule the Job

Using a cron job or task scheduler:

// Run daily at 9 AM
cron.schedule('0 9 * * *', () => {
  checkExpiringCards();
});

Building the Pre-Dunning Sequence

Sequence Timeline

const PRE_DUNNING_SCHEDULE = [
  { day: 30, channel: 'email', template: 'pre-dunning-30-day' },
  { day: 14, channel: 'email', template: 'pre-dunning-14-day' },
  { day: 14, channel: 'sms', template: 'pre-dunning-sms' },
  { day: 7, channel: 'email', template: 'pre-dunning-7-day' },
  { day: 3, channel: 'sms', template: 'pre-dunning-urgent-sms' },
];

Processing the Sequence

// /jobs/process-pre-dunning.ts

export async function processPreDunning() {
  const pendingAlerts = await db.preDunningAlert.findMany({
    where: {
      status: 'pending',
      nextReminderAt: { lte: new Date() },
    },
  });

  for (const alert of pendingAlerts) {
    const daysUntilExpiry = calculateDaysUntilExpiry(alert.expirationDate);

    // Find the appropriate reminder for current day
    const reminder = PRE_DUNNING_SCHEDULE.find(
      r => r.day >= daysUntilExpiry && r.day < daysUntilExpiry + 7
    );

    if (reminder) {
      if (reminder.channel === 'email') {
        await sendEmail({
          to: alert.customerEmail,
          template: reminder.template,
          data: {
            cardLast4: alert.cardLast4,
            expirationDate: alert.expirationDate,
            updateUrl: generateUpdateUrl(alert.customerId),
          },
        });
      } else if (reminder.channel === 'sms') {
        await sendSMS({
          to: alert.customerPhone,
          template: reminder.template,
          data: {
            cardLast4: alert.cardLast4,
            updateUrl: generateShortUrl(alert.customerId),
          },
        });
      }

      // Update next reminder time
      const nextReminder = getNextReminder(daysUntilExpiry);
      if (nextReminder) {
        await db.preDunningAlert.update({
          where: { id: alert.id },
          data: {
            nextReminderAt: calculateNextReminderDate(nextReminder.day),
            lastReminderSent: reminder.template,
          },
        });
      } else {
        // No more reminders, mark as completed
        await db.preDunningAlert.update({
          where: { id: alert.id },
          data: { status: 'completed' },
        });
      }
    }
  }
}

Generating Payment Update URLs

Create pre-authenticated URLs so customers can update without logging in:

// Generate secure, time-limited update URL
function generateUpdateUrl(customerId: string): string {
  const token = jwt.sign(
    { customerId, action: 'update-payment' },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );

  return `${process.env.APP_URL}/update-payment?token=${token}`;
}

// Short URL for SMS
async function generateShortUrl(customerId: string): Promise<string> {
  const fullUrl = generateUpdateUrl(customerId);
  // Use your short URL service or create a redirect
  const shortCode = generateShortCode();

  await db.shortUrl.create({
    code: shortCode,
    targetUrl: fullUrl,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  });

  return `${process.env.APP_URL}/r/${shortCode}`;
}

Tracking Card Updates

Know when customers update their cards:

Method 1: Webhook

// Listen for payment method updates
if (event.type === 'payment_method.attached' ||
    event.type === 'customer.source.created') {

  const customerId = event.data.object.customer;

  // Mark pre-dunning alert as resolved
  await db.preDunningAlert.updateMany({
    where: {
      customerId,
      status: 'pending'
    },
    data: {
      status: 'card_updated',
      resolvedAt: new Date(),
    },
  });
}

Method 2: Check Before Sending

async function shouldSendReminder(customerId: string): Promise<boolean> {
  // Get current default payment method
  const customer = await stripe.customers.retrieve(customerId, {
    expand: ['default_source'],
  });

  const paymentMethods = await stripe.paymentMethods.list({
    customer: customerId,
    type: 'card',
  });

  // Check if any card is NOT expiring soon
  const hasValidCard = paymentMethods.data.some(pm => {
    const card = pm.card;
    return card && !isExpiringSoon(card.exp_month, card.exp_year, 7);
  });

  return !hasValidCard;
}

Complete Flow Diagram

┌─────────────────────────────────────────────────────────────┐
│                     CARD EXPIRATION FLOW                     │
└─────────────────────────────────────────────────────────────┘

Stripe sends webhook ─────────────────────────────────────────►
  customer.source.expiring                                     │
  (30 days before expiry)                                      │
                                                               │
                          OR                                   │
                                                               │
Daily cron job ───────────────────────────────────────────────►
  Query customers for expiring cards                           │
                                                               │
                          ▼                                    │
                ┌─────────────────┐                            │
                │ Create Alert    │                            │
                │ in Database     │                            │
                └────────┬────────┘                            │
                         │                                     │
           ┌─────────────┴─────────────┐                       │
           ▼                           ▼                       │
    ┌─────────────┐             ┌─────────────┐                │
    │ Send Email  │             │  Send SMS   │                │
    │ (Day 30,14,7)│            │ (Day 14, 3) │                │
    └──────┬──────┘             └──────┬──────┘                │
           │                           │                       │
           └───────────┬───────────────┘                       │
                       ▼                                       │
              ┌─────────────────┐                              │
              │ Customer clicks │                              │
              │ update link     │                              │
              └────────┬────────┘                              │
                       ▼                                       │
              ┌─────────────────┐                              │
              │ Update payment  │                              │
              │ method in Stripe│                              │
              └────────┬────────┘                              │
                       ▼                                       │
              ┌─────────────────┐                              │
              │ Mark alert as   │                              │
              │ resolved        │                              │
              └─────────────────┘                              │

Best Practices

1. Deduplicate Alerts

Don't send multiple alerts for the same expiring card.

2. Handle Multiple Cards

Customers may have multiple payment methods. Check the default one or all of them.

3. Rate Limit Messages

Maximum 4-5 messages per expiration event.

4. Track Everything

Log every email/SMS sent and every card update.

5. Handle Edge Cases

  • Customer with no email
  • Card updated mid-sequence
  • Multiple subscriptions per customer

Testing Your Implementation

Test with Stripe Test Mode

Use test card numbers with specific expiration dates:

Card Number Expiration Purpose
4242424242424242 Current month Test expired
4242424242424242 Next month Test expiring soon
4242424242424242 12/2030 Test valid card

Trigger Test Webhooks

Use Stripe CLI:

stripe trigger customer.source.expiring

Related Resources


Skip the Implementation

Building pre-dunning detection from scratch takes significant engineering time. Rekko handles all of this automatically:

  • Connects to Stripe via OAuth (2 minutes)
  • Automatically detects expiring cards
  • Sends pre-dunning sequences at optimal times
  • Tracks card updates and prevents duplicate messages
  • Provides dashboard with all metrics

Start your free trial → — Pre-dunning working in minutes, not weeks.

stripepre-dunningwebhookscard expirationapi

Put This Into Practice

Understanding churn metrics is the first step. Rekko helps you act on them by automatically recovering failed payments.

Related Guides