After a successful payment, Paystack redirects the customer to your callback URL and sends a webhook to your server. These are independent events. The redirect can arrive before the webhook, leaving your callback page showing a pending order even though payment succeeded. Fix this with the verify-on-redirect pattern: when the callback page loads, call the Paystack Verify API to check the transaction status instead of relying on the webhook having been processed.
Here is what happens when a customer completes a payment on Paystack:
charge.success event.https://yoursite.com/payment/verify?reference=ref-001).These two requests travel different paths. The webhook goes server-to-server (Paystack's servers to your servers). The redirect goes through the customer's browser (Paystack's page tells the browser to navigate to your URL). They can arrive in any order.
On a fast day, the webhook arrives in 200ms and the redirect takes 1 second (the browser needs to close the payment page, navigate, establish a new TLS connection, and load your page). The webhook wins. Your callback page checks the database, sees the order is "paid," and shows a success message. Everyone is happy.
On a slow day, your webhook handler is under load. The webhook request sits in a queue or takes 3 seconds to process. The redirect arrives in 800ms. The customer's browser loads your callback page, which checks the database and finds the order still "pending." The customer just paid, and your page says "processing." The customer panics, tries to pay again, or contacts support.
This is the race condition. It is not a bug in Paystack. It is an inherent property of two independent asynchronous processes.
This article is part of the Paystack webhooks engineering guide.
Several factors make it common for the redirect to arrive before the webhook has finished processing:
Your webhook handler queues work. If you follow the respond-first-then-queue pattern (which you should), the webhook endpoint returns 200 immediately and pushes the event to a background queue. The queue worker picks it up and processes it. There is a delay between "event received" and "database updated." During that delay, the redirect arrives and the database still shows "pending."
Your webhook handler is slow under load. Even if you process inline (without a queue), database writes take time. Under load, a query that normally takes 50ms might take 2 seconds. The redirect does not wait for your webhook handler to finish.
The browser redirect is fast on mobile networks. Modern mobile browsers handle redirects efficiently. The customer's phone might already be loading your callback page while Paystack's webhook is still in transit to your server.
Serverless cold starts. If your webhook handler runs on a serverless platform (Vercel, Lambda, Cloudflare Workers), a cold start adds seconds to the first request. The redirect hits a warm server (your frontend), while the webhook hits a cold function. See the Vercel and AWS Lambda guides for more on this.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
This is the recommended approach. When the callback page loads, call the Paystack Verify API to get the definitive status of the transaction. Do not rely on your database state, because the webhook might not have updated it yet.
// pages/payment/verify.js (Next.js example)
export default function PaymentVerifyPage() {
const searchParams = useSearchParams();
const reference = searchParams.get('reference');
const [status, setStatus] = useState('checking');
useEffect(function() {
if (!reference) {
setStatus('error');
return;
}
// Call your backend, which calls Paystack Verify
fetch('/api/verify-payment?reference=' + encodeURIComponent(reference))
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.status === 'success') {
setStatus('success');
} else if (data.status === 'failed') {
setStatus('failed');
} else {
setStatus('pending');
}
})
.catch(function() {
setStatus('error');
});
}, [reference]);
if (status === 'checking') return <p>Verifying your payment...</p>;
if (status === 'success') return <p>Payment confirmed! Thank you.</p>;
if (status === 'failed') return <p>Payment failed. Please try again.</p>;
if (status === 'pending') return <p>Payment is still processing...</p>;
return <p>Something went wrong. Contact support.</p>;
}
The backend endpoint:
// api/verify-payment.js
export default async function handler(req, res) {
const reference = req.query.reference;
// First check your database (webhook might have already processed it)
const order = await db.query(
'SELECT status FROM orders WHERE payment_reference = $1',
[reference]
);
if (order.rows.length > 0 && order.rows[0].status === 'paid') {
return res.json({ status: 'success' });
}
// Database not updated yet. Verify with Paystack directly.
const paystackRes = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
'Authorization': 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
const paystackData = await paystackRes.json();
if (paystackData.data && paystackData.data.status === 'success') {
// Payment succeeded. Update the order now instead of waiting for the webhook.
await db.query(
'UPDATE orders SET status = $1, paid_at = NOW() WHERE payment_reference = $2 AND status = $3',
['paid', reference, 'pending']
);
return res.json({ status: 'success' });
}
return res.json({ status: paystackData.data ? paystackData.data.status : 'unknown' });
}
This approach has two layers:
The WHERE status = $3 condition in the UPDATE prevents a race with the webhook handler. If the webhook processes the event between your SELECT and UPDATE, the WHERE clause ensures you do not overwrite whatever the webhook handler did.
If you prefer not to call the Paystack Verify API from the callback page, you can poll your own database until the webhook has been processed:
// Client-side polling
function pollOrderStatus(reference, maxAttempts) {
var attempts = 0;
function check() {
attempts++;
fetch('/api/order-status?reference=' + encodeURIComponent(reference))
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.status === 'paid') {
showSuccess();
} else if (data.status === 'failed') {
showFailure();
} else if (attempts < maxAttempts) {
// Still pending. Wait and try again.
setTimeout(check, 2000); // Poll every 2 seconds
} else {
// Max attempts reached. Show a fallback message.
showFallback(
'Your payment is being processed. ' +
'You will receive a confirmation email shortly.'
);
}
})
.catch(function() {
if (attempts < maxAttempts) {
setTimeout(check, 2000);
}
});
}
check();
}
// Start polling when the page loads
pollOrderStatus(referenceFromUrl, 15); // Poll up to 15 times (30 seconds)
This approach is simpler on the backend (no Paystack API call), but worse for the user. They see a loading spinner for 2 to 10 seconds while the polling happens. With the verify-on-redirect pattern, they see the result immediately.
Polling also adds load to your server: 15 requests over 30 seconds for every completed payment. For high-traffic applications, this adds up. The verify-on-redirect pattern is one request.
If you use Paystack Inline (the popup checkout), the race condition is between the onSuccess JavaScript callback and the server-side webhook.
// The onSuccess callback fires in the browser
var handler = PaystackPop.setup({
key: 'pk_test_xxxxxxxx',
email: 'customer@example.com',
amount: 50000,
ref: 'order-ref-001',
onSuccess: function(response) {
// This fires when the customer completes payment.
// The webhook may or may not have been processed yet.
window.location.href = '/order/success?reference=' + response.reference;
},
});
handler.openIframe();
When onSuccess fires, the browser navigates to your success page. If that page checks the database, it might find the order still "pending" because the webhook has not been processed yet.
The fix is the same: verify on redirect. In the onSuccess callback, redirect to a page that calls the Paystack Verify API before showing the result. See Paystack Inline explained for the full popup integration.
onSuccess: function(response) {
// Redirect to verification page, not a static success page
window.location.href = '/payment/verify?reference=' + response.reference;
},
If the verify-on-redirect pattern confirms the payment and updates the database, do you still need the webhook? Yes. Absolutely yes.
The redirect only happens when the customer's browser is open and functional. Here are scenarios where the redirect never arrives:
In all of these cases, the payment succeeds at Paystack, but your callback page never loads. If you relied on the callback to update the order, the order stays "pending" forever. The webhook is the safety net. It arrives server-to-server regardless of what the customer's browser does.
The correct mental model:
For a deeper look at why webhooks are more reliable than callbacks, see why webhooks beat callbacks.
With the verify-on-redirect pattern, two things can update the order: the callback page (via the Verify API) and the webhook handler. If they both run at the same time, you risk double-processing.
The fix is idempotency. Both the verify-on-redirect code and the webhook handler should use the same idempotency pattern:
// Shared function used by both the webhook handler and the verify endpoint
async function confirmPayment(reference, amount) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Try to insert the idempotency record
try {
await client.query(
'INSERT INTO processed_events (idempotency_key, event_type, processed_at) ' +
'VALUES ($1, $2, NOW())',
[reference, 'charge.success']
);
} catch (err) {
if (err.code === '23505') {
// Already processed. This is fine.
await client.query('ROLLBACK');
return { alreadyProcessed: true };
}
throw err;
}
// Update the order
await client.query(
'UPDATE orders SET status = $1, paid_at = NOW(), amount_paid = $2 ' +
'WHERE payment_reference = $3 AND status = $4',
['paid', amount, reference, 'pending']
);
await client.query('COMMIT');
return { alreadyProcessed: false };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Webhook handler calls this:
async function handleChargeSuccess(data) {
await confirmPayment(data.reference, data.amount);
}
// Verify endpoint calls this:
async function verifyAndConfirm(reference) {
const txn = await verifyWithPaystack(reference);
if (txn.status === 'success') {
await confirmPayment(reference, txn.amount);
}
return txn.status;
}
Both paths use the same confirmPayment function. The processed_events table with a UNIQUE constraint ensures that only one of them succeeds. The other gets the "already processed" result and skips. No double credits, no duplicate emails, no race condition.
For the full idempotency pattern, see idempotent webhook handlers.
To handle the redirect-webhook race condition cleanly:
This pattern works for both redirect checkout and Paystack Inline. It works on serverless and traditional servers. It works whether your webhook handler processes inline or uses a queue. The key principle: the redirect is for user experience, the webhook is for system state, and the Verify API bridges any gap between them.
For related reading, see webhook ordering for more on why events arrive unpredictably, and the complete webhooks guide for the full picture.
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