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:
- Go to Developers → Webhooks
- Add endpoint:
https://yourdomain.com/api/webhooks/stripe - 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
- What is Pre-Dunning? — Complete definition
- Card Expiration Email Templates — Copy-paste templates
- Prevent Failed Payments Guide — Full strategy
- Stripe Webhook Documentation — Official docs
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.