Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Callback URL Not Redirecting

Paystack does not redirect customers after payment for three main reasons: you did not set a callback_url when initializing the transaction, you are using the popup (Inline.js) mode which uses a JavaScript callback instead of a URL redirect, or the browser is blocking the redirect due to popup blockers or CSP rules. For redirect mode, include callback_url in your /transaction/initialize request. For popup mode, handle the redirect yourself in the callback function. If you set a callback_url in the dashboard but not in the API call, the API call takes precedence.

What "Not Redirecting" Looks Like

After a successful payment, the customer expects to land back on your site (a thank-you page, a receipt, a dashboard). Instead, one of these happens:

  • The customer stays on the Paystack checkout page with a "Payment Successful" message but no way to return to your site
  • The Paystack popup closes but nothing happens on your page
  • The customer sees a blank page or a "this page can't be found" error at the callback URL
  • The redirect works for you during testing but fails for some customers

The root cause depends on which Paystack checkout mode you are using. Let's look at both.

Cause 1: callback_url Not Set (Redirect Mode)

In redirect mode, you initialize a transaction via the API, get an authorization_url, and send the customer to that URL. After payment, Paystack redirects the customer to your callback_url.

The problem: if you do not include callback_url in your API request, Paystack does not know where to send the customer back.

// BROKEN: no callback_url
const 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,  // 5,000 NGN in kobo
    // callback_url is missing!
  }),
});

The fix: Add callback_url to your initialization request.

// FIXED: callback_url included
const 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/payment/verify',
    // Use your production domain, not localhost
  }),
});

const data = await response.json();
// Redirect the customer to data.data.authorization_url
// After payment, Paystack redirects to:
// https://yoursite.com/payment/verify?trxref=abc123&reference=abc123

You can also set a default callback URL in your Paystack dashboard under Settings > API Keys & Webhooks. But the URL in the API request always takes priority. If you set different URLs in the dashboard and the API request, the API request wins.

Important: The callback_url must be a full URL with the protocol. yoursite.com/payment/verify will not work. It must be https://yoursite.com/payment/verify.

Cause 3: Browser Blocking the Popup or Redirect

Even with the correct code, browsers can block the popup or redirect in several ways:

Popup blockers: If you open the Paystack popup from a timer, an async callback, or anything that is not a direct user click, the browser treats it as a popup and blocks it. The popup never opens, so the callback never fires, and no redirect happens.

// BROKEN: popup opened from setTimeout, not a direct click
setTimeout(() => {
  const handler = PaystackPop.setup({ /* ... */ });
  handler.openIframe();  // Browser may block this
}, 1000);

// BROKEN: popup opened from an async chain
async function pay() {
  const userData = await fetchUserData();  // async operation
  const handler = PaystackPop.setup({
    email: userData.email,
    // ...
  });
  handler.openIframe();  // May be blocked because it is not a direct click
}

The fix: Always open the popup directly from a click handler. If you need to fetch data first, fetch it before the click or show a loading state.

// FIXED: popup opens directly from click
function handleClick() {
  // All setup is synchronous from the click event
  const handler = PaystackPop.setup({
    key: 'pk_test_xxxxx',
    email: document.getElementById('email').value,
    amount: 500000,
    callback: (response) => {
      window.location.href = `/payment/verify?reference=${response.reference}`;
    },
  });
  handler.openIframe();  // Direct from click, browser allows it
}

document.getElementById('pay-button').addEventListener('click', handleClick);

CSP frame-src blocking: If your Content Security Policy blocks standard.paystack.co in the frame-src directive, the popup iframe will not load. See Paystack Checkout Blocked by Content Security Policy for the fix.

Browser extensions: Some ad blockers and privacy extensions block third-party iframes. There is not much you can do about this except to offer redirect mode as a fallback. Redirect mode opens a full Paystack page instead of an iframe, so extensions are less likely to block it.

Setting Up Your Callback Page

When Paystack redirects to your callback URL, it appends the transaction reference as query parameters. Your callback page must read these parameters and verify the payment server-side.

// Example callback page: /payment/verify
// Server-side handler (Express)

router.get('/payment/verify', async (req, res) => {
  const reference = req.query.reference || req.query.trxref;

  if (!reference) {
    return res.status(400).send('No payment reference provided');
  }

  // Verify the transaction with Paystack
  const paystackResponse = await fetch(
    `https://api.paystack.co/transaction/verify/${reference}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
      },
    }
  );

  const data = await paystackResponse.json();

  if (data.data.status === 'success') {
    // Payment confirmed. Grant access, send receipt, update database.
    const amountPaid = data.data.amount / 100;  // Convert from kobo
    const customerEmail = data.data.customer.email;

    // Your business logic here
    await grantAccess(customerEmail);

    res.render('payment-success', {
      amount: amountPaid,
      reference: reference,
    });
  } else {
    // Payment failed or is still pending
    res.render('payment-failed', {
      status: data.data.status,
      message: data.data.gateway_response,
    });
  }
});

Critical: never trust the redirect alone. A customer could manually type your callback URL into their browser without paying. Always verify the transaction reference with Paystack's API before granting access.

Common callback URL mistakes:

  • Using localhost in production: http://localhost:3000/payment/verify will not work for real users
  • Forgetting the protocol: yoursite.com/verify is not a valid URL. Use https://yoursite.com/verify
  • Trailing slash mismatch: if your server treats /verify and /verify/ differently, make sure the callback URL matches
  • Path does not exist: the callback URL must point to an actual route on your server. A 404 page means the route is not set up

Dashboard Callback URL vs API Callback URL

Paystack lets you set a callback URL in two places:

  1. Dashboard: Settings > API Keys & Webhooks > Callback URL. This is the default for all transactions that do not specify a callback_url in the API request.
  2. API request: The callback_url field in the /transaction/initialize request body. This overrides the dashboard setting for that specific transaction.

Priority: API request > Dashboard setting. If both are set, the API request wins.

This can cause confusion. If your dashboard has https://yoursite.com/old-callback and your API sends https://yoursite.com/new-callback, the customer goes to /new-callback. If your API does not send a callback_url, the customer goes to /old-callback.

Recommendation: set the callback_url in every API request. Do not rely on the dashboard setting. This makes your code self-documenting and prevents surprises when someone changes the dashboard setting.

// Set callback_url explicitly in every request
const callbackUrl = process.env.NODE_ENV === 'production'
  ? 'https://yoursite.com/payment/verify'
  : 'http://localhost:3000/payment/verify';

const 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: callbackUrl,
  }),
});

When to Use Redirect Mode vs Popup Mode

Choose the right mode to avoid redirect issues entirely:

Use redirect mode when:

  • Your site is server-rendered (PHP, Django, Rails, server-side Node.js)
  • You want the checkout on a separate page (less frontend JavaScript needed)
  • You need to support browsers with strict popup blocking
  • Your payment flow involves multiple steps after payment (e.g., redirect to a different service)

Use popup mode when:

  • Your site is a single-page app (React, Vue, Angular, Next.js)
  • You want the checkout overlay on your page for a smoother experience
  • You want to handle the payment result in JavaScript without a page reload
  • Your users are on modern browsers that handle iframes well

You can also offer both: start with popup mode, and fall back to redirect mode if the popup fails to open. This covers edge cases where popup blockers or extensions interfere.

function initiatePayment(email, amount) {
  try {
    // Try popup mode first
    const handler = PaystackPop.setup({
      key: 'pk_test_xxxxx',
      email,
      amount,
      callback: (response) => {
        window.location.href = `/payment/verify?reference=${response.reference}`;
      },
    });
    handler.openIframe();
  } catch (error) {
    // Fallback: initialize via server and redirect
    fetch('/api/payments/initialize', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, amount }),
    })
      .then(res => res.json())
      .then(data => {
        window.location.href = data.authorization_url;
      });
  }
}

Verification: Confirm Redirects Work

Test the full redirect flow end to end:

Step 1: Test with a test card.

Use Paystack test card 4084 0840 8408 4081 with any future expiry date and any CVV. Complete the payment. Confirm you are redirected to your callback URL with the reference and trxref query parameters.

Step 2: Check the callback URL receives the right parameters.

// In your callback route, log what you receive
router.get('/payment/verify', (req, res) => {
  console.log('Query params:', req.query);
  // Should log: { trxref: 'abc123', reference: 'abc123' }
  // Both values are the same transaction reference

  // Continue with verification...
});

Step 3: Test the popup callback.

// Temporarily add logging to your popup callback
callback: (response) => {
  console.log('Popup callback fired with:', response);
  // Should log: { reference: 'abc123', trans: '...',  ... }
  // Then redirect
  window.location.href = `/payment/verify?reference=${response.reference}`;
},

Step 4: Test with a failed payment.

Use test card 4084 0840 8408 4081 with CVV 400 to simulate a declined card. Confirm your callback page handles the failed status gracefully instead of showing a success message.

Step 5: Test the onClose handler.

Open the popup and close it without paying. Confirm your onClose handler fires and shows an appropriate message to the customer.

Key Takeaways

  • Paystack has two checkout modes: redirect and popup. Redirect mode uses callback_url to send the customer back. Popup mode uses a JavaScript callback function. Mixing them up is the most common cause of redirect failures.
  • If you use /transaction/initialize without setting callback_url in the request body, Paystack falls back to the callback URL in your dashboard settings. If neither is set, the customer stays on the Paystack checkout page after payment.
  • The callback_url in the API request always overrides the one in your dashboard. If you set a callback_url in both places and they differ, the API request wins.
  • In popup mode (Inline.js / PaystackPop), there is no automatic redirect. The popup closes and your callback function runs. You must call window.location.href yourself to redirect the customer.
  • Paystack appends ?trxref=REFERENCE&reference=REFERENCE to your callback_url. Your callback page must read these parameters and verify the transaction on your server before granting access.
  • Browser popup blockers can prevent the Paystack popup from opening in the first place, which means the callback function never fires. Always trigger the popup from a direct user click.
  • Never trust the redirect alone to confirm payment. A customer could manually navigate to your callback URL without paying. Always verify the transaction server-side using /transaction/verify.

Frequently Asked Questions

What is the difference between callback_url and webhook_url in Paystack?
callback_url is the page your customer is redirected to after payment (a browser redirect). webhook_url is the endpoint Paystack sends a server-to-server POST notification to (no browser involved). Both fire after payment, but they serve different purposes. Use callback_url for the user experience (showing a receipt) and webhooks for the backend logic (updating your database). Always rely on webhooks for critical business logic because a customer might close their browser before the redirect happens.
Why does Paystack add ?trxref and ?reference to my callback URL?
Paystack appends the transaction reference as both trxref and reference query parameters so your callback page can identify which transaction to verify. Both parameters contain the same value. Use either one to call /transaction/verify on your server.
My callback URL works in test mode but not in live mode. Why?
In live mode, Paystack requires your callback_url to use HTTPS. If your callback URL starts with http://, Paystack may not redirect to it in live mode. Make sure your production site uses HTTPS and your callback_url starts with https://. Also verify that your live API keys are active and your Paystack account is fully activated.
Can I change the callback URL after initializing a transaction?
No. The callback_url is set at transaction initialization time and cannot be changed afterward. If you need to redirect to a different page, set the correct callback_url before the customer starts the checkout. If you forgot to set it, you will need to initialize a new transaction with the correct URL.
The popup closes but the callback function does not fire. What happened?
If the customer closes the popup manually (clicks the X or presses Escape), the onClose function fires, not the callback function. The callback only fires when payment is successful. If the popup closes by itself after a successful payment and the callback does not fire, check for JavaScript errors in your console. A syntax error in your callback function will prevent it from running.

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