Paystack sends these subscription lifecycle events via webhooks: subscription.create (new subscription), invoice.create (upcoming charge), invoice.update (charge settled), invoice.payment_failed (charge failed), subscription.not_renew (customer cancelled before next cycle), and subscription.disable (subscription ended). Your application should listen for each event and update its internal state accordingly. Process events idempotently because Paystack can send the same event multiple times.
A subscription lifecycle follows a predictable sequence of webhook events. Here is the timeline for a healthy subscription:
For a failed charge cycle:
For a customer-initiated cancellation:
This article is part of the subscriptions and recurring billing guide.
Fires when a new subscription is created. This happens after the customer's first payment (if subscribing via checkout) or immediately (if subscribing via the API with an existing authorization).
function handleSubscriptionCreate(data) {
var subscriptionCode = data.subscription_code;
var customerEmail = data.customer.email;
var planCode = data.plan.plan_code;
var planName = data.plan.name;
var authorization = data.authorization;
console.log('New subscription: ' + subscriptionCode);
console.log('Customer: ' + customerEmail);
console.log('Plan: ' + planName);
// Update your database
db.query(
'INSERT INTO billing_accounts (user_id, paystack_subscription_code, paystack_email_token, plan_code, status, created_at) VALUES ((SELECT id FROM users WHERE email = $1), $2, $3, $4, $5, NOW()) ON CONFLICT (user_id) DO UPDATE SET paystack_subscription_code = $2, paystack_email_token = $3, plan_code = $4, status = $5, updated_at = NOW()',
[customerEmail, subscriptionCode, data.email_token, planCode, 'active']
);
// Grant access to premium features
grantPremiumAccess(customerEmail);
// Send welcome email (if you disabled Paystack emails)
sendWelcomeEmail(customerEmail, planName);
}
Key fields in the payload: subscription_code, email_token, plan.plan_code, customer.email, authorization, next_payment_date.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Fires when Paystack generates an invoice for an upcoming subscription charge. This happens before the charge attempt, giving you a heads-up that billing is about to happen.
function handleInvoiceCreate(data) {
var subscriptionCode = data.subscription.subscription_code;
var amount = data.amount;
var periodStart = data.period_start;
var periodEnd = data.period_end;
console.log('Invoice created for subscription: ' + subscriptionCode);
console.log('Amount: ' + amount);
console.log('Period: ' + periodStart + ' to ' + periodEnd);
// Log the upcoming charge in your database
db.query(
'INSERT INTO subscription_invoices (subscription_code, amount, period_start, period_end, status, created_at) VALUES ($1, $2, $3, $4, $5, NOW())',
[subscriptionCode, amount, periodStart, periodEnd, 'pending']
);
// Optional: send a "your bill is coming" email
// Most products skip this to avoid email fatigue
}
This event is optional to handle. Many products ignore it. But if you want to give customers advance notice of upcoming charges (useful for large amounts like annual plans), this is the trigger.
Fires when an invoice is updated, most commonly after a successful payment. This is your primary signal that a renewal charge went through.
function handleInvoiceUpdate(data) {
if (!data.paid) {
// Invoice updated but not paid yet. Could be a partial update.
console.log('Invoice updated but not paid');
return;
}
var subscriptionCode = data.subscription.subscription_code;
var amount = data.amount;
var paidAt = data.paid_at;
var transactionRef = data.transaction.reference;
console.log('Renewal paid for subscription: ' + subscriptionCode);
// Update your database
db.query(
'UPDATE subscription_invoices SET status = $1, paid_at = $2, transaction_reference = $3 WHERE subscription_code = $4 AND status = $5',
['paid', paidAt, transactionRef, subscriptionCode, 'pending']
);
// Extend access
db.query(
'UPDATE billing_accounts SET status = $1, current_period_end = $2, updated_at = NOW() WHERE paystack_subscription_code = $3',
['active', data.period_end, subscriptionCode]
);
// Clear any dunning state (if the customer was past_due and retry succeeded)
clearDunningState(subscriptionCode);
// Send receipt email
sendRenewalReceiptEmail(data.customer.email, amount, transactionRef);
}
Always check the paid field. The invoice.update event can fire for reasons other than successful payment. Only extend access and send receipts when paid is true.
Fires when a subscription renewal charge fails. This is where your dunning process begins.
function handleInvoicePaymentFailed(data) {
var subscriptionCode = data.subscription.subscription_code;
var customerEmail = data.customer.email;
var amount = data.amount;
console.log('Payment failed for subscription: ' + subscriptionCode);
// Update invoice status
db.query(
'UPDATE subscription_invoices SET status = $1 WHERE subscription_code = $2 AND status = $3',
['failed', subscriptionCode, 'pending']
);
// Transition billing state to past_due
db.query(
'UPDATE billing_accounts SET status = $1, updated_at = NOW() WHERE paystack_subscription_code = $2',
['past_due', subscriptionCode]
);
// Start dunning sequence
startDunningSequence(customerEmail, subscriptionCode, amount);
}
Do not wait for subscription.disable to take action. By the time Paystack exhausts retries and disables the subscription, the customer may have had days or weeks of unpaid access. Start your dunning flow on the first invoice.payment_failed.
Paystack may send multiple invoice.payment_failed events if it retries and fails again. Deduplicate by checking if you have already started a dunning sequence for this subscription and billing period.
subscription.not_renew
Fires when a customer cancels their subscription before the next billing cycle. The subscription is still active for the current period. The customer has paid through current_period_end and should keep access until then.
function handleSubscriptionNotRenew(data) {
var subscriptionCode = data.subscription_code;
var customerEmail = data.customer.email;
console.log('Subscription will not renew: ' + subscriptionCode);
// Mark as cancelling, but keep access
db.query(
'UPDATE billing_accounts SET status = $1, cancelled_at = NOW(), updated_at = NOW() WHERE paystack_subscription_code = $2',
['cancelling', subscriptionCode]
);
// Send cancellation confirmation
sendCancellationConfirmEmail(customerEmail);
}
subscription.disable
Fires when a subscription is fully cancelled or disabled. No more charges will happen. This can be triggered by customer cancellation (after the period ends), admin action, or exhausted payment retries.
function handleSubscriptionDisable(data) {
var subscriptionCode = data.subscription_code;
var customerEmail = data.customer.email;
console.log('Subscription disabled: ' + subscriptionCode);
// Revoke access
db.query(
'UPDATE billing_accounts SET status = $1, updated_at = NOW() WHERE paystack_subscription_code = $2',
['cancelled', subscriptionCode]
);
revokePremiumAccess(customerEmail);
sendSubscriptionEndedEmail(customerEmail);
}
A common mistake: treating subscription.not_renew and subscription.disable the same way. They are different. not_renew means "will end later." disable means "ended now." If you revoke access on not_renew, you are taking away time the customer already paid for.
var crypto = require('crypto');
app.post('/webhooks/paystack', function(req, res) {
// Step 1: Verify webhook signature
var hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
console.log('Invalid webhook signature');
return res.sendStatus(401);
}
var event = req.body;
// Step 2: Deduplicate
var dedupKey = event.event + '_' + (event.data.reference || event.data.subscription_code || event.data.id);
if (isAlreadyProcessed(dedupKey)) {
return res.sendStatus(200);
}
markAsProcessed(dedupKey);
// Step 3: Route to handler
try {
switch (event.event) {
case 'subscription.create':
handleSubscriptionCreate(event.data);
break;
case 'invoice.create':
handleInvoiceCreate(event.data);
break;
case 'invoice.update':
handleInvoiceUpdate(event.data);
break;
case 'invoice.payment_failed':
handleInvoicePaymentFailed(event.data);
break;
case 'subscription.not_renew':
handleSubscriptionNotRenew(event.data);
break;
case 'subscription.disable':
handleSubscriptionDisable(event.data);
break;
case 'charge.success':
handleChargeSuccess(event.data);
break;
default:
console.log('Unhandled event type: ' + event.event);
}
} catch (error) {
console.log('Error processing webhook: ' + error.message);
// Still return 200 to prevent Paystack from retrying
// Log the error for investigation
}
res.sendStatus(200);
});
Always return 200 even if your handler throws an error. If you return a non-200 status, Paystack retries the webhook, and you get the same event again. Log errors for investigation but acknowledge receipt.
Test each event type before going live. Use Paystack's test environment to create subscriptions, let them renew, fail charges, and cancel.
// Unit test helpers: simulate webhook events
function simulateSubscriptionCreate() {
return {
event: 'subscription.create',
data: {
subscription_code: 'SUB_test123',
email_token: 'token_test123',
customer: { email: 'test@example.com', first_name: 'Test' },
plan: { plan_code: 'PLN_test123', name: 'Pro Monthly' },
authorization: { authorization_code: 'AUTH_test123', last4: '4081', reusable: true },
next_payment_date: '2026-08-20T00:00:00.000Z',
},
};
}
function simulateInvoicePaymentFailed() {
return {
event: 'invoice.payment_failed',
data: {
subscription: { subscription_code: 'SUB_test123', amount: 500000 },
customer: { email: 'test@example.com', first_name: 'Test' },
amount: 500000,
transaction: { reference: 'TXN_test_failed', status: 'failed' },
},
};
}
// Run these through your handler and verify database state changes
async function testWebhookHandlers() {
// Test subscription creation
var createEvent = simulateSubscriptionCreate();
handleSubscriptionCreate(createEvent.data);
var account = await db.query('SELECT status FROM billing_accounts WHERE paystack_subscription_code = $1', ['SUB_test123']);
console.log('Create test:', account.rows[0].status === 'active' ? 'PASS' : 'FAIL');
// Test payment failure
var failEvent = simulateInvoicePaymentFailed();
handleInvoicePaymentFailed(failEvent.data);
account = await db.query('SELECT status FROM billing_accounts WHERE paystack_subscription_code = $1', ['SUB_test123']);
console.log('Failure test:', account.rows[0].status === 'past_due' ? 'PASS' : 'FAIL');
}
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