When you include a callback_url in your Paystack initialize request, it overrides the default callback URL set in your dashboard. Paystack appends ?trxref=REFERENCE&reference=REFERENCE to whichever URL it uses. If you provide neither a per-transaction callback nor a dashboard default, the customer sees a Paystack success page but is not redirected back to your site.
After a customer completes payment on Paystack's checkout page, Paystack redirects them to a URL on your site. This redirect is the callback. You have two ways to tell Paystack where to send the customer.
Option 1: Dashboard default
In your Paystack dashboard, go to Settings and enter a Callback URL. Every transaction that does not include its own callback_url in the initialize request uses this URL. This is a set-it-and-forget-it approach. It works well for applications with a single checkout flow where every customer should land on the same page after paying.
Option 2: Per-transaction callback_url
Include a callback_url field in the body of your /transaction/initialize request. This URL is used for that specific transaction, ignoring the dashboard default entirely.
var 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/orders/payment-complete',
}),
});
Priority order
callback_url, Paystack uses that URL.callback_url, Paystack uses the dashboard default.Always set a dashboard default, even if you always provide per-transaction URLs. It acts as a safety net for any transaction that slips through without a callback URL configured.
Regardless of which callback URL Paystack uses, it appends two query parameters: trxref and reference. Both contain the transaction reference. They are redundant (same value), but both exist for backward compatibility.
If your callback URL is:
https://yoursite.com/payment/callback
After payment, the customer lands on:
https://yoursite.com/payment/callback?trxref=order_ORD-142&reference=order_ORD-142
If your callback URL already has query parameters:
https://yoursite.com/payment/callback?plan=premium
Paystack appends with &:
https://yoursite.com/payment/callback?plan=premium&trxref=order_ORD-142&reference=order_ORD-142
This means you can safely include your own query parameters in the callback URL and they will survive the redirect. Paystack does not overwrite existing parameters.
// Express route that handles the callback
app.get('/payment/callback', async function(req, res) {
var reference = req.query.reference;
var plan = req.query.plan; // Your custom parameter, preserved by Paystack
if (!reference) {
return res.status(400).send('Missing reference');
}
// Verify the transaction
var verifyRes = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var data = await verifyRes.json();
if (data.data && data.data.status === 'success') {
// Payment confirmed. Use the plan variable to customize the success page.
res.redirect('/success?plan=' + plan);
} else {
res.redirect('/payment-failed');
}
});
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Per-transaction callback URLs shine when different payments in your application need different post-payment experiences.
Multi-product checkout
An e-commerce site sells physical goods and digital downloads. After paying for a physical product, the customer should see an order tracking page. After paying for a digital download, they should see a download link. Two different callback URLs handle this cleanly:
// Physical product checkout
var physicalCallback = 'https://yoursite.com/orders/' + orderId + '/tracking';
// Digital product checkout
var digitalCallback = 'https://yoursite.com/downloads/' + productId;
// Initialize with the appropriate callback
var payload = {
email: customer.email,
amount: amountInKobo,
callback_url: isDigital ? digitalCallback : physicalCallback,
reference: 'order_' + orderId,
};
Subscription vs one-time payment
A SaaS app lets users buy one-time add-ons and subscribe to monthly plans. The subscription callback should show the user their updated plan and next billing date. The add-on callback should show the activated feature. Different URLs, different pages, same Paystack account.
Multi-tenant platforms
If you run a platform where multiple businesses accept payments through your system, each business might have its own callback URL on its own subdomain or custom domain. Per-transaction URLs let you route each customer back to the correct business.
A/B testing checkout flows
You can use different callback URLs to route customers to different post-payment experiences and measure which one leads to better outcomes (repeat purchases, referrals, support tickets).
Not every application needs per-transaction callbacks. The dashboard default works well when:
The dashboard approach has one advantage over per-transaction: you can change the callback URL without deploying code. If you move your confirmation page to a new path, update the dashboard setting and every new transaction uses the new URL. With per-transaction URLs hardcoded in your backend, you need a code change and a deployment.
A hybrid approach works well for many teams: set a sensible default in the dashboard (a generic "Payment received, processing your order" page) and override it per-transaction only when specific flows need custom behavior.
One of the most useful patterns is encoding state directly in the callback URL. Instead of looking up what the payment was for after the redirect, the URL itself tells you.
// Encode the order context directly in the callback URL
function buildCallbackUrl(orderId, returnPage) {
var base = process.env.APP_URL + '/payment/callback';
var params = 'order_id=' + encodeURIComponent(orderId) +
'&return_to=' + encodeURIComponent(returnPage);
return base + '?' + params;
}
// For an order payment
var orderCallback = buildCallbackUrl('ORD-142', '/orders');
// https://yoursite.com/payment/callback?order_id=ORD-142&return_to=%2Forders
// For a wallet top-up
var topUpCallback = buildCallbackUrl('TOPUP-789', '/wallet');
// https://yoursite.com/payment/callback?order_id=TOPUP-789&return_to=%2Fwallet
// Initialize with the dynamic callback
var payload = {
email: customer.email,
amount: amountInKobo,
callback_url: orderCallback,
reference: 'order_' + orderId,
};
After payment, the customer lands on a URL like:
https://yoursite.com/payment/callback?order_id=ORD-142&return_to=%2Forders&trxref=order_ORD-142&reference=order_ORD-142
Your callback handler can now:
reference parameter.order_id parameter.return_to parameter.This eliminates the need for session-based state management. The customer's browser might clear cookies, or they might complete payment on a different device. The URL carries the context regardless.
Security note: Never put sensitive data in callback URLs. The URL appears in browser history, server access logs, and potentially in referrer headers. Order IDs and page paths are fine. API keys, tokens, and personally identifiable information are not.
The callback URL and the webhook URL serve completely different purposes. Confusing them is a common source of bugs.
| Aspect | Callback URL | Webhook URL |
|---|---|---|
| How it fires | Browser redirect (GET request) | Server-to-server POST request |
| Who triggers it | The customer's browser | Paystack's servers |
| When it fires | When the customer clicks "Done" or is auto-redirected | When the transaction status changes |
| Reliability | Unreliable: customer might close the browser | Reliable: Paystack retries on failure |
| Purpose | User experience: show the customer a confirmation page | Backend processing: update your database, fulfill orders |
| Configuration | Per-transaction or dashboard default | Dashboard only (Settings > API Keys & Webhooks) |
The callback URL is for the customer's experience. It should show a nice "Thank you" page, display order details, and give them a path forward (track order, download product, go to dashboard).
The webhook is for your backend. It should verify the payment, update the database, trigger fulfillment, and send confirmation emails.
Both carry the transaction reference. Both should verify the transaction before taking action. But the callback is a "nice to have" (the customer might close their browser before the redirect), while the webhook is essential (Paystack retries until your server responds with 200).
For a complete guide to webhook engineering, see the Paystack webhooks engineering guide.
Edge case 1: Customer bookmarks the callback URL
A customer pays, lands on your callback URL, bookmarks it, and visits it a week later. Your callback handler extracts the reference, calls verify, and gets a successful response (the transaction is still successful, it does not expire). If your handler grants value based solely on the verify response without checking whether value was already granted, the customer could trigger fulfillment again. Always check your database for existing fulfillment before processing.
Edge case 2: Inline popup with no redirect
If you use Paystack Inline JS (popup checkout), the customer stays on your page. The onSuccess callback in JavaScript fires instead of a redirect. The callback URL you set in the dashboard or per-transaction is not used. Some developers set up elaborate callback handlers and then wonder why they never fire. If you are using popup checkout, handle the result in the onSuccess function, not in a server-side callback route.
Edge case 3: HTTPS vs HTTP mismatch
If your callback URL uses HTTP but your site forces HTTPS (or vice versa), the redirect might fail or lose the query parameters during the protocol redirect. Always use HTTPS for your callback URLs. Paystack will not redirect to HTTP in production.
Edge case 4: Callback URL with a trailing slash
If your server treats /payment/callback and /payment/callback/ as different routes, make sure the callback URL you configure matches the route your server listens on. A missing or extra trailing slash can cause a 404 after payment, which is a terrible customer experience.
Edge case 5: Cross-origin callback
If your frontend is on app.yoursite.com and your callback URL points to api.yoursite.com, the redirect works (it is a simple GET request), but the customer sees your API domain in their browser. If you need the customer to land on your frontend domain, point the callback there and have the frontend verify by calling your API.
Putting it all together, here is a production-ready callback handler that verifies the payment, checks for existing fulfillment, and redirects the customer to the right page.
var express = require('express');
var db = require('./database');
var app = express();
app.get('/payment/callback', async function(req, res) {
var reference = req.query.reference;
var returnTo = req.query.return_to || '/dashboard';
if (!reference) {
return res.redirect('/payment-error?reason=missing_reference');
}
try {
// Step 1: Verify with Paystack
var verifyRes = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var verifyData = await verifyRes.json();
if (!verifyData.status || verifyData.data.status !== 'success') {
return res.redirect('/payment-failed?ref=' + encodeURIComponent(reference));
}
// Step 2: Check if already fulfilled (idempotency)
var order = await db.query(
'SELECT id, status FROM orders WHERE paystack_reference = $1',
[reference]
);
if (order.rows.length === 0) {
return res.redirect('/payment-error?reason=order_not_found');
}
if (order.rows[0].status === 'fulfilled') {
// Already processed (probably by webhook). Just show success.
return res.redirect('/payment-success?ref=' + encodeURIComponent(reference));
}
// Step 3: Fulfill the order
await db.query(
'UPDATE orders SET status = $1, paid_at = $2 WHERE paystack_reference = $3 AND status != $4',
['fulfilled', new Date(), reference, 'fulfilled']
);
// Step 4: Redirect to success page
res.redirect('/payment-success?ref=' + encodeURIComponent(reference) +
'&return_to=' + encodeURIComponent(returnTo));
} catch (err) {
console.error('Callback handler error:', err);
res.redirect('/payment-error?reason=server_error');
}
});
Notice the AND status != 'fulfilled' in the UPDATE query. This is a final guard: even if two processes reach the update simultaneously, only one changes the status. The other updates zero rows because the WHERE condition no longer matches. Combined with the check above, this makes the handler fully idempotent.
For the broader context of how callbacks fit into the payment lifecycle, see the complete guide to accepting payments with Paystack.
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