Bonaventure OgetoBy Bonaventure Ogeto|

Build Paystack Subscriptions in Next.js Pages Router

To build Paystack subscriptions in Next.js Pages Router, create API routes under pages/api for plan management and webhook handling. Initialize subscription checkout by calling the Paystack transaction/initialize endpoint with a plan code from your API route. Handle subscription events (creation, renewal, failure, cancellation) in a POST handler at pages/api/paystack/webhook.ts that verifies the HMAC SHA-512 signature.

Subscription Flow Overview

Paystack subscriptions work around three objects: plans, customers, and authorizations. You create a plan that defines the price and interval. When a customer pays for that plan, Paystack captures their card authorization. From that point, Paystack automatically charges the saved authorization on each billing cycle.

In the Pages Router, your API routes serve as the backend. They call the Paystack API to create plans, initialize subscription checkouts, verify transactions, and manage subscriptions. Your webhook API route receives lifecycle events from Paystack: subscription created, charge succeeded, payment failed, subscription cancelled.

The flow in practice:

  1. Customer picks a plan on your pricing page.
  2. Your frontend calls your API route, which calls Paystack to initialize a transaction with the plan code.
  3. Customer is redirected to Paystack Checkout, pays, and gets redirected back to your callback page.
  4. Paystack sends a subscription.create webhook to your server. You store the subscription details.
  5. Each billing cycle, Paystack charges the card and sends charge.success with the subscription reference.
  6. If a charge fails, you get invoice.payment_failed and start your dunning process.

Project Setup

You need a Next.js project using the Pages Router with your Paystack API keys ready.

npm install next react react-dom

Add your keys to .env.local:

PAYSTACK_SECRET_KEY=sk_test_your_secret_key
NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_your_public_key

Create a Paystack utility at lib/paystack.ts:

// lib/paystack.ts
const PAYSTACK_SECRET_KEY = process.env.PAYSTACK_SECRET_KEY!;
const BASE_URL = "https://api.paystack.co";

export async function paystackRequest(
  endpoint: string,
  method: string = "GET",
  body?: Record<string, any>
) {
  const options: RequestInit = {
    method: method,
    headers: {
      Authorization: "Bearer " + PAYSTACK_SECRET_KEY,
      "Content-Type": "application/json",
    },
  };

  if (body) {
    options.body = JSON.stringify(body);
  }

  const res = await fetch(BASE_URL + endpoint, options);
  const data = await res.json();

  if (!data.status) {
    throw new Error(data.message || "Paystack request failed");
  }

  return data;
}

Creating Plans via API Routes

Create an API route that wraps the Paystack plan creation endpoint. For most apps you will create plans once and store the codes, but having an API route is useful for admin tools.

// pages/api/plans/create.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { paystackRequest } from "@/lib/paystack";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  try {
    const { name, amount, interval } = req.body;
    const data = await paystackRequest("/plan", "POST", {
      name: name,
      amount: amount, // in kobo
      interval: interval, // "monthly", "annually", etc.
      currency: "NGN",
    });

    res.status(200).json({ plan: data.data });
  } catch (error: any) {
    res.status(500).json({ error: error.message });
  }
}

And a route to list your existing plans:

// pages/api/plans/index.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { paystackRequest } from "@/lib/paystack";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    const data = await paystackRequest("/plan");
    res.status(200).json({ plans: data.data });
  } catch (error: any) {
    res.status(500).json({ error: error.message });
  }
}

Amounts are always in the smallest currency unit. For NGN, 500000 means 5,000 Naira. For KES, 100000 means 1,000 shillings (Paystack uses the same kobo convention for all currencies, treating the value as the lowest denomination).

Initializing Subscription Checkout

When a customer clicks "Subscribe" on your pricing page, your frontend calls an API route that initializes the transaction with a plan code. This is different from a regular one-time payment: including the plan parameter tells Paystack to create a subscription after the first charge succeeds.

// pages/api/subscribe.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { paystackRequest } from "@/lib/paystack";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  const { email, planCode, userId } = req.body;

  try {
    const data = await paystackRequest("/transaction/initialize", "POST", {
      email: email,
      plan: planCode,
      callback_url: process.env.NEXT_PUBLIC_APP_URL + "/subscription/callback",
      metadata: {
        user_id: userId,
      },
    });

    res.status(200).json({ authorization_url: data.data.authorization_url });
  } catch (error: any) {
    res.status(500).json({ error: error.message });
  }
}

On the frontend, call this API route and redirect the user:

// pages/pricing.tsx
import { useState } from "react";
import { useRouter } from "next/router";

export default function PricingPage() {
  const router = useRouter();
  const [loading, setLoading] = useState(false);

  async function handleSubscribe(planCode: string) {
    setLoading(true);
    const res = await fetch("/api/subscribe", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        email: "customer@example.com",
        planCode: planCode,
        userId: "user_123",
      }),
    });
    const data = await res.json();

    if (data.authorization_url) {
      window.location.href = data.authorization_url;
    }
    setLoading(false);
  }

  return (
    <div>
      <h1>Choose a Plan</h1>
      <button onClick={() => handleSubscribe("PLN_basic_code")} disabled={loading}>
        Basic - NGN 3,000/month
      </button>
      <button onClick={() => handleSubscribe("PLN_pro_code")} disabled={loading}>
        Pro - NGN 8,000/month
      </button>
    </div>
  );
}

After payment, the customer lands on your callback page. Use getServerSideProps to verify the transaction server-side:

// pages/subscription/callback.tsx
import { GetServerSideProps } from "next";
import { paystackRequest } from "@/lib/paystack";

export const getServerSideProps: GetServerSideProps = async (context) => {
  const reference = context.query.reference as string;

  try {
    const data = await paystackRequest("/transaction/verify/" + reference);
    return {
      props: {
        transaction: data.data,
        success: data.data.status === "success",
      },
    };
  } catch {
    return { props: { transaction: null, success: false } };
  }
};

export default function CallbackPage({ success }: { success: boolean }) {
  if (success) {
    return <div>Your subscription is active. Welcome aboard.</div>;
  }
  return <div>Payment did not go through. Please try again.</div>;
}

Webhook Handler with Raw Body Parsing

The webhook handler is the most important piece. Paystack signs every webhook with your secret key using HMAC SHA-512. To verify the signature, you need the raw request body as a string, not the parsed JSON object.

Next.js Pages Router parses the body automatically. You need to disable that for the webhook route:

// pages/api/paystack/webhook.ts
import type { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto";

// Disable body parsing to get the raw body for signature verification
export const config = {
  api: {
    bodyParser: false,
  },
};

function getRawBody(req: NextApiRequest): Promise<string> {
  return new Promise((resolve, reject) => {
    let data = "";
    req.on("data", (chunk: Buffer) => {
      data += chunk.toString();
    });
    req.on("end", () => resolve(data));
    req.on("error", reject);
  });
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  const rawBody = await getRawBody(req);
  const signature = req.headers["x-paystack-signature"] as string;
  const hash = crypto
    .createHmac("sha512", process.env.PAYSTACK_SECRET_KEY!)
    .update(rawBody)
    .digest("hex");

  if (hash !== signature) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const event = JSON.parse(rawBody);

  // Return 200 immediately
  res.status(200).json({ received: true });

  // Process the event
  switch (event.event) {
    case "subscription.create":
      // Store subscription code, plan code, email token, customer info
      // event.data.subscription_code, event.data.email_token
      break;

    case "charge.success":
      // Check if this charge is for a subscription renewal
      if (event.data.plan && event.data.plan.plan_code) {
        // Extend access, record payment
      }
      break;

    case "invoice.payment_failed":
      // Start dunning: notify customer, maybe queue a retry email
      break;

    case "subscription.not_renew":
      // Customer cancelled, but still has access until period ends
      break;

    case "subscription.disable":
      // Subscription is done, revoke access
      break;
  }
}

The bodyParser: false export config is essential. Without it, Next.js auto-parses the body, and when you try to verify the signature against the re-serialized JSON, the hash will not match because of whitespace or key ordering differences.

Cancellation and Reactivation

Customers will want to cancel. Some will want to come back. You need API routes for both.

// pages/api/subscription/cancel.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { paystackRequest } from "@/lib/paystack";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  const { subscriptionCode, emailToken } = req.body;

  try {
    await paystackRequest("/subscription/disable", "POST", {
      code: subscriptionCode,
      token: emailToken,
    });

    // Update your database: set status to "non_renewing"
    // Do NOT revoke access immediately - let the current period finish

    res.status(200).json({ message: "Subscription will not renew" });
  } catch (error: any) {
    res.status(500).json({ error: error.message });
  }
}
// pages/api/subscription/reactivate.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { paystackRequest } from "@/lib/paystack";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  const { subscriptionCode, emailToken } = req.body;

  try {
    await paystackRequest("/subscription/enable", "POST", {
      code: subscriptionCode,
      token: emailToken,
    });

    // Update your database: set status back to "active"
    res.status(200).json({ message: "Subscription reactivated" });
  } catch (error: any) {
    res.status(500).json({ error: error.message });
  }
}

Important: the emailToken comes from the subscription.create webhook payload. If you did not store it when the subscription was created, you cannot programmatically cancel or reactivate. You would need to use the Paystack Dashboard or have the customer use the Paystack manage link instead.

When a customer cancels, the subscription.not_renew event fires. This means "I am done after this period." The customer should keep access until their current billing period ends. Only when subscription.disable fires (or the period expires) should you fully revoke access.

Dunning and Failed Payments

When a recurring charge fails, Paystack sends invoice.payment_failed. Paystack also retries the charge automatically (typically three times over eight days), but you should not rely solely on retries. Customers with expired cards need to know.

Build a dunning sequence that escalates:

// lib/dunning.ts
export interface DunningConfig {
  daysAfterFailure: number;
  emailSubject: string;
  restrictAccess: boolean;
}

export const DUNNING_STEPS: DunningConfig[] = [
  { daysAfterFailure: 0, emailSubject: "Your payment could not be processed", restrictAccess: false },
  { daysAfterFailure: 3, emailSubject: "Action needed: update your payment method", restrictAccess: false },
  { daysAfterFailure: 7, emailSubject: "Your account access will be limited soon", restrictAccess: true },
  { daysAfterFailure: 14, emailSubject: "Your subscription has ended", restrictAccess: true },
];

export function getDunningStep(daysAfterFailure: number): DunningConfig | undefined {
  return DUNNING_STEPS.find(step => step.daysAfterFailure === daysAfterFailure);
}

In your webhook handler, when you receive invoice.payment_failed, record the failure timestamp and queue the first dunning email. Use a cron job or scheduled function to check daily for subscriptions in dunning and send the next email in the sequence.

Include a link to the Paystack manage page in every dunning email. You can generate this link through the API:

// pages/api/subscription/manage-link.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { paystackRequest } from "@/lib/paystack";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { subscriptionCode } = req.query;

  try {
    const data = await paystackRequest(
      "/subscription/" + subscriptionCode + "/manage/link"
    );
    res.status(200).json({ link: data.data.link });
  } catch (error: any) {
    res.status(500).json({ error: error.message });
  }
}

This link takes the customer to a Paystack-hosted page where they can update their card. You never touch card details. When they update successfully and the next retry succeeds, you will get a charge.success event. Clear the dunning state and restore full access.

Production Readiness

Before launching subscriptions to real customers, verify every piece:

  • Signature verification works with the raw body. Deploy to staging and send a test webhook from the Paystack Dashboard. Confirm your handler returns 200 and processes the event.
  • Idempotency is in place. Store event references and skip duplicates. Paystack may deliver the same event more than once.
  • Email token is stored. Without it, you cannot cancel subscriptions programmatically.
  • Dunning emails send correctly. Trigger an invoice.payment_failed event and verify the customer receives the email with a working manage link.
  • Access control respects subscription status. Use getServerSideProps to check the user's subscription status before rendering premium content.
  • Live keys are set in production. Double-check that your environment variables use sk_live_ and pk_live_ prefixes.
  • Logging captures every webhook. Log the event type, reference, and timestamp for every webhook you receive. This is your audit trail when customers dispute charges.

A daily reconciliation job that fetches subscriptions from the Paystack API and compares them to your database is a good safety net. It catches any events your webhook handler might have missed due to downtime or network issues.

Key Takeaways

  • API routes under pages/api handle all server-side Paystack calls, keeping your secret key off the client.
  • Initialize subscription checkout by passing the plan code to the transaction/initialize endpoint from an API route.
  • The webhook handler at pages/api/paystack/webhook.ts must read the raw body for signature verification, requiring a custom body parser config.
  • Store the email_token from subscription.create events so you can programmatically cancel or reactivate subscriptions.
  • Use getServerSideProps to fetch subscription status from your database and render the correct UI on billing pages.
  • Build dunning around the invoice.payment_failed event, sending customers a Paystack manage link to update their card.
  • Always return 200 from your webhook handler immediately, even if downstream processing takes time.

Frequently Asked Questions

What is the difference between subscription handling in Pages Router and App Router?
The core Paystack integration is identical. The difference is where your server-side code lives. Pages Router uses API routes under pages/api and getServerSideProps for server-side rendering. App Router uses Route Handlers and Server Actions. The webhook signature verification, plan creation, and subscription management API calls are the same in both.
Why do I need to disable body parsing for the webhook route?
Paystack signs the webhook payload with HMAC SHA-512 using the exact bytes sent. Next.js Pages Router parses the body into a JSON object by default. When you re-serialize that object to verify the signature, key ordering or whitespace differences can produce a different hash. Reading the raw body string preserves the exact bytes Paystack signed.
Can I subscribe a customer without redirecting them to Paystack Checkout?
If you already have the customer authorization code from a previous transaction, you can create a subscription directly using the POST /subscription endpoint with the customer code, plan code, and authorization code. This skips the checkout step entirely.
How do I handle plan upgrades mid-cycle?
Paystack does not support automatic plan changes. Cancel the current subscription with the disable endpoint, prorate the remaining balance manually, and create a new subscription on the new plan. Some teams credit the prorated amount as a discount on the first charge of the new plan.
What happens to the subscription if the customer disputes a charge?
A dispute does not automatically cancel the subscription. You will receive a dispute.create webhook event. Handle it by pausing the subscription manually if needed, and respond to the dispute through the Paystack Dashboard. If the dispute is resolved in the customer favor, you may want to cancel the subscription to prevent further charges.

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