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.
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:
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.
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';
}
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
When a subscription renewal fails, Paystack manages the retry process:
invoice.payment_failed webhook.subscription.disable webhook, 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.
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:
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:
For the email and SMS communications that accompany each state, see building a dunning email sequence.
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.
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.
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