Bonaventure OgetoBy Bonaventure Ogeto|

Build Paystack Subscriptions in React

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.

Architecture: React Frontend + Backend API

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:

  • React (frontend): Renders the pricing page, triggers the Paystack Popup for checkout, shows subscription status, and provides cancel/reactivate buttons.
  • Backend API (any server): Creates plans, initializes transactions, verifies payments, handles webhooks, manages subscription state, and processes dunning.

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:

  1. Customer selects a plan on your React pricing page.
  2. React opens the Paystack Popup with the plan code (or redirects to a Paystack checkout URL).
  3. Customer pays. The popup closes with a success callback.
  4. Your backend receives a subscription.create webhook from Paystack.
  5. On each renewal cycle, Paystack charges the saved card and sends charge.success to your webhook.
  6. If a charge fails, Paystack sends invoice.payment_failed, and your backend starts dunning.

Setting Up Paystack Popup for Subscriptions

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.

Building the Pricing Page

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.

Backend Webhook Handling

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.

Subscription Management UI in React

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.

Handling Failed Payments in the UI

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.

Using Redirect Checkout Instead of Popup

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.

Testing and Going Live

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:

  1. Subscribe using the popup or redirect flow.
  2. Verify the subscription.create webhook fires and your database updates.
  3. In the Paystack Dashboard, find the test subscription and trigger a manual renewal.
  4. Verify the charge.success webhook fires.
  5. Cancel the subscription from your React UI. Verify subscription.not_renew fires.
  6. Reactivate and verify it works.

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:

  • Replace test keys with live keys in your environment variables.
  • Update the webhook URL in the Paystack Dashboard to your production server.
  • Verify the popup loads correctly with the live public key.
  • Send a test webhook from the Dashboard to confirm your production webhook handler processes it.

Key Takeaways

  • React handles the frontend subscription UI and Paystack Popup checkout. All secret-key operations happen on your backend.
  • Initialize the Paystack Popup with a plan code to automatically create a subscription after the first successful charge.
  • Your backend must handle webhooks for subscription.create, charge.success, invoice.payment_failed, and subscription.disable events.
  • Never call the Paystack API with your secret key from React. Always proxy through your backend.
  • Store the email_token from the subscription.create webhook to enable programmatic cancellation and reactivation.
  • Build dunning by listening for invoice.payment_failed and sending customers a Paystack manage link to update their card.

Frequently Asked Questions

Can I use react-paystack npm package for subscriptions?
The react-paystack package wraps the Paystack Inline JS popup and supports the plan parameter. You can use it for subscription checkout. However, make sure you pass the plan code in the configuration. The subscription creation, webhooks, and lifecycle management still happen entirely on your backend.
How do I show the subscription status in real time after payment?
The Paystack Popup onSuccess callback fires immediately after payment, but the subscription.create webhook may arrive a few seconds later. After the popup closes, either poll your backend for the subscription status, or show an optimistic "Processing your subscription" message and redirect to a page that checks the status via your backend API.
Can I change the subscription plan from the React UI?
Paystack does not support in-place plan changes. To upgrade or downgrade, cancel the current subscription from your backend and create a new one on the new plan. In your React UI, guide the customer through this: show the new plan, cancel the old subscription behind the scenes, and start a new checkout for the new plan.
Should I verify the transaction on the frontend or the backend after popup success?
Always verify on the backend. The popup callback tells you the transaction reference, but it runs in the browser where it can be tampered with. Send the reference to your backend, which calls Paystack verify endpoint with the secret key, confirms the amount and status, and then grants access.
How do I handle the case where the popup closes but the webhook has not arrived yet?
This is the webhook-redirect race condition. When the popup closes with success, show the customer a confirmation page. Your backend should be designed so that either the webhook or the verification call (whichever arrives first) creates the subscription record. Use the transaction reference as the idempotency key to prevent double-creation.

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