Bonaventure OgetoBy Bonaventure Ogeto|

Accept Payments with Paystack in React

A React SPA cannot call the Paystack API directly because it requires a secret key. Create a backend API (Express, Fastify, or any server) that initializes transactions and verifies payments. Your React frontend calls your backend, gets the authorization URL or access code, and either redirects to Paystack or opens the inline popup.

How React and Paystack Fit Together

React is a frontend library. It runs in the user's browser. Paystack's API requires a secret key for initializing and verifying transactions. You cannot put a secret key in browser code because anyone can view it using browser dev tools.

The architecture looks like this:

  1. Your React frontend collects the customer's email and displays the amount.
  2. React calls your backend API with the email and amount.
  3. Your backend calls Paystack's Initialize Transaction endpoint with your secret key and returns the access code or authorization URL to React.
  4. React either redirects to the authorization URL or opens the Paystack inline popup with the access code.
  5. After payment, React calls your backend to verify the transaction.

This guide uses Express.js for the backend examples, but any backend works: Fastify, Koa, Django, FastAPI, Rails, Go, or anything that can make HTTP requests and serve JSON.

Set Up the Backend API

Create a minimal Express server with two endpoints: one to initialize and one to verify.

// server.js
const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors({ origin: 'http://localhost:3000' }));
app.use(express.json());

const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;

// Initialize a transaction
app.post('/api/pay', async (req, res) => {
  const { email, amount } = req.body;
  const reference = 'order_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);

  const response = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + PAYSTACK_SECRET,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email,
      amount: Math.round(amount * 100),
      reference,
    }),
  });

  const data = await response.json();

  if (!data.status) {
    return res.status(400).json({ error: data.message });
  }

  // TODO: Save reference and expected amount to your database

  res.json({
    authorization_url: data.data.authorization_url,
    access_code: data.data.access_code,
    reference: data.data.reference,
  });
});

// Verify a transaction
app.get('/api/verify', async (req, res) => {
  const { reference } = req.query;

  const response = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    {
      headers: { Authorization: 'Bearer ' + PAYSTACK_SECRET },
    }
  );

  const data = await response.json();

  if (data.status && data.data.status === 'success') {
    // TODO: Check amount and currency against your database
    // TODO: Mark order as paid (idempotently)

    return res.json({
      success: true,
      amount: data.data.amount / 100,
      currency: data.data.currency,
    });
  }

  res.status(400).json({ success: false });
});

app.listen(4000, () => console.log('Server running on port 4000'));

Install the dependencies:

npm install express cors

Run the server with your secret key:

PAYSTACK_SECRET_KEY=sk_test_xxx node server.js

Inline Popup Checkout in React

The inline popup is the most popular approach for React apps. The customer stays on your page, and Paystack's checkout opens as an overlay.

First, load the Paystack inline script. You can add it to your index.html or load it dynamically in a component:

// src/PaystackCheckout.jsx
import { useState, useEffect } from 'react';

function PaystackCheckout() {
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const script = document.createElement('script');
    script.src = 'https://js.paystack.co/v2/inline.js';
    script.async = true;
    document.head.appendChild(script);
    return () => { document.head.removeChild(script); };
  }, []);

  async function handlePay() {
    setLoading(true);

    try {
      const res = await fetch('http://localhost:4000/api/pay', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, amount: 5000 }),
      });

      const data = await res.json();

      if (!data.access_code) {
        alert(data.error || 'Initialization failed');
        setLoading(false);
        return;
      }

      const popup = new window.PaystackPop();
      popup.checkout({
        accessCode: data.access_code,
        onSuccess: async (transaction) => {
          // Verify on the server
          const verifyRes = await fetch(
            'http://localhost:4000/api/verify?reference=' + transaction.reference
          );
          const result = await verifyRes.json();

          if (result.success) {
            alert('Payment confirmed! ' + result.currency + ' ' + result.amount);
          } else {
            alert('Verification failed. Contact support.');
          }
          setLoading(false);
        },
        onCancel: () => {
          console.log('Payment cancelled');
          setLoading(false);
        },
      });
    } catch (err) {
      alert('Network error. Please try again.');
      setLoading(false);
    }
  }

  return (
    <div style={{ maxWidth: 400, margin: '80px auto' }}>
      <h1>Checkout</h1>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        style={{ width: '100%', padding: 8, marginBottom: 16 }}
      />
      <p>Amount: NGN 5,000</p>
      <button onClick={handlePay} disabled={loading || !email}>
        {loading ? 'Processing...' : 'Pay Now'}
      </button>
    </div>
  );
}

export default PaystackCheckout;

The flow: user clicks "Pay Now," your React app calls your Express backend, your backend calls Paystack and returns an access code, React opens the Paystack popup with that access code. After the customer pays, the onSuccess callback fires, and you verify on your backend before showing a confirmation.

Redirect Checkout

If you prefer the full-page redirect approach (simpler, but the customer leaves your React app), use the authorization URL instead of the access code:

async function handleRedirectPay() {
  const res = await fetch('http://localhost:4000/api/pay', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, amount: 5000 }),
  });

  const data = await res.json();

  if (data.authorization_url) {
    window.location.href = data.authorization_url;
  } else {
    alert(data.error || 'Failed to initialize payment');
  }
}

After payment, Paystack redirects to your callback URL with ?reference=REF. If your React app uses React Router, create a route for the callback path and verify the reference on mount:

// src/PaymentCallback.jsx
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';

function PaymentCallback() {
  const [searchParams] = useSearchParams();
  const [status, setStatus] = useState('verifying');
  const reference = searchParams.get('reference') || searchParams.get('trxref');

  useEffect(() => {
    if (!reference) {
      setStatus('error');
      return;
    }

    fetch('http://localhost:4000/api/verify?reference=' + reference)
      .then((res) => res.json())
      .then((data) => {
        setStatus(data.success ? 'success' : 'failed');
      })
      .catch(() => setStatus('error'));
  }, [reference]);

  if (status === 'verifying') return <p>Verifying payment...</p>;
  if (status === 'success') return <h1>Payment Successful!</h1>;
  return <h1>Payment Failed. Please try again.</h1>;
}

export default PaymentCallback;

Set the callback URL in your backend initialization to point to this route (for example, http://localhost:3000/payment/callback).

Create a Custom usePaystack Hook

If you accept payments on multiple pages, extract the logic into a reusable hook:

// src/hooks/usePaystack.js
import { useEffect, useRef } from 'react';

export function usePaystack() {
  const scriptLoaded = useRef(false);

  useEffect(() => {
    if (scriptLoaded.current) return;
    const script = document.createElement('script');
    script.src = 'https://js.paystack.co/v2/inline.js';
    script.async = true;
    script.onload = () => { scriptLoaded.current = true; };
    document.head.appendChild(script);
  }, []);

  async function pay({ email, amount, onSuccess, onCancel }) {
    const res = await fetch('http://localhost:4000/api/pay', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, amount }),
    });

    const data = await res.json();

    if (!data.access_code) {
      throw new Error(data.error || 'Initialization failed');
    }

    const popup = new window.PaystackPop();
    popup.checkout({
      accessCode: data.access_code,
      onSuccess: async (transaction) => {
        const verifyRes = await fetch(
          'http://localhost:4000/api/verify?reference=' + transaction.reference
        );
        const result = await verifyRes.json();
        if (result.success) {
          onSuccess(result);
        } else {
          throw new Error('Verification failed');
        }
      },
      onCancel: onCancel || (() => {}),
    });

    return data.reference;
  }

  return { pay };
}

Usage in any component:

import { usePaystack } from './hooks/usePaystack';

function BuyButton({ email, amount, productName }) {
  const { pay } = usePaystack();

  async function handleClick() {
    try {
      await pay({
        email,
        amount,
        onSuccess: (result) => {
          alert('Paid ' + result.currency + ' ' + result.amount + ' for ' + productName);
        },
      });
    } catch (err) {
      alert(err.message);
    }
  }

  return <button onClick={handleClick}>Buy {productName}</button>;
}

Security and Production Notes

Before going live, verify these points:

  • CORS. In production, restrict your backend CORS policy to your React app's domain. Do not use cors({ origin: '*' }).
  • Backend URL. Replace http://localhost:4000 with your production backend URL. Use an environment variable in your React app.
  • HTTPS. Both your React app and your backend must use HTTPS in production. Paystack requires HTTPS for callback URLs.
  • Amount validation. Validate the amount on the backend. Do not trust the amount sent from the frontend. Look up the correct amount from your database based on the product or order ID.
  • Webhooks. Set up a webhook endpoint on your backend. The popup callback and redirect callback can both fail if the customer closes their browser. Webhooks are the only reliable delivery mechanism.
  • Live keys. Switch from sk_test_ to sk_live_ in your production environment. Never hardcode keys in your source code.

Ship Payments Faster

This guide is part of the Paystack integration guides by language and framework series. We cover webhooks, verification, subscriptions, and more for every major stack.

Sign up for the McTaba newsletter

Key Takeaways

  • React runs in the browser. You cannot use your Paystack secret key in React code. All Paystack API calls must go through your backend.
  • The inline popup (PaystackPop) is the most common pattern for React apps. It keeps the user on your page while Paystack handles the checkout UI.
  • After the popup closes with a success callback, verify the transaction on your backend before granting value. The popup callback is not proof of payment.
  • Amounts are in kobo (NGN), pesewas (GHS), or cents (ZAR/KES/USD). Convert before sending to your backend or let the backend handle the conversion.
  • Use a loading state on the pay button to prevent double-clicks. Combine with a deterministic transaction reference to prevent duplicate charges.
  • Set up webhooks on your backend. They fire even when the popup is closed or the browser crashes mid-payment.

Frequently Asked Questions

Do I need react-paystack or paystack-inline-js npm packages?
No. Community packages like react-paystack wrap the Paystack inline script, but they add a dependency you do not control. Loading the official Paystack inline script from the CDN and calling it directly gives you the same functionality with less abstraction. If you prefer a package for convenience, that is fine, but it is not required.
Can I initialize a Paystack transaction directly from React without a backend?
No. The Initialize Transaction endpoint requires your secret key in the Authorization header. If you put that key in React code, anyone who opens browser dev tools can see it and make charges against your account. You must use a backend.
How do I handle the popup closing unexpectedly?
The onCancel callback fires when the customer closes the popup before completing payment. If the popup is closed after the customer authorized the charge (entered PIN/OTP), the payment may still complete on Paystack side. Your webhook handler will catch these cases and fulfill the order. Never rely solely on the popup callbacks.
What backend should I use with React for Paystack?
Any backend that can make HTTP requests and serve JSON works. Express.js and Fastify are popular choices for JavaScript developers. If your team uses Python, Django or FastAPI work great. For PHP shops, Laravel is common. The Paystack API is language-agnostic. Pick whatever your team already knows.
Can I test Paystack in React without real money?
Yes. Use test keys (sk_test_ and pk_test_). Paystack provides test card numbers for simulating successful and failed transactions. No real money moves when using test keys. Switch to live keys only when you deploy to production.

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