Paystack fires six webhook events across the subscription lifecycle: subscription.create when a customer subscribes, subscription.not_renew when they cancel before the next cycle, subscription.disable when the subscription is fully deactivated, invoice.create when a renewal invoice is generated, invoice.payment_failed when the renewal charge fails, and invoice.update when the invoice status changes after a successful charge. Your handler should route each event to the correct business logic and always return 200 before doing any heavy work.
Paystack subscriptions follow a predictable lifecycle. A customer picks a plan, their card is charged, and a subscription is created. Every billing period, Paystack generates an invoice, charges the card, and updates the invoice. If the charge fails, Paystack tells you. If the customer cancels, Paystack tells you that too.
The events that track this lifecycle are:
If you are building a SaaS product, an online course platform, or any recurring billing system on Paystack, you need to handle all six. Miss one and you end up with customers who are paying but locked out, or customers who stopped paying but still have access.
This article is part of the Paystack webhooks engineering guide. If you have not set up signature verification yet, start there.
This event fires when a customer successfully subscribes to a plan. By the time you receive it, the first charge has already gone through. The payload includes the subscription details, the plan, and the customer.
Here is what the payload looks like:
{
"event": "subscription.create",
"data": {
"domain": "live",
"status": "active",
"subscription_code": "SUB_abc123def456",
"email_token": "tok_xyz789",
"amount": 500000,
"plan": {
"name": "Pro Plan",
"plan_code": "PLN_pro2026",
"amount": 500000,
"interval": "monthly",
"currency": "NGN"
},
"customer": {
"email": "wanjiku@example.com",
"customer_code": "CUS_wanjiku001"
},
"authorization": {
"authorization_code": "AUTH_card001",
"card_type": "visa",
"last4": "4081",
"exp_month": "12",
"exp_year": "2028"
},
"next_payment_date": "2026-08-20T00:00:00.000Z",
"created_at": "2026-07-20T10:00:00.000Z"
}
}
What you should do when you receive this event:
subscription_code. You will need it to cancel or manage the subscription later via the API.customer_code or email.next_payment_date so you can show the customer when their next charge is coming.email_token. Paystack uses this to let customers manage their subscription via a link you can embed in your app.async function handleSubscriptionCreate(data) {
const {
subscription_code,
email_token,
plan,
customer,
next_payment_date,
} = data;
// Activate the user's subscription in your database
await db.query(
'UPDATE users SET plan = $1, subscription_code = $2, ' +
'subscription_status = $3, email_token = $4, ' +
'next_payment_date = $5 WHERE email = $6',
[
plan.plan_code,
subscription_code,
'active',
email_token,
next_payment_date,
customer.email,
]
);
// Send a welcome email for the new plan
await emailQueue.add('subscription-welcome', {
to: customer.email,
planName: plan.name,
nextPaymentDate: next_payment_date,
});
}
One thing to watch: if a customer subscribes, cancels, and subscribes again, you will get a new subscription_code. Do not assume a customer will only ever have one. Upsert based on the customer, not the subscription code.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
This event means the subscription will not renew at the end of the current billing period. The customer (or your code via the API) requested cancellation. The subscription is still active right now. The customer has already paid for the current period, so they should keep access until it expires.
This is where many developers make a mistake: they cut access immediately. That is wrong. The customer paid for the current period. They should keep their access until next_payment_date from the original subscription, which is effectively the expiry date.
async function handleSubscriptionNotRenew(data) {
const { subscription_code, customer } = data;
// Mark the subscription as cancelling, but do NOT revoke access yet
await db.query(
'UPDATE users SET subscription_status = $1 ' +
'WHERE subscription_code = $2',
['cancelling', subscription_code]
);
// Schedule a job to actually downgrade on the expiry date
const user = await db.query(
'SELECT next_payment_date FROM users WHERE subscription_code = $1',
[subscription_code]
);
if (user.rows.length > 0) {
const expiryDate = user.rows[0].next_payment_date;
await jobQueue.add(
'downgrade-user',
{ subscriptionCode: subscription_code },
{ delay: new Date(expiryDate).getTime() - Date.now() }
);
}
// Notify the customer that their cancellation was received
await emailQueue.add('subscription-cancel-confirm', {
to: customer.email,
expiresAt: user.rows[0]?.next_payment_date,
});
}
The delayed job approach works well with BullMQ or any queue that supports delayed execution. When the delay fires, your worker checks whether the user re-subscribed in the meantime. If they did, skip the downgrade. If they did not, switch them to the free tier.
This event means the subscription is fully deactivated. No more charges. This fires in a few scenarios:
subscription.not_renew.By this point, the customer should no longer have premium access. If you already handled subscription.not_renew with a delayed downgrade job, this event serves as a safety net. If the delayed job failed or was missed, this event catches it.
async function handleSubscriptionDisable(data) {
const { subscription_code, customer } = data;
// Ensure the user is downgraded
const result = await db.query(
'UPDATE users SET plan = $1, subscription_code = NULL, ' +
'subscription_status = $2, email_token = NULL ' +
'WHERE subscription_code = $3 AND subscription_status != $4 ' +
'RETURNING email',
['free', 'inactive', subscription_code, 'inactive']
);
// Only notify if we actually changed something
if (result.rows.length > 0) {
await emailQueue.add('subscription-ended', {
to: customer.email,
resubscribeUrl: '/pricing',
});
}
}
Notice the WHERE subscription_status != 'inactive' guard. If you already downgraded the user through the delayed job from subscription.not_renew, there is no need to do it again or send another email. This is a simple form of idempotency. For more thorough deduplication patterns, see idempotent Paystack webhook handlers.
Paystack uses invoices to manage recurring charges. Before each billing cycle, Paystack creates an invoice, attempts to charge the customer's card, and updates the invoice with the result. Three events track this process.
invoice.create
Paystack generated an invoice for the upcoming renewal. At this point, no charge has happened yet. The invoice exists so Paystack can attempt the charge. You can use this event to notify the customer that a charge is coming, which is a nice touch for transparency and reduces chargebacks.
async function handleInvoiceCreate(data) {
const { subscription, customer, amount } = data;
const amountFormatted = (amount / 100).toLocaleString();
// Notify the customer about the upcoming charge
await emailQueue.add('upcoming-charge', {
to: customer.email,
amount: amountFormatted,
currency: data.currency || 'NGN',
subscriptionCode: subscription.subscription_code,
chargeDate: data.period_start,
});
// Log the invoice for your records
await db.query(
'INSERT INTO invoices (invoice_code, subscription_code, amount, ' +
'status, created_at) VALUES ($1, $2, $3, $4, NOW()) ' +
'ON CONFLICT (invoice_code) DO NOTHING',
[data.invoice_code, subscription.subscription_code, amount, 'pending']
);
}
invoice.update
The invoice was updated. This usually means the renewal charge succeeded. Check the paid field in the payload. If it is true, the subscription renewed successfully.
async function handleInvoiceUpdate(data) {
const { invoice_code, subscription, paid } = data;
if (paid) {
// Renewal succeeded. Extend the subscription period.
await db.query(
'UPDATE users SET next_payment_date = $1, ' +
'subscription_status = $2 WHERE subscription_code = $3',
[
data.next_payment_date || subscription.next_payment_date,
'active',
subscription.subscription_code,
]
);
await db.query(
'UPDATE invoices SET status = $1, paid_at = NOW() ' +
'WHERE invoice_code = $2',
['paid', invoice_code]
);
}
}
invoice.payment_failed
The renewal charge failed. The card was declined, expired, or had insufficient funds. This is where you need a grace period strategy. Do not lock the customer out immediately. Give them a few days to update their payment method.
async function handleInvoicePaymentFailed(data) {
const { subscription, customer } = data;
// Start a grace period (e.g., 7 days)
const gracePeriodEnd = new Date();
gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 7);
await db.query(
'UPDATE users SET subscription_status = $1, ' +
'grace_period_end = $2 WHERE subscription_code = $3',
['past_due', gracePeriodEnd, subscription.subscription_code]
);
// Urgent notification to the customer
await emailQueue.add('payment-failed', {
to: customer.email,
updatePaymentUrl: '/account/billing',
gracePeriodEnd: gracePeriodEnd.toISOString(),
});
// Alert your team
await slackQueue.add('billing-alert', {
channel: '#billing',
text: 'Subscription renewal failed for ' + customer.email +
'. Grace period ends ' + gracePeriodEnd.toDateString(),
});
}
Paystack may retry the charge automatically, depending on your settings. If a retry succeeds, you will get an invoice.update with paid: true, and your handler above will flip the status back to active. This is why storing the state as past_due (not cancelled) matters. It keeps the door open for automatic recovery.
In production, you want a single webhook handler that routes each event type to the correct function. Here is the full pattern:
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post(
'/webhooks/paystack',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verify signature
const secret = process.env.PAYSTACK_SECRET_KEY;
const signature = req.headers['x-paystack-signature'];
const hash = crypto
.createHmac('sha512', secret)
.update(req.body)
.digest('hex');
if (hash !== signature) {
return res.status(400).send('Invalid signature');
}
// 2. Return 200 immediately
res.status(200).send('OK');
// 3. Parse and route
const event = JSON.parse(req.body.toString());
try {
switch (event.event) {
case 'subscription.create':
await handleSubscriptionCreate(event.data);
break;
case 'subscription.not_renew':
await handleSubscriptionNotRenew(event.data);
break;
case 'subscription.disable':
await handleSubscriptionDisable(event.data);
break;
case 'invoice.create':
await handleInvoiceCreate(event.data);
break;
case 'invoice.update':
await handleInvoiceUpdate(event.data);
break;
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data);
break;
default:
console.log('Unhandled event: ' + event.event);
}
} catch (err) {
console.error(
'Error processing ' + event.event + ': ' + err.message
);
// Log the error but do not change the response.
// The 200 was already sent. Queue for retry if needed.
}
}
);
In a real production setup, you would not process inline after sending the 200. You would push the event to a queue (BullMQ, a database job table, or Redis) and let a background worker handle it. The inline approach shown here works for low-volume applications and makes the logic clear. For the queue-based approach, see queueing webhook work with BullMQ.
When a renewal charge fails, most SaaS products do not cut access immediately. They enter a grace period (also called dunning) where the customer keeps access but is prompted to update their payment method.
A reasonable dunning flow for the Kenyan and Nigerian market:
invoice.payment_failed arrives. Send the first email. Mark the subscription as past_due.If at any point during this window Paystack retries the charge and it succeeds, you receive invoice.update with paid: true. Cancel the dunning sequence, flip the status back to active, and carry on.
The grace period length depends on your business. Seven days is standard for most African SaaS products. Some give 14 days. Whatever you choose, communicate it clearly to the customer in every email.
For mobile money subscriptions (common in Kenya and Ghana), card decline is even more frequent because customers often have to maintain a minimum M-Pesa or mobile money balance. Longer grace periods and SMS reminders (not just email) improve retention significantly.
Subscription events are harder to test than one-off charges because they unfold over time. Here are practical approaches:
Use Paystack test mode plans with short intervals. Create a test plan with a daily interval. Subscribe using a test card. Paystack will fire subscription and invoice events every day in test mode, so you can see the full lifecycle without waiting a month.
Simulate events with curl. Generate a valid signature and POST a fake subscription event to your local server:
SECRET="sk_test_your_secret_key"
BODY='{"event":"subscription.create","data":{"subscription_code":"SUB_test123","status":"active","plan":{"plan_code":"PLN_test","name":"Test Plan","amount":100000,"interval":"monthly"},"customer":{"email":"test@example.com","customer_code":"CUS_test1"},"next_payment_date":"2026-08-20T00:00:00.000Z"}}'
SIG=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/paystack -H "Content-Type: application/json" -H "x-paystack-signature: $SIG" -d "$BODY"
Write unit tests for each handler function. Your handler functions like handleSubscriptionCreate and handleInvoicePaymentFailed are pure business logic. Mock the database and queue, pass in a test payload, and assert the correct state changes happened. You do not need a real Paystack connection for this.
For a full testing setup, see simulating Paystack webhook events for automated tests.
These are the problems that come up repeatedly when developers handle Paystack subscription webhooks:
Cutting access on subscription.not_renew. The customer cancelled, but they paid for the current period. They keep access until next_payment_date. Only downgrade on subscription.disable or when the current period actually expires.
Ignoring invoice.payment_failed. If you only listen for subscription.disable, you will not know about failed renewals until Paystack gives up entirely. By then the customer may have already left. The invoice.payment_failed event gives you a chance to reach out, fix the payment method, and save the subscription.
Not storing subscription_code. You need this to manage the subscription via the Paystack API (cancelling, updating, or fetching status). Without it, you cannot programmatically interact with the subscription.
Double-processing renewal events. Paystack can retry webhook deliveries. If you credit a month of access every time you receive invoice.update with paid: true, a retry will give the customer two months for one payment. Deduplicate by invoice_code. See idempotent webhook handlers.
Treating amounts as Naira instead of kobo. The amount field is always in the smallest currency unit. 500000 kobo is 5,000 Naira. 500000 pesewas is 5,000 GHS. If you store or display the amount without dividing by 100, everything looks 100x more expensive.
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