To build Paystack subscriptions in Next.js App Router, create plans via the Paystack API from a Server Action or Route Handler, initialize a subscription checkout that captures the customer authorization code, then handle subscription lifecycle events (creation, renewal, cancellation, failed charges) through a POST Route Handler at /api/paystack/webhook that verifies the signature and updates your database.
Before writing any Next.js code, you need to understand the moving parts. A Paystack subscription connects three things: a plan (the price and interval), a customer (their email and identity), and an authorization (the saved card or payment method Paystack will charge each cycle).
The flow goes like this:
The subscription lifecycle events you will handle include subscription.create, charge.success (with a subscription reference), invoice.payment_failed, subscription.not_renew, and subscription.disable. Each tells your app something different about the state of that customer's billing.
In Next.js App Router, Server Actions handle the outbound API calls (creating plans, initializing transactions), while Route Handlers receive the inbound webhooks. This separation keeps your secret key on the server and gives you type-safe, colocated logic.
Start with a Next.js 14+ project using the App Router. You need your Paystack secret key and public key from the Paystack Dashboard under Settings > API Keys & Webhooks.
npm install next@latest react@latest
Create a .env.local file in your project root:
PAYSTACK_SECRET_KEY=sk_test_your_secret_key_here
NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_your_public_key_here
PAYSTACK_WEBHOOK_SECRET=your_webhook_secret_here
Create a reusable Paystack helper 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 paystackFetch(endpoint: string, options: RequestInit = {}) {
const res = await fetch(BASE_URL + endpoint, {
...options,
headers: {
Authorization: "Bearer " + PAYSTACK_SECRET_KEY,
"Content-Type": "application/json",
...options.headers,
},
});
const data = await res.json();
if (!data.status) {
throw new Error(data.message || "Paystack API error");
}
return data;
}
This helper centralizes auth headers and error handling. Every server-side call to Paystack goes through it.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Plans define what you are selling: the amount, billing interval, and currency. You can create plans from the Paystack Dashboard or programmatically. For a SaaS product, creating them via API lets you version-control your pricing.
Create a Server Action at app/actions/plans.ts:
// app/actions/plans.ts
"use server";
import { paystackFetch } from "@/lib/paystack";
export async function createPlan(name: string, amountInKobo: number, interval: string) {
const data = await paystackFetch("/plan", {
method: "POST",
body: JSON.stringify({
name: name,
amount: amountInKobo,
interval: interval, // "monthly", "yearly", "weekly", "daily"
currency: "NGN",
}),
});
return data.data; // { plan_code: "PLN_xxx", ... }
}
export async function listPlans() {
const data = await paystackFetch("/plan");
return data.data;
}
A few things to note about plans:
invoice_limit to cap how many times a subscription renews (useful for 12-month plans that should not auto-renew forever).For most SaaS apps, you will create two or three plans (Basic, Pro, Enterprise) once and store the plan codes in your database or environment variables. You do not need to create a new plan for every customer.
To subscribe a customer, you initialize a transaction that includes the plan code. When the customer completes payment, Paystack captures their card authorization and creates the subscription automatically.
// app/actions/subscriptions.ts
"use server";
import { paystackFetch } from "@/lib/paystack";
import { redirect } from "next/navigation";
export async function subscribeCustomer(formData: FormData) {
const email = formData.get("email") as string;
const planCode = formData.get("planCode") as string;
const data = await paystackFetch("/transaction/initialize", {
method: "POST",
body: JSON.stringify({
email: email,
plan: planCode,
callback_url: process.env.NEXT_PUBLIC_APP_URL + "/subscription/callback",
metadata: {
user_id: formData.get("userId"),
custom_fields: [
{
display_name: "Plan",
variable_name: "plan",
value: planCode,
},
],
},
}),
});
redirect(data.data.authorization_url);
}
The customer form in your React Server Component is straightforward:
// app/pricing/page.tsx
import { subscribeCustomer } from "@/app/actions/subscriptions";
export default function PricingPage() {
return (
<form action={subscribeCustomer}>
<input type="hidden" name="planCode" value="PLN_your_plan_code" />
<input type="hidden" name="userId" value="user_123" />
<input type="email" name="email" placeholder="you@example.com" required />
<button type="submit">Subscribe - NGN 5,000/month</button>
</form>
);
}
When the customer completes payment on the Paystack checkout page, two things happen simultaneously: Paystack redirects them to your callback URL with ?trxref=xxx&reference=xxx, and Paystack sends a charge.success webhook to your server. The webhook is the reliable one. The redirect is for user experience only.
On the callback page, verify the transaction server-side and show the customer their subscription status:
// app/subscription/callback/page.tsx
import { paystackFetch } from "@/lib/paystack";
export default async function SubscriptionCallback({
searchParams,
}: {
searchParams: { reference: string };
}) {
const ref = searchParams.reference;
const data = await paystackFetch("/transaction/verify/" + ref);
const transaction = data.data;
if (transaction.status === "success") {
return <div>Subscription active. Welcome aboard.</div>;
}
return <div>Something went wrong. Please try again.</div>;
}Webhooks are where the real subscription logic lives. Paystack sends events for every lifecycle moment: creation, renewals, failures, and cancellation. Your Route Handler at app/api/paystack/webhook/route.ts catches them all.
// app/api/paystack/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
const PAYSTACK_WEBHOOK_SECRET = process.env.PAYSTACK_SECRET_KEY!;
function verifySignature(body: string, signature: string): boolean {
const hash = crypto
.createHmac("sha512", PAYSTACK_WEBHOOK_SECRET)
.update(body)
.digest("hex");
return hash === signature;
}
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("x-paystack-signature") || "";
if (!verifySignature(body, signature)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(body);
// Return 200 immediately, then process
// In production, queue the event for async processing
switch (event.event) {
case "subscription.create":
await handleSubscriptionCreated(event.data);
break;
case "charge.success":
if (event.data.plan && event.data.plan.plan_code) {
await handleSubscriptionRenewal(event.data);
}
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data);
break;
case "subscription.not_renew":
await handleSubscriptionNotRenew(event.data);
break;
case "subscription.disable":
await handleSubscriptionDisabled(event.data);
break;
}
return NextResponse.json({ received: true }, { status: 200 });
}
async function handleSubscriptionCreated(data: any) {
// Save subscription to your database
// data.subscription_code, data.plan.plan_code, data.customer.email
// data.authorization.authorization_code
console.log("Subscription created:", data.subscription_code);
}
async function handleSubscriptionRenewal(data: any) {
// Extend the user access period
// data.reference, data.amount, data.customer.email
console.log("Renewal successful:", data.reference);
}
async function handlePaymentFailed(data: any) {
// Start dunning: email the customer to update their card
// data.subscription.subscription_code, data.customer.email
console.log("Payment failed for:", data.customer.email);
}
async function handleSubscriptionNotRenew(data: any) {
// Customer cancelled - mark subscription for expiry at period end
console.log("Will not renew:", data.subscription_code);
}
async function handleSubscriptionDisabled(data: any) {
// Subscription fully stopped - revoke access
console.log("Subscription disabled:", data.subscription_code);
}
Critical details about webhook handling:
req.text() before JSON.parse(). If you parse first, the re-stringified JSON might have different whitespace.Once subscriptions are running, you need to let customers manage them: check status, cancel, and resubscribe.
Create Server Actions for subscription management:
// app/actions/manage-subscription.ts
"use server";
import { paystackFetch } from "@/lib/paystack";
export async function getSubscription(subscriptionCode: string) {
const data = await paystackFetch("/subscription/" + subscriptionCode);
return data.data;
}
export async function cancelSubscription(subscriptionCode: string, emailToken: string) {
const data = await paystackFetch("/subscription/disable", {
method: "POST",
body: JSON.stringify({
code: subscriptionCode,
token: emailToken,
}),
});
return data;
}
export async function reactivateSubscription(subscriptionCode: string, emailToken: string) {
const data = await paystackFetch("/subscription/enable", {
method: "POST",
body: JSON.stringify({
code: subscriptionCode,
token: emailToken,
}),
});
return data;
}
The emailToken is sent in the subscription.create webhook event. Store it alongside the subscription code in your database. Without it, you cannot programmatically cancel or reactivate a subscription.
To generate a link where the customer can update their card (useful for dunning), use the manage subscription link:
export async function getManageLink(subscriptionCode: string) {
const data = await paystackFetch(
"/subscription/" + subscriptionCode + "/manage/link"
);
return data.data.link;
}
This returns a Paystack-hosted page where the customer can update their payment method without you handling any card details. Send this link in your dunning emails after a failed charge.
Dunning is what happens when a subscription charge fails. Maybe the card expired, maybe the bank declined it, maybe the account had insufficient funds. Your job is to give the customer a chance to fix the problem before you cut off their access.
A practical dunning flow in Next.js:
// lib/dunning.ts
import { getManageLink } from "@/app/actions/manage-subscription";
interface DunningStep {
dayAfterFailure: number;
subject: string;
action: "email" | "restrict" | "cancel";
}
const DUNNING_SCHEDULE: DunningStep[] = [
{ dayAfterFailure: 0, subject: "Your payment failed", action: "email" },
{ dayAfterFailure: 3, subject: "Update your card to keep your account", action: "email" },
{ dayAfterFailure: 7, subject: "Your account will be restricted soon", action: "restrict" },
{ dayAfterFailure: 14, subject: "Your subscription has been cancelled", action: "cancel" },
];
export async function processDunningStep(
subscriptionCode: string,
customerEmail: string,
daysSinceFailure: number
) {
const step = DUNNING_SCHEDULE.find(s => s.dayAfterFailure === daysSinceFailure);
if (!step) return;
const manageLink = await getManageLink(subscriptionCode);
switch (step.action) {
case "email":
// Send email with manageLink using your email provider
await sendDunningEmail(customerEmail, step.subject, manageLink);
break;
case "restrict":
// Downgrade to read-only or limited features
await restrictAccount(customerEmail);
await sendDunningEmail(customerEmail, step.subject, manageLink);
break;
case "cancel":
// Fully revoke access
await cancelAccount(customerEmail);
break;
}
}
Paystack retries failed charges three times over eight days by default. Your dunning flow should run parallel to those retries. If Paystack succeeds on a retry, you will get a charge.success event, and you should clear any dunning state and restore full access.
The key insight: never cut off access on the first failure. Legitimate customers get declined for temporary reasons all the time. Give them at least a week before any restriction.
Your database needs to track the relationship between your users, Paystack plans, and active subscriptions. Here is a minimal schema that covers the essentials:
-- subscriptions table
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
paystack_subscription_code TEXT UNIQUE NOT NULL,
paystack_plan_code TEXT NOT NULL,
paystack_customer_code TEXT NOT NULL,
paystack_email_token TEXT, -- needed for cancel/reactivate
status TEXT NOT NULL DEFAULT 'active',
-- active, non_renewing, attention, cancelled
current_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- subscription_events table for idempotency
CREATE TABLE subscription_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
event_reference TEXT UNIQUE NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
The subscription_events table is your idempotency guard. Before processing any webhook, check if the event reference already exists. If it does, skip it. This protects you from Paystack's retry logic delivering the same event twice.
The status field maps to Paystack's subscription states:
You cannot test webhooks on localhost because Paystack needs to reach your server over the public internet. Use a tunnel to expose your local Route Handler:
# Using ngrok
ngrok http 3000
# Using Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000
Copy the public URL and set it as your webhook URL in the Paystack Dashboard under Settings > API Keys & Webhooks. The full path would be something like https://abc123.ngrok.io/api/paystack/webhook.
For testing the subscription flow end to end:
4084 0840 8408 4081 with any future expiry and any CVV.For testing failed charges, Paystack provides test cards that simulate different failure scenarios. Card number 4084 0840 8408 4081 with expiry 01/20 (a past date does not work in test mode, but you can configure failures in the dashboard). Check the Paystack integration hub for the latest test card numbers.
Always test the full lifecycle: subscribe, verify the webhook fires, simulate a renewal, simulate a failure, cancel, and reactivate. Each of these touches different parts of your code.
Before going live with subscriptions, walk through this list:
sk_test_ and pk_test_ with your live keys in production environment variables.invoice.payment_failed fires, a real email goes out with a manage link.paystack_subscription_code and user_id on your subscriptions table.One last thing: the subscription.not_renew event fires when a customer cancels, but they still have access until the end of their current billing period. Do not revoke access on this event. Wait for the period to end, or for subscription.disable, before downgrading them.
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