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.
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.
Here is the full flow from the customer clicking "Pay" to seeing a verified result:
onSuccess callback with the transaction reference.https://api.paystack.co/transaction/verify/:reference with your secret key.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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
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.
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.
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.
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.
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