Build Paystack Subscriptions in Next.js App Router
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.
How Paystack Subscriptions Work
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:
- You create a plan on Paystack (monthly, yearly, whatever interval you need).
- A customer pays for that plan through Paystack Checkout. This first payment captures their authorization code.
- Paystack stores that authorization and automatically charges it when the next billing cycle arrives.
- On each renewal, Paystack sends webhook events to your server. Your job is to listen, verify, and update your database.
- If a charge fails, Paystack retries and sends you failure events so you can notify the customer.
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.
Project Setup and Environment
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.
Creating Subscription Plans
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:
- Amount is in the smallest currency unit. For NGN, that is kobo. A 5,000 NGN plan means you pass 500000.
- Interval options are "hourly", "daily", "weekly", "monthly", "quarterly", and "annually".
- Plan codes start with PLN_ and are what you reference when subscribing a customer.
- You can also set
invoice_limitto 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.
Subscribing Customers with Server Actions
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>;
}Handling Subscription Webhooks in Route Handlers
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:
- Return 200 fast. Paystack expects a 200 response within a few seconds. If your handler times out, Paystack retries, and you risk processing the same event twice.
- Use the raw body for signature verification. Call
req.text()beforeJSON.parse(). If you parse first, the re-stringified JSON might have different whitespace. - The secret for HMAC is your Paystack secret key, not a separate webhook secret. This trips up a lot of developers.
- Make handlers idempotent. Store the event reference and skip duplicates.
Managing the Subscription Lifecycle
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.
Building a Dunning Flow
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.
Database Schema for Subscriptions
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:
- active - Currently billing and access is granted.
- non_renewing - Customer cancelled, but the current period is still active.
- attention - A charge failed and Paystack is retrying.
- cancelled - Subscription is fully done.
Testing Subscriptions Locally
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:
- Create a test plan with a small amount (e.g., 10000 kobo = 100 NGN).
- Use Paystack test card
4084 0840 8408 4081with any future expiry and any CVV. - After the first payment, check the Paystack Dashboard under Subscriptions to see the created subscription.
- Use the Paystack Dashboard to manually trigger a subscription renewal for testing.
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.
Production Checklist
Before going live with subscriptions, walk through this list:
- Switch to live keys. Replace
sk_test_andpk_test_with your live keys in production environment variables. - Set the webhook URL in the Paystack Dashboard to your production domain.
- Verify signature checking works. Deploy, then send a test webhook from the Dashboard and confirm your handler processes it.
- Idempotency is implemented. Your webhook handler checks for duplicate events before processing.
- Dunning emails are configured. When
invoice.payment_failedfires, a real email goes out with a manage link. - Database has proper indexes. Index
paystack_subscription_codeanduser_idon your subscriptions table. - Logging is in place. Log every webhook event (type, reference, timestamp) so you can debug billing issues without guessing.
- Cancellation flow works. Customers can cancel from your UI, and your Server Action calls the Paystack disable endpoint with the correct email token.
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.
Key Takeaways
- ✓Server Actions handle plan creation and subscription initialization without exposing your secret key to the client.
- ✓Route Handlers at /api/paystack/webhook receive subscription lifecycle events like subscription.create, invoice.payment_failed, and subscription.not_renew.
- ✓Always verify the webhook signature using HMAC SHA-512 before processing any event.
- ✓Store the authorization code from the first successful charge so Paystack can bill the customer on subsequent cycles.
- ✓Use the subscription code (not the plan code) when you need to cancel or manage a specific customer subscription.
- ✓Build a dunning flow that listens for invoice.payment_failed and sends the customer a link to update their card.
Frequently Asked Questions
- Do I need to create a Paystack customer before subscribing them?
- No. When you initialize a transaction with a plan code and an email, Paystack creates the customer automatically if they do not already exist. Paystack deduplicates by email, so if the same email subscribes again, it attaches to the existing customer record.
- How do I handle plan upgrades or downgrades?
- Paystack does not have a built-in plan change endpoint. The standard approach is to cancel the current subscription (using the disable endpoint), then create a new subscription on the new plan. You can prorate manually by calculating the remaining days on the old plan and applying a partial credit on the new one.
- What happens if my webhook endpoint is down when Paystack sends an event?
- Paystack retries failed webhook deliveries several times over a period of hours. If all retries fail, the event is lost from the webhook perspective. As a safety net, build a daily reconciliation job that fetches active subscriptions from the Paystack API and compares them to your database.
- Can I use Paystack subscriptions with mobile money in Ghana or Kenya?
- Paystack subscriptions are designed around card authorizations. Mobile money does not produce a reusable authorization code in the same way cards do. For mobile money recurring billing, you would need to build your own billing engine that prompts the customer to approve each charge individually.
- How do I test subscription renewals without waiting for the next billing cycle?
- Create a plan with a "daily" or "hourly" interval in test mode. The charge will fire at the next interval, letting you test the renewal webhook quickly. Alternatively, you can use the Paystack Dashboard to manually trigger a charge on a test subscription.
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