Paystack does not redirect customers after payment for three main reasons: you did not set a callback_url when initializing the transaction, you are using the popup (Inline.js) mode which uses a JavaScript callback instead of a URL redirect, or the browser is blocking the redirect due to popup blockers or CSP rules. For redirect mode, include callback_url in your /transaction/initialize request. For popup mode, handle the redirect yourself in the callback function. If you set a callback_url in the dashboard but not in the API call, the API call takes precedence.
After a successful payment, the customer expects to land back on your site (a thank-you page, a receipt, a dashboard). Instead, one of these happens:
The root cause depends on which Paystack checkout mode you are using. Let's look at both.
In redirect mode, you initialize a transaction via the API, get an authorization_url, and send the customer to that URL. After payment, Paystack redirects the customer to your callback_url.
The problem: if you do not include callback_url in your API request, Paystack does not know where to send the customer back.
// BROKEN: no callback_url
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'customer@example.com',
amount: 500000, // 5,000 NGN in kobo
// callback_url is missing!
}),
});
The fix: Add callback_url to your initialization request.
// FIXED: callback_url included
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'customer@example.com',
amount: 500000,
callback_url: 'https://yoursite.com/payment/verify',
// Use your production domain, not localhost
}),
});
const data = await response.json();
// Redirect the customer to data.data.authorization_url
// After payment, Paystack redirects to:
// https://yoursite.com/payment/verify?trxref=abc123&reference=abc123
You can also set a default callback URL in your Paystack dashboard under Settings > API Keys & Webhooks. But the URL in the API request always takes priority. If you set different URLs in the dashboard and the API request, the API request wins.
Important: The callback_url must be a full URL with the protocol. yoursite.com/payment/verify will not work. It must be https://yoursite.com/payment/verify.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
In popup mode (Inline.js / PaystackPop), there is no automatic redirect. The popup opens, the customer pays, the popup closes, and your callback function runs. If you do not redirect the customer in that function, they stay on the same page.
// BROKEN: callback function does nothing useful
const handler = PaystackPop.setup({
key: 'pk_test_xxxxx',
email: 'customer@example.com',
amount: 500000,
callback: function(response) {
console.log('Payment complete:', response.reference);
// Customer stays on the same page. No redirect.
},
onClose: function() {
console.log('Popup closed');
},
});
handler.openIframe();
The fix: Redirect the customer in the callback function.
// FIXED: redirect after payment
const handler = PaystackPop.setup({
key: 'pk_test_xxxxx',
email: 'customer@example.com',
amount: 500000,
callback: function(response) {
// Redirect to your verification/thank-you page
window.location.href = `/payment/verify?reference=${response.reference}`;
},
onClose: function() {
// Customer closed the popup without completing payment
alert('Payment was not completed. Please try again.');
},
});
handler.openIframe();
In React or Next.js, use the router instead of window.location:
// React with react-router
import { useNavigate } from 'react-router-dom';
function CheckoutButton() {
const navigate = useNavigate();
const handlePayment = () => {
const handler = PaystackPop.setup({
key: 'pk_test_xxxxx',
email: 'customer@example.com',
amount: 500000,
callback: (response) => {
navigate(`/payment/verify?reference=${response.reference}`);
},
onClose: () => {
// Optionally show a message
},
});
handler.openIframe();
};
return <button onClick={handlePayment}>Pay Now</button>;
}
The callback function receives an object with a reference property. Pass this reference to your server to verify the payment.
Even with the correct code, browsers can block the popup or redirect in several ways:
Popup blockers: If you open the Paystack popup from a timer, an async callback, or anything that is not a direct user click, the browser treats it as a popup and blocks it. The popup never opens, so the callback never fires, and no redirect happens.
// BROKEN: popup opened from setTimeout, not a direct click
setTimeout(() => {
const handler = PaystackPop.setup({ /* ... */ });
handler.openIframe(); // Browser may block this
}, 1000);
// BROKEN: popup opened from an async chain
async function pay() {
const userData = await fetchUserData(); // async operation
const handler = PaystackPop.setup({
email: userData.email,
// ...
});
handler.openIframe(); // May be blocked because it is not a direct click
}
The fix: Always open the popup directly from a click handler. If you need to fetch data first, fetch it before the click or show a loading state.
// FIXED: popup opens directly from click
function handleClick() {
// All setup is synchronous from the click event
const handler = PaystackPop.setup({
key: 'pk_test_xxxxx',
email: document.getElementById('email').value,
amount: 500000,
callback: (response) => {
window.location.href = `/payment/verify?reference=${response.reference}`;
},
});
handler.openIframe(); // Direct from click, browser allows it
}
document.getElementById('pay-button').addEventListener('click', handleClick);
CSP frame-src blocking: If your Content Security Policy blocks standard.paystack.co in the frame-src directive, the popup iframe will not load. See Paystack Checkout Blocked by Content Security Policy for the fix.
Browser extensions: Some ad blockers and privacy extensions block third-party iframes. There is not much you can do about this except to offer redirect mode as a fallback. Redirect mode opens a full Paystack page instead of an iframe, so extensions are less likely to block it.
When Paystack redirects to your callback URL, it appends the transaction reference as query parameters. Your callback page must read these parameters and verify the payment server-side.
// Example callback page: /payment/verify
// Server-side handler (Express)
router.get('/payment/verify', async (req, res) => {
const reference = req.query.reference || req.query.trxref;
if (!reference) {
return res.status(400).send('No payment reference provided');
}
// Verify the transaction with Paystack
const paystackResponse = await fetch(
`https://api.paystack.co/transaction/verify/${reference}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await paystackResponse.json();
if (data.data.status === 'success') {
// Payment confirmed. Grant access, send receipt, update database.
const amountPaid = data.data.amount / 100; // Convert from kobo
const customerEmail = data.data.customer.email;
// Your business logic here
await grantAccess(customerEmail);
res.render('payment-success', {
amount: amountPaid,
reference: reference,
});
} else {
// Payment failed or is still pending
res.render('payment-failed', {
status: data.data.status,
message: data.data.gateway_response,
});
}
});
Critical: never trust the redirect alone. A customer could manually type your callback URL into their browser without paying. Always verify the transaction reference with Paystack's API before granting access.
Common callback URL mistakes:
localhost in production: http://localhost:3000/payment/verify will not work for real usersyoursite.com/verify is not a valid URL. Use https://yoursite.com/verify/verify and /verify/ differently, make sure the callback URL matchesPaystack lets you set a callback URL in two places:
callback_url field in the /transaction/initialize request body. This overrides the dashboard setting for that specific transaction.Priority: API request > Dashboard setting. If both are set, the API request wins.
This can cause confusion. If your dashboard has https://yoursite.com/old-callback and your API sends https://yoursite.com/new-callback, the customer goes to /new-callback. If your API does not send a callback_url, the customer goes to /old-callback.
Recommendation: set the callback_url in every API request. Do not rely on the dashboard setting. This makes your code self-documenting and prevents surprises when someone changes the dashboard setting.
// Set callback_url explicitly in every request
const callbackUrl = process.env.NODE_ENV === 'production'
? 'https://yoursite.com/payment/verify'
: 'http://localhost:3000/payment/verify';
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'customer@example.com',
amount: 500000,
callback_url: callbackUrl,
}),
});
Choose the right mode to avoid redirect issues entirely:
Use redirect mode when:
Use popup mode when:
You can also offer both: start with popup mode, and fall back to redirect mode if the popup fails to open. This covers edge cases where popup blockers or extensions interfere.
function initiatePayment(email, amount) {
try {
// Try popup mode first
const handler = PaystackPop.setup({
key: 'pk_test_xxxxx',
email,
amount,
callback: (response) => {
window.location.href = `/payment/verify?reference=${response.reference}`;
},
});
handler.openIframe();
} catch (error) {
// Fallback: initialize via server and redirect
fetch('/api/payments/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, amount }),
})
.then(res => res.json())
.then(data => {
window.location.href = data.authorization_url;
});
}
}
Test the full redirect flow end to end:
Step 1: Test with a test card.
Use Paystack test card 4084 0840 8408 4081 with any future expiry date and any CVV. Complete the payment. Confirm you are redirected to your callback URL with the reference and trxref query parameters.
Step 2: Check the callback URL receives the right parameters.
// In your callback route, log what you receive
router.get('/payment/verify', (req, res) => {
console.log('Query params:', req.query);
// Should log: { trxref: 'abc123', reference: 'abc123' }
// Both values are the same transaction reference
// Continue with verification...
});
Step 3: Test the popup callback.
// Temporarily add logging to your popup callback
callback: (response) => {
console.log('Popup callback fired with:', response);
// Should log: { reference: 'abc123', trans: '...', ... }
// Then redirect
window.location.href = `/payment/verify?reference=${response.reference}`;
},
Step 4: Test with a failed payment.
Use test card 4084 0840 8408 4081 with CVV 400 to simulate a declined card. Confirm your callback page handles the failed status gracefully instead of showing a success message.
Step 5: Test the onClose handler.
Open the popup and close it without paying. Confirm your onClose handler fires and shows an appropriate message to the customer.
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