Paystack Callback URLs: Per-Transaction vs Dashboard Configuration
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.
The Two Ways to Set a Callback URL
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
- If the initialize request includes
callback_url, Paystack uses that URL. - If the initialize request does not include
callback_url, Paystack uses the dashboard default. - If neither is set, Paystack shows its own generic success page but does not redirect the customer back to your site. The customer is stuck on Paystack's domain.
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.
How Paystack Appends Query Parameters
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');
}
});
When Per-Transaction Callbacks Are the Right Choice
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).
When the Dashboard Default Is Enough
Not every application needs per-transaction callbacks. The dashboard default works well when:
- Your application has a single payment flow (one product, one checkout page, one confirmation page).
- You use metadata or the transaction reference to determine what to show on the confirmation page, rather than relying on the URL.
- You are building a simple integration where minimizing code is more important than customizing the post-payment experience.
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.
Dynamic Callback URLs with Encoded State
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:
- Verify the payment using the
referenceparameter. - Look up the order using the
order_idparameter. - Redirect the user to their original page using the
return_toparameter.
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.
Callback URL vs Webhook: Different Jobs
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 Cases That Catch People
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.
A Complete Callback Handler
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.
Key Takeaways
- ✓Per-transaction callback_url always wins. If you include callback_url in the initialize request, Paystack ignores the dashboard default for that transaction.
- ✓The dashboard default acts as a fallback. It covers transactions where you forget or choose not to set a per-transaction URL. Always set a sensible default even if you use per-transaction URLs.
- ✓Paystack appends ?trxref=REFERENCE&reference=REFERENCE as query parameters to your callback URL. Both parameters contain the same transaction reference. If your URL already has query parameters, Paystack appends with &.
- ✓Per-transaction callbacks are essential for multi-product apps where different checkout flows need different landing pages. An order checkout and a subscription renewal should not land on the same page.
- ✓Never treat the callback redirect as proof of payment. The customer arriving at your callback URL only means they were redirected. Always verify the transaction server-side using the reference parameter.
- ✓Dynamic callback URLs let you encode state (like the order ID or return page) directly in the URL, reducing the need for session storage or database lookups to figure out where the customer came from.
Frequently Asked Questions
- What happens if I set neither a per-transaction callback URL nor a dashboard default?
- The customer sees a generic Paystack success page after payment but is not redirected back to your site. They have no way to return to your application other than manually typing your URL or using the browser back button. Always set at least a dashboard default to avoid this.
- Can I use localhost as a callback URL for testing?
- Yes, during development with test keys, you can use localhost URLs (like http://localhost:3000/payment/callback). Paystack does not validate the URL domain for test transactions. For live transactions, the callback URL must be a publicly accessible HTTPS URL.
- Does the callback URL support hash fragments (#)?
- Hash fragments (like #section) are handled entirely by the browser and are not sent to the server. Paystack appends query parameters before the hash, so your URL should work. However, single-page applications that rely on hash-based routing may need to parse the URL carefully to extract both the hash route and the query parameters.
- Can I change the callback URL after initializing a transaction?
- No. Once a transaction is initialized, the callback URL is locked for that transaction. If you need a different callback URL, you would need to initialize a new transaction with a new reference. This is why getting the callback URL right during initialization matters.
- Is the callback URL the same as the webhook URL?
- No. The callback URL is where the customer is redirected in their browser (a GET request). The webhook URL is where Paystack sends server-to-server POST notifications about transaction events. They serve different purposes and should point to different endpoints. The callback handles the customer experience; the webhook handles backend processing.
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