Bonaventure OgetoBy Bonaventure Ogeto|

Handling subscription.create, subscription.disable and invoice Events

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.

The Subscription Lifecycle in Paystack

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:

  • subscription.create - Fired once when a new subscription is created. The first charge has already succeeded at this point.
  • subscription.not_renew - The customer (or you) cancelled the subscription. The current period is still active, but the next renewal will not happen.
  • subscription.disable - The subscription is fully deactivated. No more charges will occur.
  • invoice.create - Paystack generated a new invoice for an upcoming renewal.
  • invoice.payment_failed - The renewal charge failed. The card was declined, expired, or had insufficient funds.
  • invoice.update - The invoice was updated, usually because the renewal charge succeeded.

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.

subscription.create: Activating the Account

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:

  1. Store the subscription_code. You will need it to cancel or manage the subscription later via the API.
  2. Activate the customer's account or upgrade their plan tier. Link the subscription to the customer using their customer_code or email.
  3. Store the next_payment_date so you can show the customer when their next charge is coming.
  4. Store the 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.

subscription.not_renew: The Cancellation Warning

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.

subscription.disable: Final Deactivation

This event means the subscription is fully deactivated. No more charges. This fires in a few scenarios:

  • You explicitly disabled the subscription via the Paystack API.
  • The subscription reached its end after a subscription.not_renew.
  • Paystack deactivated it after repeated payment failures.

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.

Invoice Events: Tracking Renewal Charges

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.

Putting It Together: The Subscription Event Router

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.

Grace Periods and Dunning

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:

  1. Day 0: invoice.payment_failed arrives. Send the first email. Mark the subscription as past_due.
  2. Day 3: If still unpaid, send a second email with more urgency. Consider an in-app banner on their dashboard.
  3. Day 5: Send a final warning. "Your access will be restricted in 2 days."
  4. Day 7: Downgrade to the free tier. Send a confirmation that their subscription has ended, with a link to re-subscribe.

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.

Testing Subscription Webhooks

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.

Common Mistakes with Subscription Events

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.

Key Takeaways

  • Paystack sends six distinct events across the subscription lifecycle: subscription.create, subscription.not_renew, subscription.disable, invoice.create, invoice.payment_failed, and invoice.update.
  • subscription.create fires once when the customer first subscribes. Use it to activate their account and store the subscription code.
  • subscription.not_renew means the customer cancelled but still has access until the current period ends. Do not cut access immediately.
  • invoice.payment_failed is your signal to notify the customer and start a grace period before downgrading their account.
  • Always deduplicate subscription events by the subscription_code and event type to avoid double-processing during retries.
  • Return 200 from your webhook handler before processing. Queue subscription state changes for background workers.

Frequently Asked Questions

What is the difference between subscription.not_renew and subscription.disable?
subscription.not_renew means the subscription will not renew at the end of the current billing period, but the customer still has access until then. subscription.disable means the subscription is fully deactivated and no more charges will occur. Think of not_renew as the cancellation notice and disable as the final shutdown.
Does Paystack automatically retry failed subscription charges?
Yes. Paystack may retry failed subscription charges automatically. The retry schedule is not publicly documented with fixed intervals, so check the Paystack dashboard or documentation for current behavior. Each failed attempt triggers an invoice.payment_failed event, and a successful retry triggers invoice.update with paid set to true.
How long should the grace period be after a failed subscription payment?
Seven days is a common default for African SaaS products. Some businesses use 3, 10, or 14 days depending on their market and pricing. The key is to communicate the deadline clearly in every notification you send to the customer, and to automatically restore access if the payment eventually succeeds.
Can I cancel a Paystack subscription from my server without the customer?
Yes. You can disable a subscription by sending a POST request to the Paystack API at /subscription/disable with the subscription code and email token. When you do this, Paystack will fire the subscription.disable webhook event so your handler can update the customer status accordingly.
What happens if I miss a subscription webhook event?
Paystack will retry the delivery several times. If all retries fail, the event is lost from the webhook perspective. You can recover by polling the Paystack API to fetch the current subscription status and reconcile it with your database. Build a periodic reconciliation job as a safety net.

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