Bonaventure OgetoBy Bonaventure Ogeto|

Verify Paystack Payments in React

React runs in the browser and cannot safely verify Paystack payments directly because verification requires your secret key. Instead, build a backend endpoint (Express, Fastify, or any server) that calls GET https://api.paystack.co/transaction/verify/:reference with your secret key. After Paystack Inline completes, your React component calls your backend endpoint with the reference, and your backend returns the verified status. Check that data.status is "success" and data.amount matches the expected order amount.

Why React Alone Cannot Verify Payments

React is a frontend library. It runs in the user's browser. Paystack's verify endpoint requires your secret key in the Authorization header, and that key must never appear in client-side code. If you put your secret key in a React component, anyone can open browser dev tools, read the key, and use it to access your Paystack account.

Beyond the security issue, there is a trust issue. Even if you could call the verify API from the browser, the browser is controlled by the user. A determined attacker could intercept the response and modify it to say "success" regardless of the actual status. Server-side verification removes the browser from the trust equation entirely.

The pattern for React applications is simple: your React frontend talks to your backend, and your backend talks to Paystack. The frontend never touches the Paystack API directly for verification.

This means you need a backend. It could be Express, Fastify, NestJS, Django, Laravel, Go, or any server that can make HTTP requests and respond to your React app. See Verify Paystack Payments in Node.js and Express for a detailed backend implementation.

The Complete Verification Flow

Here is the full flow from the customer clicking "Pay" to seeing a verified result:

  1. Your React app initializes a transaction through your backend. Your backend calls Paystack's Initialize Transaction endpoint and returns the access code or authorization URL to the frontend.
  2. The customer completes payment in the Paystack Inline popup (or is redirected to Paystack and back).
  3. The Paystack popup calls your onSuccess callback with the transaction reference.
  4. Your React component calls your backend verification endpoint with the reference.
  5. Your backend calls GET https://api.paystack.co/transaction/verify/:reference with your secret key.
  6. Paystack returns the transaction details including status and amount.
  7. Your backend checks the status and amount, updates the database, and returns the result to your React app.
  8. Your React component displays the result to the user.

The user sees "Verifying payment..." between steps 4 and 8. Keep this fast by having your backend respond quickly. If the Paystack API is slow, show a message saying verification may take a moment.

Building the Backend Verification Endpoint

Here is a minimal Express endpoint that your React app will call. If you use a different backend framework, the logic is the same:

// server/routes/paystack.js
const express = require('express');
const router = express.Router();

router.get('/verify', async (req, res) => {
  const reference = req.query.reference;

  if (!reference) {
    return res.status(400).json({ error: 'Reference is required' });
  }

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

    const payload = await response.json();

    if (!response.ok) {
      return res.status(response.status).json({
        error: payload.message || 'Verification failed',
      });
    }

    const { data } = payload;

    // TODO: Look up order in your database and compare amounts
    // const order = await Order.findOne({ reference });
    // if (data.amount !== order.expectedAmountInKobo) {
    //   return res.status(400).json({ error: 'Amount mismatch' });
    // }

    // TODO: Update order status idempotently
    // if (!order.paid) {
    //   order.paid = true;
    //   order.paidAt = data.paid_at;
    //   await order.save();
    // }

    return res.json({
      status: data.status,
      reference: data.reference,
      amount: data.amount,
      currency: data.currency,
      channel: data.channel,
      paidAt: data.paid_at,
    });
  } catch (error) {
    console.error('Verification error:', error);
    return res.status(500).json({ error: 'Verification service error' });
  }
});

module.exports = router;

Make sure your Express server has CORS configured to accept requests from your React app's origin. If your React app runs on localhost:3000 and your backend on localhost:4000, you need CORS middleware:

const cors = require('cors');
app.use(cors({ origin: 'http://localhost:3000' }));

In production, set the origin to your actual domain.

Calling the Verify Endpoint from React

After the Paystack popup closes successfully, call your backend to verify. Here is a React component that handles the full flow:

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

const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:4000';

export default function PaymentVerifier({ reference, onVerified }) {
  const [status, setStatus] = useState('verifying');
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function verify() {
      try {
        const response = await fetch(
          API_URL + '/api/paystack/verify?reference=' + encodeURIComponent(reference)
        );
        const result = await response.json();

        if (cancelled) return;

        if (response.ok && result.status === 'success') {
          setStatus('success');
          setData(result);
          if (onVerified) onVerified(result);
        } else if (result.status === 'pending') {
          setStatus('pending');
          setData(result);
        } else {
          setStatus('failed');
          setError(result.error || result.message || 'Payment verification failed');
        }
      } catch (err) {
        if (cancelled) return;
        setStatus('error');
        setError('Network error. Please check your connection.');
      }
    }

    verify();

    return () => {
      cancelled = true;
    };
  }, [reference, onVerified]);

  if (status === 'verifying') {
    return <div className="verification-status">Verifying your payment...</div>;
  }

  if (status === 'success') {
    return (
      <div className="verification-success">
        <h2>Payment Confirmed</h2>
        <p>Reference: {data.reference}</p>
        <p>Amount: {data.currency} {data.amount / 100}</p>
        <p>Paid via: {data.channel}</p>
      </div>
    );
  }

  if (status === 'pending') {
    return (
      <div className="verification-pending">
        <h2>Payment Processing</h2>
        <p>Your payment is being processed. You will receive confirmation shortly.</p>
      </div>
    );
  }

  return (
    <div className="verification-failed">
      <h2>Payment Not Confirmed</h2>
      <p>{error}</p>
    </div>
  );
}

The cancelled flag prevents state updates if the component unmounts before the fetch completes. This avoids the "can't perform a React state update on an unmounted component" warning. The onVerified callback lets the parent component respond to a successful verification, such as navigating to a receipt page.

Integrating with Paystack Inline Popup

Here is a complete checkout component that initializes payment through your backend, opens the Paystack popup, and verifies the result:

// src/components/Checkout.jsx
import { useState } from 'react';
import PaymentVerifier from './PaymentVerifier';

const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:4000';

export default function Checkout({ orderId, amount, email }) {
  const [reference, setReference] = useState(null);
  const [phase, setPhase] = useState('checkout'); // checkout | verifying | done

  async function handlePay() {
    // 1. Initialize transaction through your backend
    const initRes = await fetch(API_URL + '/api/paystack/initialize', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ orderId, amount, email }),
    });
    const initData = await initRes.json();

    if (!initRes.ok) {
      alert('Could not initialize payment: ' + initData.error);
      return;
    }

    // 2. Open Paystack Inline popup
    const handler = window.PaystackPop.setup({
      key: process.env.REACT_APP_PAYSTACK_PUBLIC_KEY,
      email: email,
      amount: amount, // in kobo
      ref: initData.reference,
      onClose: function () {
        // User closed popup without completing
        console.log('Payment popup closed');
      },
      callback: function (response) {
        // 3. Payment completed, now verify
        setReference(response.reference);
        setPhase('verifying');
      },
    });

    handler.openIframe();
  }

  if (phase === 'verifying' && reference) {
    return (
      <PaymentVerifier
        reference={reference}
        onVerified={() => setPhase('done')}
      />
    );
  }

  if (phase === 'done') {
    return <div>Thank you! Your order has been confirmed.</div>;
  }

  return (
    <div>
      <h2>Order Total: KES {amount / 100}</h2>
      <button onClick={handlePay}>Pay Now</button>
    </div>
  );
}

Notice that the public key is used for the popup (this is safe since it is a publishable key), but verification uses the secret key on the backend. The flow moves through three phases: checkout, verifying, and done.

Building a Custom usePaystackVerify Hook

If you verify payments in multiple places across your app, extract the logic into a custom hook:

// src/hooks/usePaystackVerify.js
import { useState, useCallback } from 'react';

const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:4000';

export function usePaystackVerify() {
  const [verifying, setVerifying] = useState(false);
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);

  const verify = useCallback(async (reference) => {
    setVerifying(true);
    setResult(null);
    setError(null);

    try {
      const response = await fetch(
        API_URL + '/api/paystack/verify?reference=' + encodeURIComponent(reference)
      );
      const data = await response.json();

      if (response.ok && data.status === 'success') {
        setResult(data);
      } else {
        setError(data.error || data.message || 'Verification failed');
      }

      return data;
    } catch (err) {
      const message = 'Network error during verification';
      setError(message);
      return { status: 'error', error: message };
    } finally {
      setVerifying(false);
    }
  }, []);

  return { verify, verifying, result, error };
}

// Usage in a component:
// const { verify, verifying, result, error } = usePaystackVerify();
// After popup: const data = await verify(response.reference);

This hook gives you a clean interface: call verify(reference) and use the verifying, result, and error states to render your UI. The useCallback ensures the verify function has a stable reference across re-renders.

Handling Pending Payments (Mobile Money, Bank Transfer, USSD)

Card payments resolve instantly, but mobile money, bank transfer, and USSD payments can take time. When your backend returns a "pending" status, your React component should tell the user to wait and offer to check again:

// In your verification component
function PendingPayment({ reference }) {
  const [checking, setChecking] = useState(false);
  const [result, setResult] = useState(null);
  const API_URL = process.env.REACT_APP_API_URL;

  async function checkAgain() {
    setChecking(true);
    try {
      const res = await fetch(
        API_URL + '/api/paystack/verify?reference=' + encodeURIComponent(reference)
      );
      const data = await res.json();
      setResult(data);
    } catch (e) {
      // silent fail, user can try again
    }
    setChecking(false);
  }

  if (result && result.status === 'success') {
    return <div>Payment confirmed!</div>;
  }

  return (
    <div>
      <h2>Payment Processing</h2>
      <p>
        Your payment is being processed. This can take a few minutes for
        bank transfers and mobile money.
      </p>
      <button onClick={checkAgain} disabled={checking}>
        {checking ? 'Checking...' : 'Check Status'}
      </button>
    </div>
  );
}

Do not poll automatically every few seconds. Let the user click "Check Status" when they want to. Automatic polling burns API calls and annoys users. In the background, your webhook handler will update the order when payment clears, so even if the user leaves the page, their order will be fulfilled.

Security and Environment Configuration

A few security rules for React + Paystack integrations:

Public key in the frontend, secret key on the backend. Your React app uses REACT_APP_PAYSTACK_PUBLIC_KEY for the Inline popup. Your backend uses PAYSTACK_SECRET_KEY for verification. Never swap these.

Environment variables in Create React App. Only variables prefixed with REACT_APP_ are embedded in the build. Your PAYSTACK_SECRET_KEY should NOT have this prefix, so it is never included in the frontend bundle.

CORS on your backend. Restrict the Access-Control-Allow-Origin header to your React app's domain. Do not use * in production.

HTTPS everywhere. Both your React app and your backend should serve over HTTPS in production. Payment data must not travel over unencrypted connections.

Rate limiting. Add rate limiting to your backend verification endpoint. Without it, an attacker could enumerate valid references by brute-forcing your endpoint. A limit of 10 requests per minute per IP is reasonable for a verification endpoint.

Key Takeaways

  • React cannot verify payments on its own. Verification requires your Paystack secret key, which must never be in browser code.
  • Build a backend endpoint that calls the Paystack verify API and returns the result. Your React app calls this backend endpoint.
  • After the Paystack Inline popup closes with onSuccess, call your backend with the transaction reference before showing a success screen.
  • Always check both status and amount on the backend. A "success" status with the wrong amount means the customer underpaid.
  • Handle loading, success, failure, and pending states in your React component to give users clear feedback.
  • Make your backend verification idempotent so that multiple calls for the same reference do not cause double-granting.

Frequently Asked Questions

Can I call the Paystack verify API directly from React using the public key?
No. The verify endpoint requires your secret key. The public key is only for initializing transactions and the Inline popup. If you try to verify with the public key, Paystack will reject the request.
What backend should I use with React for Paystack verification?
Any backend that can make HTTP requests works. Express (Node.js) is the most common choice for React developers. You can also use Django, FastAPI, Laravel, Go, or any server. The verification logic is the same regardless of backend language.
Should I verify in the Paystack onSuccess callback or on a callback page?
If you use Paystack Inline (popup), verify in the onSuccess callback by calling your backend. If you use redirect checkout, verify on the callback page when the user returns. Either way, verification happens on your backend, not in the browser.
What if the user closes the browser after paying but before verification completes?
The verification will not complete, but your webhook handler will still receive the charge.success event from Paystack. This is why you need both callback verification (for immediate UI feedback) and webhook handling (for reliable background processing).
How do I test Paystack verification in development?
Use your Paystack test keys. Test transactions can be verified the same way as live transactions. Paystack provides test card numbers that produce predictable results. Point your React app at your local backend, and use a tool like ngrok if your webhook URL needs to be public.

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