McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Handling Card Expiry in Paystack Recurring Billing
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

Handling Card Expiry in Paystack Recurring Billing

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.

In this article

  1. 1. Why Card Expiry Matters for Recurring Billing
  2. 2. Tracking Expiry Dates in Your Database
  3. 3. Finding Cards About to Expire
  4. 4. The Pre-Expiry Notification Sequence
  5. 5. The Card Update Flow
  6. 6. Bank-Initiated Card Changes
  7. 7. Metrics and Monitoring

Why Card Expiry Matters for Recurring Billing

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.

Tracking Expiry Dates in Your Database

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.

Finding Cards About to Expire

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;
}

The Pre-Expiry Notification Sequence

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."

The Card Update Flow

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.

Bank-Initiated Card Changes

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:

  • Card expiry monitoring catches predictable, scheduled card deaths.
  • Dunning catches unpredictable card failures including bank-initiated reissuance.

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.

Metrics and Monitoring

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.

Key Takeaways

  • ✓Card expiry is predictable. The exp_month and exp_year fields in the authorization object tell you exactly when the card will stop working.
  • ✓Proactive notification beats reactive dunning. Emailing a customer before their card expires is cheaper and more effective than recovering from a failed charge.
  • ✓Run a monthly scan for cards expiring in the next 30-60 days. Send notifications at 30 days, 14 days, and 3 days before expiry.
  • ✓The card update flow collects a new card payment, stores the new authorization, and updates any active subscriptions to use the new card.
  • ✓Paystack does not notify you when a card expires. You must track expiry dates and act on them yourself.
  • ✓In African markets, card reissuance by banks can change card numbers even before expiry. The only way to detect this is when the charge fails.

Frequently Asked Questions

Does Paystack notify me when a card is about to expire?
No. Paystack does not send card expiry notifications. You must track exp_month and exp_year from the authorization object in your own database and implement your own notification system.
When exactly does a card expire?
A card expires at the end of its expiry month. A card with exp_month 07 and exp_year 2026 is valid through July 31, 2026. Charges on August 1 and beyond will fail. Some banks may deactivate cards a few days before the end of the month.
What happens to a Paystack subscription when the card expires?
The next renewal charge fails. Paystack sends an invoice.payment_failed webhook. Paystack retries according to its schedule. If all retries fail, the subscription may be disabled. Proactive card expiry notifications prevent this scenario entirely.
Can the customer update their card on a Paystack subscription without cancelling?
There is no direct API to swap the card on an existing subscription. The practical approach is to collect a new card payment, cancel the old subscription, and create a new subscription with the new authorization. Alternatively, for manual charge_authorization billing, just update the authorization code in your database.
How far in advance should I notify customers about card expiry?
Start notifications 30 days before expiry. Send reminders at 14 days and 3 days. This gives the customer plenty of time to update their card without feeling rushed. Three touchpoints balance persistence with avoiding annoyance.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Subscriptions and Recurring Billing: Complete Guide

Everything you need to build recurring billing on Paystack. Covers the subscriptions API, plans, authorization codes for manual charges, dunning, lifecycle webhooks, cancellation, and real-world patterns for SaaS, memberships, school fees, and loan collection across Africa.

Read more

Storing Paystack Authorization Codes Securely

Authorization codes are payment credentials. They can charge real money from a customer's card. This guide covers how to store them safely in your database, what metadata to keep alongside them, encryption at rest, access control patterns, and how authorization codes fit into PCI compliance requirements.

Read more

Handling Failed Recurring Charges and Dunning on Paystack

Recurring charges fail. Cards expire, accounts run dry, banks decline for opaque reasons. This guide covers every failure scenario in Paystack recurring billing, shows you how to read gateway responses, build a retry strategy, implement a dunning state machine, and handle the higher failure rates typical in African card markets.

Read more

Building a Dunning Email Sequence for Paystack Failures

A dunning email sequence recovers revenue from failed recurring charges. This guide walks through the timing, content, and automation of each email in the sequence. Covers the first failure notification, retry updates, card update prompts, and final suspension warnings, with code for automating the entire flow.

Read more

Charging a Saved Card with Paystack Authorization Codes

A complete walkthrough of the Paystack charge_authorization endpoint. Learn how to charge a customer's saved card without them re-entering card details, handle success and failure responses, build idempotent charges, and avoid the common mistakes that cause double charges or missed payments.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.