Bonaventure OgetoBy Bonaventure Ogeto|

Building a Dunning Email Sequence for Paystack Failures

A dunning sequence for Paystack failures should include 4-5 emails over 7-10 days. Email 1 (day 0): friendly notification that payment failed with a link to update their card. Email 2 (day 3): reminder with urgency. Email 3 (day 7): warning that access will be restricted. Email 4 (day 10): final notice before suspension. Each email should include a direct link to update the payment method. Track which emails recover payment and optimize timing based on your data.

What Dunning Does for Revenue

Without dunning, every failed recurring charge becomes lost revenue. The customer does not know their payment failed (or they know but procrastinate). Their subscription lapses. They churn.

With dunning, you notify the customer, make it easy to fix, retry the charge, and recover the payment. Good dunning sequences recover 30-50% of involuntary churn (failures caused by card issues, not by customers choosing to leave).

In African markets, dunning is especially important because prepaid and debit card failures are more common. "Insufficient Funds" is the most frequent decline reason. Timing your dunning emails around salary dates can dramatically improve recovery.

This article is part of the subscriptions and recurring billing guide. For the technical implementation of retry logic, see handling failed recurring charges.

The Four-Email Dunning Sequence

Here is a proven four-email sequence with timing and tone for each:

Email 1 - Day 0: "Your payment did not go through"

Tone: Helpful, no blame. Subject line: "Action needed: your [Product] payment did not go through."

Content: Explain that the charge failed, mention the card on file (Visa ending in 4081), include a prominent button to update the payment method, and reassure them that their account is still active. Do not mention suspension or cancellation yet.

Email 2 - Day 3: "Reminder: please update your payment"

Tone: Gentle urgency. Subject line: "Your [Product] subscription needs attention."

Content: Remind them the payment failed, note that this is the second notice, include the update button, and mention that continued access depends on resolving the payment.

Email 3 - Day 7: "Your access will be affected"

Tone: Clear and direct. Subject line: "Important: your [Product] access will change in 3 days."

Content: State that the payment has been outstanding for a week, explain what will change if not resolved (features restricted, account downgraded), include the update button, and offer to help if they are having issues.

Email 4 - Day 10: "Your account has been suspended"

Tone: Final, but not hostile. Subject line: "Your [Product] account has been suspended."

Content: Inform them that access has been restricted, explain how to reactivate (update card and the system will charge automatically), assure them that their data is safe, and include the update button.

Automating the Sequence

var DUNNING_SEQUENCE = [
  { dayOffset: 0, templateId: 'dunning_initial', retryCharge: false },
  { dayOffset: 3, templateId: 'dunning_reminder', retryCharge: true },
  { dayOffset: 7, templateId: 'dunning_warning', retryCharge: true },
  { dayOffset: 10, templateId: 'dunning_suspension', retryCharge: false },
];

async function startDunningSequence(userId, failedChargeReference) {
  // Create dunning record
  await db.query(
    'INSERT INTO dunning_sequences (user_id, failed_reference, status, step, started_at, next_email_at) VALUES ($1, $2, $3, $4, NOW(), NOW())',
    [userId, failedChargeReference, 'active', 0]
  );
}

// Run this every hour via cron
async function processDunningQueue() {
  var due = await db.query(
    'SELECT ds.id, ds.user_id, ds.step, ds.failed_reference, u.email FROM dunning_sequences ds JOIN users u ON ds.user_id = u.id WHERE ds.status = $1 AND ds.next_email_at <= NOW()',
    ['active']
  );

  for (var i = 0; i < due.rows.length; i++) {
    var record = due.rows[i];
    var step = DUNNING_SEQUENCE[record.step];

    if (!step) {
      // All steps exhausted. Suspend the account.
      await suspendAccount(record.user_id);
      await db.query(
        'UPDATE dunning_sequences SET status = $1 WHERE id = $2',
        ['exhausted', record.id]
      );
      continue;
    }

    // Retry the charge if this step includes a retry
    if (step.retryCharge) {
      var chargeResult = await retryFailedCharge(record.user_id);
      if (chargeResult && chargeResult.status === 'success') {
        // Payment recovered. End the dunning sequence.
        await db.query(
          'UPDATE dunning_sequences SET status = $1, recovered_at = NOW(), recovered_at_step = $2 WHERE id = $3',
          ['recovered', record.step, record.id]
        );
        await restoreAccount(record.user_id);
        await sendPaymentRecoveredEmail(record.email);
        continue;
      }
    }

    // Send the dunning email
    var cardUpdateUrl = await generateCardUpdateUrl(record.email, record.user_id);
    await sendDunningEmail(record.email, step.templateId, {
      cardUpdateUrl: cardUpdateUrl,
      daysSinceFailure: step.dayOffset,
    });

    // Schedule the next step
    var nextStep = record.step + 1;
    var nextEmailAt = null;

    if (nextStep < DUNNING_SEQUENCE.length) {
      var daysUntilNext = DUNNING_SEQUENCE[nextStep].dayOffset - step.dayOffset;
      nextEmailAt = new Date(Date.now() + daysUntilNext * 24 * 60 * 60 * 1000);
    }

    await db.query(
      'UPDATE dunning_sequences SET step = $1, next_email_at = $2, last_email_at = NOW() WHERE id = $3',
      [nextStep, nextEmailAt, record.id]
    );
  }
}

The Card Update Flow

Every dunning email should include a link to update the payment method. This link takes the customer to a page where they can pay with a new card. The payment creates a new authorization code that replaces the old one.

async function generateCardUpdateUrl(email, userId) {
  var response = await axios.post(
    'https://api.paystack.co/transaction/initialize',
    {
      email: email,
      amount: 5000, // Small verification charge
      callback_url: 'https://yoursite.com/card-updated?user=' + userId,
      metadata: {
        purpose: 'card_update_dunning',
        user_id: userId,
      },
    },
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        'Content-Type': 'application/json',
      },
    }
  );

  return response.data.data.authorization_url;
}

// Handle the callback when the customer updates their card
async function handleCardUpdateCallback(reference, userId) {
  var verification = await verifyTransaction(reference);

  if (verification.status !== 'success' || !verification.authorization.reusable) {
    return { success: false };
  }

  // Save the new authorization
  await saveAuthorization(userId, verification.authorization);

  // Charge the overdue amount with the new card
  var overdue = await getOverdueAmount(userId);
  if (overdue > 0) {
    var result = await chargeAuthorization(
      verification.customer.email,
      overdue,
      verification.authorization.authorization_code,
      'RECOVERY_' + userId + '_' + Date.now()
    );

    if (result.status === 'success') {
      await resolveDunning(userId);
      await restoreAccount(userId);
      return { success: true, charged: overdue };
    }
  }

  return { success: true, charged: 0 };
}

The best card update flows are one-click from the email. Do not require the customer to log in first. Use a secure, time-limited token in the URL that authenticates them directly to the card update page.

Email Content Principles

The goal of a dunning email is to get the customer to fix the payment, not to punish them. Keep these principles in mind:

Be specific about what happened. "Your Visa ending in 4081 was declined" is better than "Your payment failed." Show the card on file so they know which card to fix.

Make the fix obvious. One big button: "Update Payment Method." Do not bury it in a paragraph. Do not make them log in and navigate to settings.

Do not blame the customer. They probably did not intend to miss the payment. Their card expired, their balance was low, or their bank blocked the transaction. Assume good intent.

Escalate gradually. The first email is casual. The last email is serious. Do not start with "Your account will be suspended" on day one.

Include an escape hatch. If the customer does not want to continue, make it easy to cancel. Trapping people in a dunning loop they cannot exit creates resentment and chargebacks.

Consider the channel. In some African markets, SMS or WhatsApp gets higher open rates than email. If you have the customer's phone number and consent, send an SMS alongside the email.

Timing Around Salary Dates

In many African markets, salaries are paid on specific dates: the 25th-31st of the month, or the 1st. "Insufficient Funds" failures spike just before payday and drop right after.

If your billing runs on the 15th and a charge fails with "Insufficient Funds," scheduling a retry for the 1st or 2nd of the next month (when salaries have likely arrived) can significantly improve recovery rates.

function getOptimalRetryDate(failureDate, gatewayResponse) {
  if (gatewayResponse !== 'Insufficient Funds') {
    // For non-fund-related failures, use the standard retry schedule
    return new Date(failureDate.getTime() + 3 * 24 * 60 * 60 * 1000);
  }

  // For insufficient funds, try to align with common salary dates
  var currentDay = failureDate.getDate();
  var retryDate = new Date(failureDate);

  if (currentDay < 25) {
    // Try the 25th (early salary payments)
    retryDate.setDate(25);
  } else if (currentDay >= 25 && currentDay <= 31) {
    // Try the 1st of next month
    retryDate.setMonth(retryDate.getMonth() + 1);
    retryDate.setDate(1);
  } else {
    // Already early in the month. Try in 3 days.
    retryDate.setDate(retryDate.getDate() + 3);
  }

  return retryDate;
}

This is not a guarantee, but aligning retries with payday patterns has shown measurable improvement for products serving employed professionals in Nigeria, Kenya, and Ghana.

Measuring Dunning Effectiveness

async function getDunningMetrics(startDate, endDate) {
  var total = await db.query(
    'SELECT COUNT(*) as count FROM dunning_sequences WHERE started_at BETWEEN $1 AND $2',
    [startDate, endDate]
  );

  var recovered = await db.query(
    'SELECT COUNT(*) as count, recovered_at_step FROM dunning_sequences WHERE started_at BETWEEN $1 AND $2 AND status = $3 GROUP BY recovered_at_step',
    [startDate, endDate, 'recovered']
  );

  var exhausted = await db.query(
    'SELECT COUNT(*) as count FROM dunning_sequences WHERE started_at BETWEEN $1 AND $2 AND status = $3',
    [startDate, endDate, 'exhausted']
  );

  var totalCount = parseInt(total.rows[0].count);
  var recoveredCount = 0;
  var byStep = {};

  for (var i = 0; i < recovered.rows.length; i++) {
    var row = recovered.rows[i];
    var stepCount = parseInt(row.count);
    recoveredCount = recoveredCount + stepCount;
    byStep['step_' + row.recovered_at_step] = stepCount;
  }

  return {
    totalSequences: totalCount,
    recovered: recoveredCount,
    recoveryRate: totalCount > 0 ? (recoveredCount / totalCount * 100).toFixed(1) + '%' : '0%',
    exhausted: parseInt(exhausted.rows[0].count),
    recoveryByStep: byStep,
  };
}

Key metrics to track:

  • Overall recovery rate: Percentage of dunning sequences that result in payment recovery. Target: 30-50%.
  • Recovery by step: Which email in the sequence recovers the most payments. This tells you which messages are effective.
  • Time to recovery: How many days between the first failure and the recovered payment. Shorter is better.
  • Card update rate: Percentage of customers who click the card update link. Low click rates mean your emails are not compelling enough or the link is not prominent enough.

In-App Dunning: Beyond Email

Emails are the foundation, but adding in-app notifications increases recovery. When a customer logs in during a dunning period, show them a banner they cannot miss.

// API endpoint that the frontend calls on page load
router.get('/api/billing/status', async function(req, res) {
  var userId = req.user.id;

  var dunning = await db.query(
    'SELECT status, step, started_at FROM dunning_sequences WHERE user_id = $1 AND status = $2 ORDER BY started_at DESC LIMIT 1',
    [userId, 'active']
  );

  if (dunning.rows.length === 0) {
    return res.json({ status: 'ok' });
  }

  var record = dunning.rows[0];
  var daysSinceStart = Math.floor(
    (Date.now() - new Date(record.started_at).getTime()) / (24 * 60 * 60 * 1000)
  );

  var cardUpdateUrl = await generateCardUpdateUrl(req.user.email, userId);

  res.json({
    status: 'payment_failed',
    daysSinceFailure: daysSinceStart,
    cardUpdateUrl: cardUpdateUrl,
    message: 'Your last payment did not go through. Please update your payment method to keep your account active.',
    urgency: daysSinceStart > 7 ? 'high' : 'medium',
  });
});

The in-app banner catches customers who do not read email. Combined with email and optional SMS, this multi-channel approach maximizes recovery. For the complete technical integration, see the recurring billing guide.

Key Takeaways

  • A dunning sequence is a series of emails sent when a recurring charge fails. Good sequences recover 30-50% of failed charges.
  • Send the first email immediately after the failure. It should be helpful, not threatening. Include a clear link to update the payment method.
  • Space emails 2-3 days apart. Four to five emails over 7-10 days is the standard cadence.
  • Each email should escalate in urgency but remain respectful. The customer probably did not intend to miss the payment.
  • Include the card update link in every email. Make it one click to fix the problem.
  • Track recovery rates per email in the sequence. This tells you which emails are effective and which are ignored.

Frequently Asked Questions

How many dunning emails should I send?
Four to five emails over 7-10 days is the standard cadence. More than five emails risks annoying the customer. Fewer than three misses recovery opportunities. Space them 2-3 days apart, escalating in urgency with each email.
Should I retry the charge on the same day I send the dunning email?
Not on the first email (day 0), because the charge just failed. On subsequent emails (day 3 and day 7), retry before sending the email. If the retry succeeds, send a recovery confirmation instead of a dunning email. This avoids sending a dunning email for an already-resolved issue.
What recovery rate should I expect from dunning?
A well-implemented dunning sequence recovers 30-50% of involuntary churned customers. In African markets with higher card failure rates, the absolute number of recoveries can be significant. Even a 20% recovery rate represents real revenue that would otherwise be lost.
Should I send dunning emails via WhatsApp or SMS instead of email?
Consider adding SMS or WhatsApp as a complement to email, not a replacement. Some African markets have higher SMS open rates than email. Use email as the primary channel (it can contain detailed information and the card update link) and SMS as a nudge. Always respect communication preferences and regulations.
What should I do after the dunning sequence is exhausted and the customer still has not paid?
Suspend the account (restrict access but preserve data). Send a final email with reactivation instructions. Keep the account and data for 30-90 days in case the customer comes back. After that retention period, you can archive or delete according to your data retention policy.

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