Handling Failed Recurring Charges and Dunning on Paystack
When a Paystack recurring charge fails, the response includes a gateway_response string explaining why. Map these responses to actions: retry for temporary failures (Insufficient Funds), request a card update for permanent failures (Card Expired), and investigate for ambiguous ones (Do Not Honor). Build a dunning state machine with timed retries and customer notifications. Limit retries to three to five attempts over seven to ten days.
Why Recurring Charges Fail More in Africa
If you have worked with Stripe in the US or Europe, you might expect recurring charge failure rates of 2-5%. In African markets on Paystack, expect higher. The reasons are structural:
- Prepaid and debit cards dominate. Unlike credit cards that have a credit line, debit cards draw from the account balance. If the balance is low on billing day, the charge fails.
- Salary cycles affect balances. Many customers get paid on specific dates. A charge that hits on the 28th might fail if the salary arrives on the 1st.
- Banks are conservative with automated debits. Some Nigerian and Kenyan banks flag recurring charges more aggressively than their European counterparts.
- Card replacement cycles. When banks reissue cards (new chip, new design, bank merger), old authorization codes stop working. Customers may not realize they need to update their payment method.
- Network connectivity. Charges can fail due to timeouts between Paystack, the card network, and the issuing bank. These are transient failures that succeed on retry.
None of this means you should avoid recurring billing. It means your dunning strategy matters more. A good dunning flow recovers 40-60% of failed charges. A missing one loses all of them.
This article is part of the subscriptions and recurring billing guide.
Reading Gateway Responses
When a charge fails, the gateway_response field tells you what the bank or card network reported. Here are the most common responses and what to do about each:
| Gateway Response | Category | Action |
|---|---|---|
| Insufficient Funds | Temporary | Retry in 24-72 hours. The customer may have funds later. |
| Card Expired | Permanent | Do not retry. Ask customer to add a new card. |
| Do Not Honor | Ambiguous | Retry once after 24 hours. If it fails again, ask customer to contact their bank. |
| Transaction Not Permitted | Permanent (likely) | The card has restrictions. Ask customer to contact their bank or use a different card. |
| Invalid Transaction | Configuration error | Check your request parameters (amount, currency, authorization code). |
| No Card Record | Permanent | The card is no longer on file with the bank. Ask customer to add a new card. |
| Suspected Fraud | Permanent | Do not retry. The bank flagged this. Ask customer to contact their bank. |
function categorizeFailure(gatewayResponse) {
var permanent = ['Card Expired', 'No Card Record', 'Suspected Fraud', 'Stolen Card', 'Lost Card'];
var temporary = ['Insufficient Funds', 'System Error', 'Timeout'];
var ambiguous = ['Do Not Honor', 'Transaction Not Permitted'];
if (permanent.indexOf(gatewayResponse) !== -1) {
return 'permanent';
}
if (temporary.indexOf(gatewayResponse) !== -1) {
return 'temporary';
}
if (ambiguous.indexOf(gatewayResponse) !== -1) {
return 'ambiguous';
}
return 'unknown';
}
How the Subscriptions API Handles Failures
When a subscription renewal fails, Paystack manages the retry process:
- Paystack attempts the charge on the billing date.
- If the charge fails, Paystack sends an
invoice.payment_failedwebhook. - Paystack retries the charge over the following days on its own schedule.
- If all retries fail, Paystack may send a
subscription.disablewebhook, meaning the subscription is cancelled.
Your responsibilities during this process:
app.post('/webhooks/paystack', function(req, res) {
var event = req.body;
if (event.event === 'invoice.payment_failed') {
var customerEmail = event.data.customer.email;
var subscriptionCode = event.data.subscription.subscription_code;
var amount = event.data.subscription.amount;
// Mark the account as past_due
updateSubscriptionStatus(subscriptionCode, 'past_due');
// Notify the customer
sendPaymentFailedEmail(customerEmail, {
amount: amount,
updateCardUrl: 'https://yoursite.com/update-card',
});
// Optionally: restrict access to premium features
restrictPremiumAccess(customerEmail);
}
if (event.event === 'invoice.update') {
// A previously failed invoice was paid (retry succeeded)
if (event.data.paid) {
var subCode = event.data.subscription.subscription_code;
updateSubscriptionStatus(subCode, 'active');
restoreFullAccess(event.data.customer.email);
}
}
if (event.event === 'subscription.disable') {
// All retries exhausted. Subscription is cancelled.
var email = event.data.customer.email;
updateSubscriptionStatus(event.data.subscription_code, 'cancelled');
revokeAccess(email);
sendSubscriptionCancelledEmail(email);
}
res.sendStatus(200);
});
Do not wait for subscription.disable to take action. By the time Paystack gives up retrying, the customer has had days of service without paying. Start your dunning flow on invoice.payment_failed.
Building a Retry Strategy for Manual Charges
When you use charge_authorization directly, Paystack does not retry for you. You need a retry queue.
var RETRY_SCHEDULE = [
{ delay: 1 * 60 * 60 * 1000, label: '1 hour' },
{ delay: 24 * 60 * 60 * 1000, label: '1 day' },
{ delay: 3 * 24 * 60 * 60 * 1000, label: '3 days' },
{ delay: 7 * 24 * 60 * 60 * 1000, label: '7 days' },
];
async function handleChargeFailure(userId, reference, gatewayResponse, retryCount) {
var category = categorizeFailure(gatewayResponse);
if (category === 'permanent') {
// No point retrying. Request card update.
await updateBillingStatus(userId, 'card_invalid');
await sendCardUpdateRequest(userId, gatewayResponse);
return;
}
if (retryCount >= RETRY_SCHEDULE.length) {
// Max retries reached. Suspend the account.
await updateBillingStatus(userId, 'suspended');
await sendSuspensionNotice(userId);
return;
}
// Schedule the next retry
var nextRetry = RETRY_SCHEDULE[retryCount];
var retryAt = new Date(Date.now() + nextRetry.delay);
await db.query(
'INSERT INTO charge_retries (user_id, original_reference, retry_count, scheduled_at, status) VALUES ($1, $2, $3, $4, $5)',
[userId, reference, retryCount + 1, retryAt, 'scheduled']
);
console.log('Retry ' + (retryCount + 1) + ' scheduled for ' + userId + ' at ' + retryAt.toISOString());
}
// Cron job or task queue worker processes retries
async function processScheduledRetries() {
var due = await db.query(
'SELECT * FROM charge_retries WHERE scheduled_at <= NOW() AND status = $1',
['scheduled']
);
for (var i = 0; i < due.rows.length; i++) {
var retry = due.rows[i];
var user = await getUser(retry.user_id);
var paymentMethod = await getDefaultPaymentMethod(retry.user_id);
var ref = 'RETRY_' + retry.original_reference + '_' + retry.retry_count;
var result = await chargeCard(user.email, user.amount_due, paymentMethod.authorization_code, ref);
if (result.status === 'success') {
await markRetrySucceeded(retry.id);
await updateBillingStatus(retry.user_id, 'active');
await sendPaymentSuccessEmail(user.email);
} else {
await markRetryFailed(retry.id, result.gateway_response);
await handleChargeFailure(retry.user_id, retry.original_reference, result.gateway_response, retry.retry_count);
}
}
}
Key principles for retry timing:
- Wait at least 1 hour before the first retry. The bank might be experiencing a temporary outage.
- Space retries further apart with each attempt. 1 hour, 1 day, 3 days, 7 days is a reasonable schedule.
- Stop after 4-5 retries. Banks track failed charge attempts. Too many look like fraud.
- For "Insufficient Funds," consider timing retries around salary dates (beginning and middle of the month in many African markets).
The Dunning State Machine
Your billing status should flow through defined states. Each state determines what the customer can access and what communications they receive.
var BILLING_STATES = {
active: {
description: 'Payment is current',
access: 'full',
transitions: ['past_due'],
},
past_due: {
description: 'Payment failed, retrying',
access: 'degraded',
transitions: ['active', 'suspended'],
},
suspended: {
description: 'All retries exhausted, no payment',
access: 'none',
transitions: ['active', 'cancelled'],
},
cancelled: {
description: 'Customer or system cancelled',
access: 'none',
transitions: ['active'],
},
};
async function transitionBillingState(userId, newState) {
var current = await getCurrentBillingState(userId);
if (BILLING_STATES[current].transitions.indexOf(newState) === -1) {
console.log('Invalid transition from ' + current + ' to ' + newState);
return false;
}
await db.query(
'UPDATE user_billing SET status = $1, status_changed_at = NOW() WHERE user_id = $2',
[newState, userId]
);
// Log the transition for analytics
await db.query(
'INSERT INTO billing_state_log (user_id, from_state, to_state, changed_at) VALUES ($1, $2, $3, NOW())',
[userId, current, newState]
);
return true;
}
What "degraded access" means is your business decision. Options:
- Show a banner: "Your payment failed. Please update your card."
- Disable premium features but keep basic access.
- Set the account to read-only.
- Allow full access during a grace period (3-7 days).
For the email and SMS communications that accompany each state, see building a dunning email sequence.
Implementing Grace Periods
A grace period is the window between a failed charge and access revocation. It gives the customer time to fix the payment issue without losing service. Most SaaS products offer 3-7 days.
var GRACE_PERIOD_DAYS = 7;
async function checkGracePeriod(userId) {
var billing = await db.query(
'SELECT status, status_changed_at FROM user_billing WHERE user_id = $1',
[userId]
);
if (billing.rows.length === 0) return 'no_billing';
var row = billing.rows[0];
if (row.status !== 'past_due') return row.status;
var daysSinceFailure = Math.floor(
(Date.now() - new Date(row.status_changed_at).getTime()) / (24 * 60 * 60 * 1000)
);
if (daysSinceFailure <= GRACE_PERIOD_DAYS) {
return 'grace_period';
}
// Grace period expired. Suspend.
await transitionBillingState(userId, 'suspended');
return 'suspended';
}
// Use in middleware
async function billingMiddleware(req, res, next) {
var userId = req.user.id;
var status = await checkGracePeriod(userId);
if (status === 'active' || status === 'grace_period') {
if (status === 'grace_period') {
res.set('X-Payment-Status', 'past_due');
}
next();
} else if (status === 'suspended' || status === 'cancelled') {
res.status(402).json({
error: 'Payment required',
message: 'Your subscription is inactive. Please update your payment method.',
updateUrl: '/billing/update-card',
});
} else {
next();
}
}
During the grace period, the customer has full access but sees reminders to update their payment. After the grace period, access is revoked. This is the standard pattern used by most subscription businesses.
Recovering from Failures: The Card Update Flow
When retries are exhausted or the failure is permanent (card expired), the customer needs to add a new card. Build a simple flow for this.
// Generate a payment link for card update
async function createCardUpdateLink(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',
metadata: {
purpose: 'card_update',
user_id: userId,
},
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
return response.data.data.authorization_url;
}
// Handle the callback after card update
async function handleCardUpdate(reference) {
var verification = await verifyTransaction(reference);
if (verification.status === 'success' && verification.authorization.reusable) {
var userId = verification.metadata.user_id;
// Save the new authorization
await saveNewAuthorization(userId, verification.authorization);
// Charge the overdue amount with the new card
var overdue = await getOverdueAmount(userId);
if (overdue > 0) {
var chargeResult = await chargeCard(
verification.customer.email,
overdue,
verification.authorization.authorization_code,
'RECOVERY_' + userId + '_' + Date.now()
);
if (chargeResult.status === 'success') {
await transitionBillingState(userId, 'active');
await clearDunningQueue(userId);
}
} else {
await transitionBillingState(userId, 'active');
}
}
}
Place the card update link prominently: in dunning emails, in the app banner, and on the billing settings page. Make it as easy as possible for the customer to fix the problem.
Key Takeaways
- ✓Failed charges are normal in recurring billing, especially in African markets where prepaid and debit cards are common. Build your system to expect and handle them.
- ✓The gateway_response field tells you why a charge failed. Map each response to a specific action: retry, request card update, or investigate.
- ✓Subscription API failures trigger Paystack-managed retries. Manual charge_authorization failures are entirely your responsibility to retry.
- ✓Limit retries to three to five attempts over seven to ten days. Excessive retries look suspicious to banks and can get your account flagged.
- ✓Build a dunning state machine with states like active, past_due, and suspended. Transition between states based on charge results and time elapsed.
- ✓Notify customers at each stage of the dunning process. The first notification should be helpful, not threatening.
Frequently Asked Questions
- How many times should I retry a failed Paystack charge?
- Three to five times over seven to ten days is the industry standard. Space retries at increasing intervals: 1 hour, 1 day, 3 days, 7 days. Excessive retries can get flagged by banks as suspicious activity. For permanent failures like card expired, do not retry at all.
- Does Paystack automatically retry failed subscription charges?
- Yes. When a subscription renewal fails, Paystack retries the charge automatically over the following days. The exact retry schedule is managed by Paystack. You receive invoice.payment_failed webhooks for each failed attempt and subscription.disable if all retries fail.
- What is the most common reason for recurring charge failures in Nigeria?
- Insufficient Funds is the most frequent decline reason, because prepaid and debit cards are dominant. The customer's account balance is too low for the charge. This is usually temporary. Timing retries around common salary payment dates (month-end or mid-month) can improve recovery rates.
- Should I cut off access immediately when a charge fails?
- No. Best practice is to implement a grace period of 3-7 days. During this time, the customer keeps access but sees a notification to update their payment. This gives them time to add funds or update their card without losing service. Immediate cutoff increases churn.
- How do I know if a failed charge is temporary or permanent?
- Read the gateway_response field. Insufficient Funds and System Error are typically temporary. Card Expired, No Card Record, and Suspected Fraud are permanent. Do Not Honor is ambiguous. For permanent failures, ask the customer to add a new card instead of retrying.
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