To build Paystack subscriptions in React, use the Paystack Inline JS popup on the frontend to collect the first payment with a plan code, and handle all server-side logic (plan creation, webhook processing, subscription management) through your backend API. The React frontend handles UI state and triggers the checkout, while your backend verifies signatures, processes lifecycle events, and manages the subscription database.
React runs in the browser. Your Paystack secret key must never appear in browser code. This means every Paystack subscription flow splits into two parts:
This guide shows the React side in detail. For the backend, the code examples use a generic HTTP API. You can implement that backend in Express, Django, Laravel, or any other server framework.
The subscription flow from the customer perspective:
subscription.create webhook from Paystack.charge.success to your webhook.invoice.payment_failed, and your backend starts dunning.The Paystack Inline JS popup is the fastest way to collect the first subscription payment. Load the script and configure it with the plan code.
First, add the Paystack script to your index.html or load it dynamically:
<script src="https://js.paystack.co/v2/inline.js"></script>
Create a reusable hook for the Paystack popup:
// hooks/usePaystackSubscription.ts
import { useCallback } from "react";
declare global {
interface Window {
PaystackPop: any;
}
}
interface SubscriptionConfig {
email: string;
planCode: string;
amount: number; // in kobo, must match the plan amount
onSuccess: (reference: string) => void;
onClose: () => void;
}
export function usePaystackSubscription() {
const subscribe = useCallback((config: SubscriptionConfig) => {
const popup = new window.PaystackPop();
popup.checkout({
key: process.env.REACT_APP_PAYSTACK_PUBLIC_KEY,
email: config.email,
amount: config.amount,
plan: config.planCode,
onSuccess: (transaction: any) => {
config.onSuccess(transaction.reference);
},
onClose: () => {
config.onClose();
},
});
}, []);
return { subscribe };
}
The plan parameter is the key difference from a one-time payment. When you include it, Paystack treats the transaction as a subscription enrollment. The amount must match the plan amount exactly, or Paystack will reject it.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Your pricing page shows the available plans and lets customers subscribe. Fetch plans from your backend (which calls the Paystack API) and display them.
// components/PricingPage.tsx
import { useState, useEffect } from "react";
import { usePaystackSubscription } from "../hooks/usePaystackSubscription";
interface Plan {
plan_code: string;
name: string;
amount: number;
interval: string;
currency: string;
}
export function PricingPage({ userEmail }: { userEmail: string }) {
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(false);
const { subscribe } = usePaystackSubscription();
useEffect(() => {
fetch("/api/plans")
.then((res) => res.json())
.then((data) => setPlans(data.plans));
}, []);
function handleSubscribe(plan: Plan) {
setLoading(true);
subscribe({
email: userEmail,
planCode: plan.plan_code,
amount: plan.amount,
onSuccess: (reference) => {
// Verify the transaction on your backend
fetch("/api/verify-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reference: reference }),
}).then(() => {
setLoading(false);
// Navigate to dashboard or show success
window.location.href = "/dashboard";
});
},
onClose: () => {
setLoading(false);
},
});
}
function formatAmount(amount: number, currency: string) {
return currency + " " + (amount / 100).toLocaleString();
}
return (
<div>
<h1>Choose Your Plan</h1>
{plans.map((plan) => (
<div key={plan.plan_code}>
<h2>{plan.name}</h2>
<p>{formatAmount(plan.amount, plan.currency)} / {plan.interval}</p>
<button onClick={() => handleSubscribe(plan)} disabled={loading}>
Subscribe
</button>
</div>
))}
</div>
);
}
After the popup reports success, the onSuccess callback fires with the transaction reference. Send that reference to your backend for verification. Do not grant access based solely on the popup callback. The webhook is the authoritative signal.
Your backend receives all subscription lifecycle events from Paystack. The React frontend never sees these directly. Here is what each event means and what your backend should do:
subscription.create: A new subscription was created. Store the subscription code, plan code, customer code, and email token. The email token is critical for cancellation.
charge.success (with plan data): A renewal charge succeeded. Extend the customer access period. Update the subscription's current_period_end timestamp.
invoice.payment_failed: The renewal charge failed. Start your dunning sequence. Record the failure timestamp so you know when to escalate.
subscription.not_renew: The customer or your code cancelled the subscription. They still have access until the end of the current billing period. Set the status to "non_renewing."
subscription.disable: The subscription is fully stopped. Revoke access.
Your backend webhook handler (in pseudocode for any framework):
// POST /api/paystack/webhook
// 1. Read the raw request body
// 2. Verify the HMAC SHA-512 signature using your secret key
// 3. Parse the JSON
// 4. Check for idempotency (skip if you already processed this event reference)
// 5. Switch on event.event
// 6. Return 200 immediately
The React app polls your backend for subscription status changes, or you can use WebSockets or server-sent events if you want real-time updates in the UI.
Once a customer is subscribed, they need to see their subscription status, current plan, next billing date, and a button to cancel.
// components/SubscriptionManager.tsx
import { useState, useEffect } from "react";
interface Subscription {
id: string;
planName: string;
status: string;
currentPeriodEnd: string;
amount: number;
currency: string;
interval: string;
}
export function SubscriptionManager() {
const [subscription, setSubscription] = useState<Subscription | null>(null);
const [cancelling, setCancelling] = useState(false);
useEffect(() => {
fetch("/api/my-subscription")
.then((res) => res.json())
.then((data) => setSubscription(data.subscription));
}, []);
async function handleCancel() {
if (!window.confirm("Cancel your subscription? You will keep access until the end of your current period.")) {
return;
}
setCancelling(true);
const res = await fetch("/api/subscription/cancel", {
method: "POST",
});
if (res.ok) {
setSubscription((prev) =>
prev ? { ...prev, status: "non_renewing" } : null
);
}
setCancelling(false);
}
async function handleReactivate() {
const res = await fetch("/api/subscription/reactivate", {
method: "POST",
});
if (res.ok) {
setSubscription((prev) =>
prev ? { ...prev, status: "active" } : null
);
}
}
if (!subscription) {
return <div>Loading...</div>;
}
return (
<div>
<h2>Your Subscription</h2>
<p>Plan: {subscription.planName}</p>
<p>Status: {subscription.status}</p>
<p>Next billing: {new Date(subscription.currentPeriodEnd).toLocaleDateString()}</p>
{subscription.status === "active" && (
<button onClick={handleCancel} disabled={cancelling}>
Cancel Subscription
</button>
)}
{subscription.status === "non_renewing" && (
<div>
<p>Your subscription will end on {new Date(subscription.currentPeriodEnd).toLocaleDateString()}</p>
<button onClick={handleReactivate}>Reactivate</button>
</div>
)}
{subscription.status === "attention" && (
<div>
<p>Your last payment failed. Please update your card.</p>
<a href="/update-card">Update Payment Method</a>
</div>
)}
</div>
);
}
The /api/my-subscription endpoint returns the current user's subscription data from your database. The cancel and reactivate endpoints call the Paystack API on the backend using the stored subscription code and email token.
When a subscription charge fails, your backend receives the invoice.payment_failed webhook and updates the subscription status to "attention" in your database. The React app should detect this state and prompt the customer to take action.
// components/PaymentFailedBanner.tsx
import { useState, useEffect } from "react";
export function PaymentFailedBanner() {
const [manageLink, setManageLink] = useState("");
const [show, setShow] = useState(false);
useEffect(() => {
fetch("/api/my-subscription")
.then((res) => res.json())
.then((data) => {
if (data.subscription && data.subscription.status === "attention") {
setShow(true);
// Fetch the Paystack manage link from your backend
fetch("/api/subscription/manage-link")
.then((res) => res.json())
.then((linkData) => setManageLink(linkData.link));
}
});
}, []);
if (!show) return null;
return (
<div style={{ background: "#FEE2E2", padding: "16px", borderRadius: "8px" }}>
<p>Your last payment could not be processed. Please update your payment method to keep your subscription active.</p>
{manageLink && (
<a href={manageLink} target="_blank" rel="noopener noreferrer">
Update Payment Method
</a>
)}
</div>
);
}
Place this banner component at the top of your app layout so the customer sees it on every page. The manage link opens a Paystack-hosted page where the customer can enter new card details. You never handle card numbers.
When the customer updates their card and the next charge succeeds, your backend receives a charge.success webhook, clears the dunning state, and the React app hides the banner on the next data fetch.
If you prefer not to use the Paystack Popup (maybe for mobile browsers where popups are unreliable), you can use redirect checkout instead. Your backend initializes the transaction and returns the authorization URL, and React redirects the browser there.
// components/RedirectCheckout.tsx
import { useState } from "react";
export function RedirectCheckout({ planCode, email }: { planCode: string; email: string }) {
const [loading, setLoading] = useState(false);
async function handleSubscribe() {
setLoading(true);
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email,
planCode: planCode,
}),
});
const data = await res.json();
if (data.authorization_url) {
window.location.href = data.authorization_url;
} else {
setLoading(false);
alert("Something went wrong. Please try again.");
}
}
return (
<button onClick={handleSubscribe} disabled={loading}>
{loading ? "Redirecting..." : "Subscribe Now"}
</button>
);
}
The redirect approach is more reliable on mobile and works well when you want full-page checkout. The trade-off is that the customer leaves your site temporarily. After payment, Paystack redirects back to your callback URL with the transaction reference.
During development, use Paystack test mode. Test card 4084 0840 8408 4081 with any future expiry and CVV works for successful payments.
Test the full subscription lifecycle:
subscription.create webhook fires and your database updates.charge.success webhook fires.subscription.not_renew fires.For testing failed payments, you can create a plan with a daily interval and use a card that will fail on the second charge (check the Paystack documentation for failure simulation cards).
When switching to production:
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