Cancelling and Pausing Paystack Subscriptions
Cancel a Paystack subscription by calling POST /subscription/disable with the subscription code and email token. Paystack stops all future charges immediately. There is no native pause feature. To pause, cancel the subscription and store the customer's authorization code. When they want to resume, create a new subscription using the stored authorization. Track cancellation type (immediate vs end-of-period) in your application to handle access correctly.
Cancelling a Subscription
Paystack provides one mechanism for stopping a subscription: the disable endpoint. It takes two parameters: the subscription code and the email token.
var axios = require('axios');
async function cancelSubscription(subscriptionCode, emailToken) {
var response = await axios.post(
'https://api.paystack.co/subscription/disable',
{
code: subscriptionCode,
token: emailToken,
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
return response.data;
}
When you disable a subscription:
- Paystack stops all future automatic charges.
- Paystack sends a
subscription.disablewebhook event. - The subscription status changes to "cancelled" or "non-renewing" depending on timing.
- The authorization code is not affected. You can still charge the customer manually or create a new subscription later.
You can also re-enable a disabled subscription:
async function reEnableSubscription(subscriptionCode, emailToken) {
var response = await axios.post(
'https://api.paystack.co/subscription/enable',
{
code: subscriptionCode,
token: emailToken,
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
return response.data;
}
This article is part of the subscriptions and recurring billing guide.
Immediate vs End-of-Period Cancellation
Paystack's disable endpoint is immediate: it stops future charges right now. But the business question is different: should the customer lose access immediately or keep it until the end of their paid period?
Most subscription businesses use end-of-period cancellation. The customer paid for a month (or year). They should get what they paid for, even if they cancel on day 2.
Paystack does not manage this for you. You implement it in your application:
async function requestCancellation(userId) {
var subscription = await getActiveSubscription(userId);
if (!subscription) {
throw new Error('No active subscription to cancel');
}
// Disable on Paystack (stops future charges)
await cancelSubscription(
subscription.paystack_subscription_code,
subscription.paystack_email_token
);
// In your database: mark as cancelling but keep access until period end
await db.query(
'UPDATE billing_accounts SET status = $1, cancelled_at = NOW(), access_until = $2, cancellation_reason = $3, updated_at = NOW() WHERE user_id = $4',
['cancelling', subscription.current_period_end, 'customer_request', userId]
);
return {
status: 'cancelling',
accessUntil: subscription.current_period_end,
message: 'Your subscription will remain active until ' + formatDate(subscription.current_period_end),
};
}
// Middleware checks access_until, not just status
async function subscriptionMiddleware(req, res, next) {
var userId = req.user.id;
var billing = await db.query(
'SELECT status, access_until FROM billing_accounts WHERE user_id = $1',
[userId]
);
if (billing.rows.length === 0) {
req.subscriptionActive = false;
return next();
}
var row = billing.rows[0];
if (row.status === 'active') {
req.subscriptionActive = true;
} else if (row.status === 'cancelling' && new Date(row.access_until) > new Date()) {
req.subscriptionActive = true;
req.cancellingAt = row.access_until;
} else {
req.subscriptionActive = false;
}
next();
}
Run a daily job to find subscriptions where access_until has passed and transition them from "cancelling" to "cancelled":
async function processExpiredCancellations() {
await db.query(
'UPDATE billing_accounts SET status = $1, updated_at = NOW() WHERE status = $2 AND access_until < NOW()',
['cancelled', 'cancelling']
);
}
Implementing Pause (Without a Native Feature)
Paystack has no pause button. Pausing means: stop charging temporarily, let the customer resume later without re-entering card details.
Here is the pattern:
async function pauseSubscription(userId, pauseReason) {
var subscription = await getActiveSubscription(userId);
// Cancel on Paystack
await cancelSubscription(
subscription.paystack_subscription_code,
subscription.paystack_email_token
);
// Store the pause state in your database
// Keep the authorization code for later resumption
await db.query(
'UPDATE billing_accounts SET status = $1, paused_at = NOW(), pause_reason = $2, updated_at = NOW() WHERE user_id = $3',
['paused', pauseReason, userId]
);
return {
status: 'paused',
message: 'Subscription paused. You can resume anytime.',
};
}
async function resumeSubscription(userId) {
var billing = await db.query(
'SELECT plan_id, payment_method_id FROM billing_accounts WHERE user_id = $1 AND status = $2',
[userId, 'paused']
);
if (billing.rows.length === 0) {
throw new Error('No paused subscription found');
}
var account = billing.rows[0];
var paymentMethod = await getPaymentMethod(account.payment_method_id);
var plan = await getPlan(account.plan_id);
var user = await getUser(userId);
// Check if the card is still valid
var now = new Date();
if (paymentMethod.exp_year < now.getFullYear() ||
(paymentMethod.exp_year === now.getFullYear() && paymentMethod.exp_month < now.getMonth() + 1)) {
return {
success: false,
reason: 'card_expired',
message: 'Your card has expired. Please add a new card to resume.',
};
}
// Create new subscription on Paystack
var authCode = await getAuthorizationCode(account.payment_method_id);
var response = await axios.post(
'https://api.paystack.co/subscription',
{
customer: user.email,
plan: plan.paystack_plan_code,
authorization: authCode,
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
var newSub = response.data.data;
await db.query(
'UPDATE billing_accounts SET status = $1, paused_at = NULL, paystack_subscription_code = $2, paystack_email_token = $3, updated_at = NOW() WHERE user_id = $4',
['active', newSub.subscription_code, newSub.email_token, userId]
);
return {
success: true,
message: 'Subscription resumed.',
nextCharge: newSub.next_payment_date,
};
}
Downsides of the cancel-and-resume approach:
- The billing date resets when you create a new subscription.
- Subscription history (invoices_count, creation date) resets.
- The card may have expired during the pause.
Setting Pause Limits
Allow unlimited pauses and some customers will stay paused forever. Set limits.
var PAUSE_POLICY = {
maxPausesPerYear: 3,
maxPauseDurationDays: 30,
minimumActiveBeforePause: 30, // Must be active for at least 30 days before pausing
};
async function canPause(userId) {
// Check pause count this year
var yearStart = new Date(new Date().getFullYear(), 0, 1);
var pauseCount = await db.query(
'SELECT COUNT(*) as count FROM pause_history WHERE user_id = $1 AND paused_at >= $2',
[userId, yearStart]
);
if (parseInt(pauseCount.rows[0].count) >= PAUSE_POLICY.maxPausesPerYear) {
return { allowed: false, reason: 'Maximum ' + PAUSE_POLICY.maxPausesPerYear + ' pauses per year reached.' };
}
// Check minimum active time
var billing = await db.query(
'SELECT status, updated_at FROM billing_accounts WHERE user_id = $1',
[userId]
);
if (billing.rows.length > 0) {
var daysSinceLastChange = Math.floor(
(Date.now() - new Date(billing.rows[0].updated_at).getTime()) / (24 * 60 * 60 * 1000)
);
if (daysSinceLastChange < PAUSE_POLICY.minimumActiveBeforePause) {
return { allowed: false, reason: 'Subscription must be active for at least ' + PAUSE_POLICY.minimumActiveBeforePause + ' days before pausing.' };
}
}
return { allowed: true };
}
Auto-resume after the maximum pause duration to prevent indefinite pauses:
// Run daily
async function autoResumePausedSubscriptions() {
var maxPauseDate = new Date(Date.now() - PAUSE_POLICY.maxPauseDurationDays * 24 * 60 * 60 * 1000);
var overduePauses = await db.query(
'SELECT user_id FROM billing_accounts WHERE status = $1 AND paused_at < $2',
['paused', maxPauseDate]
);
for (var i = 0; i < overduePauses.rows.length; i++) {
try {
await resumeSubscription(overduePauses.rows[i].user_id);
} catch (error) {
console.log('Auto-resume failed for user ' + overduePauses.rows[i].user_id + ': ' + error.message);
}
}
}
The Reactivation Flow
A cancelled customer comes back. They want to resubscribe. The flow depends on whether their old authorization is still valid.
async function reactivate(userId, planCode) {
var user = await getUser(userId);
var paymentMethod = await getDefaultPaymentMethod(userId);
if (!paymentMethod || !paymentMethod.is_reusable) {
// No valid card on file. Send them through checkout.
var checkoutUrl = await initializeSubscriptionCheckout(user.email, planCode);
return {
needsCard: true,
checkoutUrl: checkoutUrl,
};
}
// Check card expiry
var now = new Date();
var expired = paymentMethod.exp_year < now.getFullYear() ||
(paymentMethod.exp_year === now.getFullYear() && paymentMethod.exp_month < now.getMonth() + 1);
if (expired) {
var checkoutUrl = await initializeSubscriptionCheckout(user.email, planCode);
return {
needsCard: true,
reason: 'Your saved card has expired.',
checkoutUrl: checkoutUrl,
};
}
// Card is valid. Create subscription directly.
var authCode = await getAuthorizationCode(paymentMethod.id);
var subscription = await createPaystackSubscription(user.email, planCode, authCode);
await db.query(
'UPDATE billing_accounts SET status = $1, plan_id = (SELECT id FROM billing_plans WHERE paystack_plan_code = $2), paystack_subscription_code = $3, paystack_email_token = $4, cancelled_at = NULL, updated_at = NOW() WHERE user_id = $5',
['active', planCode, subscription.subscription_code, subscription.email_token, userId]
);
return {
needsCard: false,
status: 'active',
nextCharge: subscription.next_payment_date,
};
}
For customers who cancelled voluntarily, consider offering a win-back discount on reactivation. See coupons and discounts.
Tracking Cancellation Reasons
When a customer cancels, ask why. This data drives product improvements and retention strategies.
var CANCELLATION_REASONS = [
{ id: 'too_expensive', label: 'Too expensive' },
{ id: 'not_using', label: 'Not using it enough' },
{ id: 'missing_features', label: 'Missing features I need' },
{ id: 'found_alternative', label: 'Found a better alternative' },
{ id: 'temporary', label: 'Just need a break (will come back)' },
{ id: 'other', label: 'Other' },
];
async function processCancellationWithReason(userId, reasonId, feedback) {
await db.query(
'INSERT INTO cancellation_records (user_id, reason, feedback, created_at) VALUES ($1, $2, $3, NOW())',
[userId, reasonId, feedback]
);
// Offer retention based on reason
if (reasonId === 'too_expensive') {
// Offer a discount or suggest a cheaper plan
return {
retention_offer: 'downgrade',
message: 'Would a lower plan work better for your budget?',
};
}
if (reasonId === 'temporary') {
return {
retention_offer: 'pause',
message: 'You can pause your subscription instead of cancelling.',
};
}
// Proceed with cancellation
return await requestCancellation(userId);
}
Track "too expensive" and "temporary" separately. For "too expensive," you might retain the customer with a downgrade or discount. For "temporary," offer pause instead of full cancellation. These are low-cost retention strategies that reduce churn significantly. For churn analytics, see subscription churn instrumentation.
Key Takeaways
- ✓Paystack cancels subscriptions via the disable endpoint. It requires the subscription_code and email_token.
- ✓Disabling a subscription stops all future charges immediately. Paystack does not pro-rate or refund the current period.
- ✓End-of-period cancellation is a business decision you implement. Disable the subscription but let the customer keep access until current_period_end.
- ✓Paystack has no native pause feature. Implement pause by cancelling the subscription and storing the authorization for later reactivation.
- ✓Reactivation creates a new subscription using the stored authorization code. The billing date resets to the reactivation date.
- ✓Track voluntary vs involuntary cancellations separately. They need different retention strategies.
Frequently Asked Questions
- Does Paystack support pausing a subscription?
- No. Paystack has no native pause feature. You implement pause by disabling the subscription (stopping charges), storing the authorization code, and creating a new subscription when the customer wants to resume.
- What happens to the billing date when I cancel and resume a subscription?
- The billing date resets to the date you create the new subscription. If the original subscription was billed on the 1st and you resume on the 15th, the new billing date is the 15th.
- Can the customer cancel their own subscription without going through my app?
- Yes, if they have the email_token. Paystack sends subscription management emails that include a link for the customer to cancel directly through Paystack. You can disable these emails if you want all cancellations to go through your app. See the subscription emails article for details.
- Should I let customers cancel immediately or only at end of period?
- End-of-period cancellation is the industry standard. The customer keeps access until the end of their paid period. This is fairer (they paid for the full period) and reduces disputes. Store the cancellation date and access_until date separately in your database.
- How do I re-enable a disabled Paystack subscription?
- Call POST /subscription/enable with the subscription code and email token. This reactivates the subscription and resumes automatic charges. Note that this only works if the subscription was recently disabled. For long-paused subscriptions, creating a new subscription is more reliable.
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