McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Paystack Invoice Events for Subscription Lifecycles
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

Paystack Invoice Events for Subscription Lifecycles

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.

In this article

  1. 1. The Subscription Event Timeline
  2. 2. subscription.create
  3. 3. invoice.create
  4. 4. invoice.update
  5. 5. invoice.payment_failed
  6. 6. subscription.not_renew and subscription.disable
  7. 7. The Complete Webhook Handler
  8. 8. Testing Your Webhook Handlers

The Subscription Event Timeline

A subscription lifecycle follows a predictable sequence of webhook events. Here is the timeline for a healthy subscription:

  1. subscription.create: Customer subscribes. Subscription is active.
  2. invoice.create: Paystack generates an invoice before the next billing date.
  3. charge.success: The charge is processed successfully.
  4. invoice.update: The invoice is marked as paid.
  5. Steps 2-4 repeat every billing cycle.

For a failed charge cycle:

  1. invoice.create: Paystack generates the invoice.
  2. invoice.payment_failed: The charge attempt fails.
  3. Paystack retries. If retry succeeds: charge.success then invoice.update.
  4. If all retries fail: subscription.disable.

For a customer-initiated cancellation:

  1. subscription.not_renew: Customer cancels. Current period still active.
  2. At the end of the period: subscription.disable.

This article is part of the subscriptions and recurring billing guide.

subscription.create

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.

invoice.create

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.

invoice.update

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.

invoice.payment_failed

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 and subscription.disable

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.

The Complete Webhook Handler

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.

Testing Your Webhook Handlers

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');
}

Key Takeaways

  • ✓Six key webhook events drive the subscription lifecycle: subscription.create, invoice.create, invoice.update, invoice.payment_failed, subscription.not_renew, and subscription.disable.
  • ✓invoice.create fires when Paystack generates an invoice before attempting a charge. Use it to log upcoming charges.
  • ✓invoice.update fires when an invoice is settled (usually after successful payment). Use it to confirm renewals and extend access.
  • ✓invoice.payment_failed fires when the renewal charge fails. Start your dunning flow here, not at subscription.disable.
  • ✓subscription.not_renew fires when a customer cancels before the next billing cycle. Keep their access until the current period ends.
  • ✓Process every webhook idempotently. Deduplicate by event reference or a combination of event type and subscription code.
  • ✓Always verify webhook signatures before processing events to prevent spoofed webhooks from corrupting your data.

Frequently Asked Questions

What is the difference between invoice.update and charge.success for subscription payments?
Both fire when a subscription charge succeeds, but they serve different purposes. invoice.update is specific to the subscription system and includes period information (period_start, period_end). charge.success fires for all successful charges on your account, not just subscriptions. Use invoice.update for subscription-specific logic and charge.success for general payment processing.
Can Paystack send the same webhook event multiple times?
Yes. Network issues, timeouts, or retries can cause duplicate webhook deliveries. Always process events idempotently by tracking which events you have already handled. Use a combination of event type and reference or subscription code as a deduplication key.
Should I return 200 even if my webhook handler fails?
Yes. Return 200 to acknowledge receipt. If you return a non-200 status, Paystack retries the webhook, creating more duplicate events. Log the error internally and investigate. Your reconciliation process should catch any missed events.
What happens if my webhook endpoint is down when Paystack sends an event?
Paystack retries webhook delivery with exponential backoff. Your endpoint will receive the event when it comes back online. However, if your endpoint is down for an extended period, you may miss events. Run a daily reconciliation process that checks Paystack API for subscription and transaction statuses to catch anything missed.
How do I verify that a Paystack webhook is authentic?
Paystack signs webhook payloads with your secret key using HMAC SHA-512. The signature is in the x-paystack-signature header. Compute the HMAC of the raw request body using your secret key and compare it to the header value. If they match, the webhook is authentic.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Subscriptions and Recurring Billing: Complete Guide

Everything you need to build recurring billing on Paystack. Covers the subscriptions API, plans, authorization codes for manual charges, dunning, lifecycle webhooks, cancellation, and real-world patterns for SaaS, memberships, school fees, and loan collection across Africa.

Read more

Handling Failed Recurring Charges and Dunning on Paystack

Recurring charges fail. Cards expire, accounts run dry, banks decline for opaque reasons. This guide covers every failure scenario in Paystack recurring billing, shows you how to read gateway responses, build a retry strategy, implement a dunning state machine, and handle the higher failure rates typical in African card markets.

Read more

Paystack Plans and Subscriptions: The Full Data Model

A complete walkthrough of every object in the Paystack recurring billing system. Plans, subscriptions, authorizations, invoices, and customers: how they connect, what each field means, and the edge cases that trip up developers building billing for African markets.

Read more

Cancelling and Pausing Paystack Subscriptions

Paystack lets you cancel subscriptions but has no native pause feature. This guide covers the disable API for cancellation, the difference between immediate and end-of-period cancellation, how to implement pause and resume in your application, and the reactivation flow for customers who come back.

Read more

Paystack Subscription Emails: Disabling and Replacing Them

Paystack sends default email notifications for subscription charges, renewals, and failures. If you send your own branded emails, your customers get duplicates. This guide shows you how to disable Paystack's emails via plan settings, build your own notification system on webhooks, and ensure customers get exactly one clear email per billing event.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.