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.
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:
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.
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
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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).
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>;
}
Before going live, verify these points:
cors({ origin: '*' }).http://localhost:4000 with your production backend URL. Use an environment variable in your React app.sk_test_ to sk_live_ in your production environment. Never hardcode keys in your source code.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