How to Add Paystack Recurring Billing to a SaaS App

One-time payments are the easy part. You initialize a transaction, the customer pays, you confirm the reference — done. But if you are building a SaaS product, that model will not keep the lights on. You need recurring billing: automatic monthly or annual charges, graceful handling of failed cards, and a clear way to upgrade or cancel a plan. Paystack supports all of this, but the documentation is scattered and most tutorials bail out before the hard parts. This guide does not.

We will walk through the full subscription lifecycle using Paystack's API in a Node.js backend and a React frontend.


The Four Moving Parts

Before writing a single line of code, map out what you are actually building:

  1. Plans — price tiers defined on Paystack (e.g., Starter at GHS 99/month)
  2. Subscriptions — a customer attached to a plan, with a managed charge schedule
  3. Webhooks — Paystack calling your server when a charge succeeds, fails, or a subscription is cancelled
  4. Recovery flows — what happens when a card expires or a charge fails

Miss any one of these and you will ship a leaky revenue engine.


Step 1: Create Plans via the API

You can create plans in the Paystack dashboard, but doing it programmatically means your plans are version-controlled and reproducible across environments.

// server/paystack/createPlan.js
const axios = require("axios");

async function createPlan({ name, amount, interval }) {
  // amount is in the smallest currency unit (kobo for NGN, pesewas for GHS)
  const response = await axios.post(
    "https://api.paystack.co/plan",
    { name, amount, interval }, // interval: "monthly" | "annually" | "weekly"
    {
      headers: {
        Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
        "Content-Type": "application/json",
      },
    }
  );
  return response.data.data; // { plan_code, id, ... }
}

Store the returned plan_code in your database alongside the tier name. You will need it every time a customer subscribes.


Step 2: Initialize a Subscription (Not Just a Transaction)

The key difference from a one-time payment is that you pass the plan parameter when initializing the transaction. Paystack will charge the customer immediately and store their card authorization for future recurring charges.

On your React frontend, call your own backend to create the session — never expose your secret key to the browser.

POST /api/billing/subscribe
Body: { email, planCode }

Your Node.js handler:

app.post("/api/billing/subscribe", async (req, res) => {
  const { email, planCode } = req.body;
  const response = await axios.post(
    "https://api.paystack.co/transaction/initialize",
    {
      email,
      amount: 0, // Paystack derives the amount from the plan
      plan: planCode,
      callback_url: `${process.env.APP_URL}/billing/callback`,
    },
    { headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
  );
  res.json({ authorizationUrl: response.data.data.authorization_url });
});

Redirect the user to authorizationUrl. After payment, Paystack redirects them back to your callback_url with a reference query parameter. Verify that reference and mark the customer as active in your database.


Step 3: Handle Webhooks — This Is Where Most Integrations Break

Webhooks are non-negotiable for subscriptions. The initial transaction tells you someone subscribed. Webhooks tell you everything that happens after.

Register your webhook URL in the Paystack dashboard under Settings → API Keys & Webhooks.

The events you must handle:

EventWhat it means
charge.successRecurring charge went through — extend the billing period
invoice.payment_failedCard was declined — trigger recovery flow
subscription.disableCustomer or admin cancelled — revoke access
subscription.enableSubscription was reactivated

A minimal but secure webhook handler in Express:

const crypto = require("crypto");

app.post("/webhooks/paystack", express.raw({ type: "application/json" }), (req, res) => {
  const hash = crypto
    .createHmac("sha512", process.env.PAYSTACK_SECRET_KEY)
    .update(req.body)
    .digest("hex");

  if (hash !== req.headers["x-paystack-signature"]) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body);

  switch (event.event) {
    case "charge.success":
      handleChargeSuccess(event.data);
      break;
    case "invoice.payment_failed":
      handlePaymentFailed(event.data);
      break;
    case "subscription.disable":
      handleSubscriptionCancelled(event.data);
      break;
  }

  res.sendStatus(200); // Always respond quickly — process async
});

Two things to note: use express.raw() on this route (not express.json()), because the HMAC verification needs the raw bytes. And always return a 200 immediately — queue the actual processing as a background job to avoid timeouts.


Step 4: Failed Payment Recovery

A failed charge is not the end of the world, but ignoring it is. Your handlePaymentFailed function should do three things:

  • Set a grace period — give the customer 3–7 days before revoking access.
  • Send a dunning email — prompt them to update their card. Paystack provides a update_subscription_url in the subscription object; include this link in the email.
  • Escalate on repeat failure — after two or three failed attempts, suspend the account and send a final notice.
async function handlePaymentFailed(data) {
  const customer = await db.customers.findByEmail(data.customer.email);
  await db.customers.update(customer.id, {
    status: "past_due",
    grace_period_ends: addDays(new Date(), 5),
  });
  await emailService.sendDunningEmail({
    to: customer.email,
    updateUrl: data.subscription.update_subscription_url,
  });
}

Step 5: Cancellations and Plan Changes

To cancel a subscription programmatically, call POST /subscription/disable with the subscription_code and the customer's email_token (both returned when the subscription was created). Store these in your database at subscription time.

For plan upgrades or downgrades, the cleanest approach is to disable the current subscription and create a new one on the target plan. Paystack does not currently support mid-cycle proration natively, so you will need to calculate and credit unused days manually if that matters to your pricing model.


Testing the Full Flow Locally

Use ngrok or a similar tunneling tool to expose your local server to Paystack's webhook delivery. Set the tunnel URL as your webhook endpoint in the dashboard during development. Always test with Paystack's test keys and the dedicated test cards listed in their documentation before going live.


Why This Matters for Your Project

A subscription system is not just a payment feature — it is the financial backbone of your entire product. Getting the webhook handling wrong means customers lose access after successful renewals. Skipping the recovery flow means silent churn you will never see in your dashboard. If you are building on a Node.js/React stack and targeting African markets, Paystack gives you a robust foundation; the engineering effort is in wiring the lifecycle together correctly. Build it once, build it right, and your billing layer will scale from 10 customers to 10,000 without a rewrite.