Track exp_month and exp_year from the Paystack authorization object in your database. Run a monthly job to find cards expiring in the next 30-60 days. Send the customer a notification with a link to add a new card before the old one expires. When they add a new card, update your records and switch any active subscriptions to the new authorization.
Every card has an expiry date. When the date passes, charges against the card's authorization code fail. The gateway_response is usually "Card Expired" or similar. This failure is completely predictable if you track the expiry date.
Card expiry is the leading cause of involuntary churn in subscription businesses. A customer who wants to keep paying cannot because their card expired. They might not notice the failed charge email. By the time they do, they have already been downgraded or suspended.
Proactive handling turns a potential churned customer into a seamless card update. The customer receives a notification before the charge fails, updates their card in one click, and never experiences a service interruption.
Paystack does not send expiry notifications. Paystack does not track when your customers' cards are about to expire. That is your job.
This article is part of the subscriptions and recurring billing guide.
When you store a Paystack authorization, store the exp_month and exp_year fields alongside the authorization code.
async function storeAuthorizationWithExpiry(userId, auth) {
await db.query(
'INSERT INTO payment_methods (user_id, authorization_code_encrypted, card_last4, card_type, card_bank, exp_month, exp_year, is_reusable, is_default, paystack_signature) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)',
[
userId,
encrypt(auth.authorization_code),
auth.last4,
auth.card_type,
auth.bank,
parseInt(auth.exp_month),
parseInt(auth.exp_year),
auth.reusable,
true,
auth.signature,
]
);
}
Cards expire at the end of their expiry month. A card with exp_month 07 and exp_year 2026 is valid through the last day of July 2026 and expires on August 1, 2026.
Create an index on the expiry columns for fast scanning:
CREATE INDEX idx_payment_methods_expiry ON payment_methods(exp_year, exp_month)
WHERE deactivated_at IS NULL AND is_reusable = true;
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
async function findExpiringCards(daysAhead) {
var now = new Date();
var futureDate = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
var targetMonth = futureDate.getMonth() + 1;
var targetYear = futureDate.getFullYear();
// Find cards expiring in the target month
var results = await db.query(
'SELECT pm.id, pm.user_id, pm.card_last4, pm.card_type, pm.exp_month, pm.exp_year, u.email, u.first_name FROM payment_methods pm JOIN users u ON pm.user_id = u.id WHERE pm.exp_year = $1 AND pm.exp_month = $2 AND pm.is_reusable = true AND pm.deactivated_at IS NULL AND pm.is_default = true',
[targetYear, targetMonth]
);
return results.rows;
}
// Also find cards already expired (missed the proactive window)
async function findExpiredCards() {
var now = new Date();
var currentMonth = now.getMonth() + 1;
var currentYear = now.getFullYear();
var results = await db.query(
'SELECT pm.id, pm.user_id, pm.card_last4, pm.card_type, pm.exp_month, pm.exp_year, u.email FROM payment_methods pm JOIN users u ON pm.user_id = u.id WHERE pm.is_reusable = true AND pm.deactivated_at IS NULL AND pm.is_default = true AND (pm.exp_year < $1 OR (pm.exp_year = $1 AND pm.exp_month < $2))',
[currentYear, currentMonth]
);
return results.rows;
}
Send three notifications before the card expires:
var EXPIRY_NOTIFICATIONS = [
{ daysBeforeExpiry: 30, templateId: 'card_expiring_30days' },
{ daysBeforeExpiry: 14, templateId: 'card_expiring_14days' },
{ daysBeforeExpiry: 3, templateId: 'card_expiring_3days' },
];
// Run monthly
async function sendExpiryNotifications() {
for (var i = 0; i < EXPIRY_NOTIFICATIONS.length; i++) {
var notification = EXPIRY_NOTIFICATIONS[i];
var expiringCards = await findExpiringCards(notification.daysBeforeExpiry);
for (var j = 0; j < expiringCards.length; j++) {
var card = expiringCards[j];
// Check if we already sent this notification level
var alreadySent = await db.query(
'SELECT id FROM expiry_notifications WHERE payment_method_id = $1 AND notification_type = $2',
[card.id, notification.templateId]
);
if (alreadySent.rows.length > 0) continue;
// Generate card update link
var updateUrl = await generateCardUpdateUrl(card.email, card.user_id);
// Send the notification
await sendEmail(card.email, notification.templateId, {
firstName: card.first_name,
cardDisplay: card.card_type + ' ending in ' + card.card_last4,
expiryDate: card.exp_month + '/' + card.exp_year,
updateUrl: updateUrl,
});
// Record that we sent it
await db.query(
'INSERT INTO expiry_notifications (payment_method_id, user_id, notification_type, sent_at) VALUES ($1, $2, $3, NOW())',
[card.id, card.user_id, notification.templateId]
);
}
}
}
The first notification (30 days) is casual: "Your card is expiring next month. Update it anytime." The second (14 days) is more direct: "Your card expires in two weeks. Update now to avoid service interruption." The third (3 days) is urgent: "Your card expires in 3 days. Update immediately to keep your subscription active."
When the customer clicks the update link, they go through a payment flow that collects their new card details. This creates a new authorization code.
async function handleCardUpdateFromExpiry(reference, userId) {
var verification = await verifyTransaction(reference);
if (verification.status !== 'success') {
return { success: false, message: 'Card verification failed' };
}
if (!verification.authorization.reusable) {
return { success: false, message: 'Please use a card for recurring billing' };
}
var newAuth = verification.authorization;
// Deactivate the old default card
await db.query(
'UPDATE payment_methods SET is_default = false, deactivated_at = NOW() WHERE user_id = $1 AND is_default = true',
[userId]
);
// Save the new card as default
await storeAuthorizationWithExpiry(userId, newAuth);
// Update any active Paystack subscriptions to use the new card
var activeSubscriptions = await db.query(
'SELECT paystack_subscription_code, paystack_email_token FROM billing_accounts WHERE user_id = $1 AND status = $2',
[userId, 'active']
);
for (var i = 0; i < activeSubscriptions.rows.length; i++) {
var sub = activeSubscriptions.rows[i];
// Cancel old subscription and create new one with updated auth
try {
await disablePaystackSubscription(sub.paystack_subscription_code, sub.paystack_email_token);
// The new subscription will use the new authorization
// Handle timing to avoid double charges
} catch (error) {
console.log('Error updating subscription: ' + error.message);
}
}
return {
success: true,
card: newAuth.card_type + ' ending in ' + newAuth.last4,
newExpiry: newAuth.exp_month + '/' + newAuth.exp_year,
};
}
For customers using manual charge_authorization (not the Subscriptions API), updating the card is simpler. You just save the new authorization code and use it for the next charge. No subscription cancellation needed.
Sometimes cards stop working before their printed expiry date. Banks reissue cards for security reasons, merge with other banks, or upgrade card types. The customer gets a new card with a new number, but the old authorization code stops working.
You cannot predict these changes. There is no Paystack event for "card reissued." You find out when the charge fails with a gateway response like "No Card Record" or "Do Not Honor."
This is why your dunning flow and card expiry monitoring work together as complementary systems:
In African markets, bank-initiated card changes are relatively common. Banks merge, upgrade their card infrastructure, or reissue cards after data breaches. Build your system to handle both predictable and unpredictable card failures gracefully.
async function getCardExpiryDashboard() {
var now = new Date();
var currentMonth = now.getMonth() + 1;
var currentYear = now.getFullYear();
// Cards expiring this month
var thisMonth = await db.query(
'SELECT COUNT(*) as count FROM payment_methods WHERE exp_year = $1 AND exp_month = $2 AND is_default = true AND is_reusable = true AND deactivated_at IS NULL',
[currentYear, currentMonth]
);
// Cards expiring next month
var nextMonth = currentMonth === 12 ? 1 : currentMonth + 1;
var nextMonthYear = currentMonth === 12 ? currentYear + 1 : currentYear;
var nextMonthCount = await db.query(
'SELECT COUNT(*) as count FROM payment_methods WHERE exp_year = $1 AND exp_month = $2 AND is_default = true AND is_reusable = true AND deactivated_at IS NULL',
[nextMonthYear, nextMonth]
);
// Already expired but still marked as default (problem)
var alreadyExpired = await db.query(
'SELECT COUNT(*) as count FROM payment_methods WHERE is_default = true AND is_reusable = true AND deactivated_at IS NULL AND (exp_year < $1 OR (exp_year = $1 AND exp_month < $2))',
[currentYear, currentMonth]
);
// Notification sent but card not yet updated
var notifiedNotUpdated = await db.query(
'SELECT COUNT(DISTINCT en.user_id) as count FROM expiry_notifications en JOIN payment_methods pm ON en.payment_method_id = pm.id WHERE pm.deactivated_at IS NULL AND pm.is_default = true',
[]
);
return {
expiringThisMonth: parseInt(thisMonth.rows[0].count),
expiringNextMonth: parseInt(nextMonthCount.rows[0].count),
alreadyExpired: parseInt(alreadyExpired.rows[0].count),
notifiedNotUpdated: parseInt(notifiedNotUpdated.rows[0].count),
};
}
Monitor the "already expired but still default" count. If this number is growing, your notification emails are not effective enough or customers are not seeing them. Investigate open rates, click rates, and the card update completion rate.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course