Bonaventure OgetoBy Bonaventure Ogeto|

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.

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.

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.

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