Bonaventure OgetoBy Bonaventure Ogeto|

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.

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;

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.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon