Bonaventure OgetoBy Bonaventure Ogeto|

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

  1. If the initialize request includes callback_url, Paystack uses that URL.
  2. If the initialize request does not include callback_url, Paystack uses the dashboard default.
  3. 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:

  1. Verify the payment using the reference parameter.
  2. Look up the order using the order_id parameter.
  3. Redirect the user to their original page using the 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.

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.

AspectCallback URLWebhook URL
How it firesBrowser redirect (GET request)Server-to-server POST request
Who triggers itThe customer's browserPaystack's servers
When it firesWhen the customer clicks "Done" or is auto-redirectedWhen the transaction status changes
ReliabilityUnreliable: customer might close the browserReliable: Paystack retries on failure
PurposeUser experience: show the customer a confirmation pageBackend processing: update your database, fulfill orders
ConfigurationPer-transaction or dashboard defaultDashboard 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.

Keep Building

Callback URLs are one piece of the post-payment puzzle. For webhook handling (the reliable counterpart to callbacks), see the Paystack webhooks engineering guide. For idempotency patterns that protect against double-fulfillment, see idempotency in Paystack payment flows.

Building production payment systems for Africa means getting these details right. The McTaba Full-Stack Software and AI Engineering program covers end-to-end payment integration, database design, and deployment.

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