Race Conditions Between Paystack Webhooks and Redirect Callbacks
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.
The Race Between Webhook and Redirect
Here is what happens when a customer completes a payment on Paystack:
- Customer enters card details on the Paystack checkout page.
- Paystack processes the charge.
- Paystack does two things simultaneously:
- Sends a webhook POST to your server with the
charge.successevent. - Redirects the customer's browser to your callback URL (e.g.,
https://yoursite.com/payment/verify?reference=ref-001).
- Sends a webhook POST to your server with the
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.
Why the Redirect Often Wins
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.
Pattern 1: Verify on Redirect
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:
- Check the database first. If the webhook already processed the event, you get the answer instantly without calling Paystack's API.
- If the database is not updated, call Paystack Verify. This gives you the truth regardless of webhook timing.
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.
Pattern 2: Polling on the Callback Page
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.
The Same Race with Paystack Inline (Popup)
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;
},
Why You Cannot Skip the Webhook
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:
- The customer closes their browser after entering card details but before the redirect fires.
- The customer's phone loses internet connection during the redirect.
- The customer's browser crashes.
- The payment was initiated via a backend API call (no browser involved).
- The customer navigates away from the page before the payment completes.
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:
- The webhook is the authoritative confirmation. It always arrives (with retries if needed). It updates your system of record.
- The redirect callback is for user experience. It shows the customer a timely confirmation. It should not be the only way your system learns about the payment.
- The verify-on-redirect is a bridge. It covers the gap between the redirect arriving and the webhook being processed, so the customer sees a fast confirmation.
For a deeper look at why webhooks are more reliable than callbacks, see why webhooks beat callbacks.
Preventing Double Processing
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.
Summary of Recommendations
To handle the redirect-webhook race condition cleanly:
- Use verify-on-redirect. When the callback page loads, check the database first, then call the Paystack Verify API if the database is not updated yet. This gives the customer immediate confirmation.
- Keep the webhook handler. The webhook is your authoritative, reliable confirmation. It handles cases where the redirect never arrives.
- Share the confirmation logic. Both the verify-on-redirect code and the webhook handler should call the same idempotent function. This prevents double-processing.
- Show appropriate UI. If the payment is confirmed (by either path), show success. If the Verify API says the payment is still processing, show a "processing" message with a note that the customer will receive an email.
- Never show "failed" prematurely. If your database shows "pending" and you have not verified with Paystack, the payment might have succeeded. Do not show a failure message based on a "pending" database state alone.
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.
Key Takeaways
- ✓The Paystack redirect callback and the webhook are independent HTTP requests. Neither waits for the other.
- ✓The redirect often arrives before the webhook. The customer sees a "processing" or "pending" page even though they already paid.
- ✓Use the verify-on-redirect pattern: when the callback page loads, call the Paystack Verify API to get the definitive transaction status.
- ✓Never rely solely on the redirect callback for payment confirmation. The webhook is the authoritative notification. The redirect is for user experience.
- ✓Build your callback page to handle three states: already confirmed (webhook arrived first), payment verified (redirect arrived first, verify API confirms), and genuinely pending.
- ✓For Paystack Inline (popup), the same race condition applies between the onSuccess callback and the webhook.
Frequently Asked Questions
- Can the Paystack redirect arrive before the webhook?
- Yes. The redirect and the webhook are independent HTTP requests. The redirect goes through the customer browser while the webhook goes server-to-server. Either can arrive first. Your callback page should not assume the webhook has been processed.
- Should I call the Paystack Verify API on every redirect?
- Check your database first. If the webhook has already processed the event, the database shows the payment as confirmed, and you do not need to call the Verify API. Only call Verify when the database still shows "pending." This reduces unnecessary API calls.
- Is it safe to update the order from the redirect callback instead of the webhook?
- It is safe if you verify with the Paystack API first and use an idempotent update pattern. But you must still keep the webhook handler. The redirect depends on the customer browser. If the browser closes, the redirect never arrives. The webhook is server-to-server and always arrives.
- What should I show the customer if the payment is still processing when the redirect arrives?
- Show a message like "Your payment is being confirmed. This usually takes a few seconds." You can poll your database for updates or simply tell the customer to check their email for a confirmation. Never show an error message for a payment that might have succeeded.
- Does the same race condition apply to Paystack Inline popup checkout?
- Yes. The onSuccess JavaScript callback in the popup fires at roughly the same time the webhook is sent. If you redirect to a page that checks the database, the webhook might not have been processed yet. Use the same verify-on-redirect pattern.
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