Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Popup (InlineJS) Explained Line by Line

Paystack Inline JS V2 is loaded via a script tag pointing to https://js.paystack.co/v2/inline.js. You create a new PaystackPop() instance and call either popup.checkout() with an access code from your server, or popup.newTransaction() with your public key and payment details. The checkout opens as an iframe overlay. onSuccess fires when payment completes (with the transaction reference), onClose fires when the customer closes the popup, and onLoad fires when the checkout iframe has loaded.

Loading the Script

Paystack Inline JS is a single JavaScript file hosted by Paystack. You load it with a script tag in your HTML.

<script src="https://js.paystack.co/v2/inline.js"></script>

This script does one thing: it creates a global PaystackPop class on the window object. After the script loads, you can create instances of PaystackPop and use them to open checkout popups.

Important details about the script:

  • Load it from https://js.paystack.co/v2/inline.js. The /v2/ path is the current version. The old /v1/ path still works but is deprecated and will eventually be removed.
  • The script is small (a few kilobytes) and loads quickly. Paystack serves it from a CDN.
  • You can load it in the <head> or at the bottom of the <body>. If you load it in the head, add the async attribute so it does not block page rendering: <script src="https://js.paystack.co/v2/inline.js" async></script>
  • If you are using a framework like React or Vue, load the script dynamically or include it in your HTML template. For React, you can add it to your index.html or load it in a useEffect hook.

After loading, verify the script is ready before using it:

// Check that PaystackPop is available
if (typeof PaystackPop === 'undefined') {
  console.error('Paystack Inline JS has not loaded yet');
  return;
}

var popup = new PaystackPop();

Two Checkout Modes: checkout() vs newTransaction()

PaystackPop gives you two ways to open the checkout popup. Understanding the difference is important because it affects your security model.

Mode 1: checkout() with an access code (recommended)

You initialize the transaction on your server using /transaction/initialize. Your server returns an access_code. You pass that access code to popup.checkout(). The checkout opens with the transaction already configured (amount, email, currency, metadata are all set server-side).

// Step 1: Your server initializes the transaction
// POST /api/initialize-payment -> returns { access_code: "abc123" }

// Step 2: Frontend opens the popup with the access code
var popup = new PaystackPop();
popup.checkout({
  accessCode: 'abc123', // From your server
  onSuccess: function(transaction) {
    // transaction.reference contains the payment reference
    verifyPaymentOnServer(transaction.reference);
  },
  onClose: function() {
    console.log('Customer closed the popup');
  },
});

This is the recommended approach because your secret key stays on the server, the amount is set server-side (so the customer cannot tamper with it), and the frontend only handles the UI popup.

Mode 2: newTransaction() with your public key

You skip the server-side initialize step and pass payment details directly to the popup using your public key. Paystack initializes the transaction when the popup opens.

var popup = new PaystackPop();
popup.newTransaction({
  key: 'pk_live_your_public_key',
  email: 'customer@example.com',
  amount: 500000, // 5,000 NGN in kobo
  currency: 'NGN',
  ref: 'order_142_' + Date.now(),
  onSuccess: function(transaction) {
    verifyPaymentOnServer(transaction.reference);
  },
  onClose: function() {
    console.log('Customer closed the popup');
  },
});

Why checkout() is better than newTransaction():

  • With newTransaction(), the amount is set in frontend code. A user with browser DevTools can change it before the popup opens. With checkout(), the amount is locked server-side.
  • With newTransaction(), you cannot attach metadata or custom fields without exposing them in frontend code. With checkout(), metadata is set in the server-side initialize call.
  • newTransaction() is convenient for prototyping but not recommended for production payment flows where amount integrity matters.

Every Configuration Option

Here are all the configuration options you can pass to checkout() and newTransaction().

Options for checkout() (access code mode):

Option Type Required Description
accessCode string Yes The access code from your server-side initialize call
onSuccess function No Called when payment completes successfully
onClose function No Called when the customer closes the popup
onLoad function No Called when the checkout iframe has finished loading

Options for newTransaction() (public key mode):

Option Type Required Description
key string Yes Your Paystack public key (pk_live_ or pk_test_)
email string Yes Customer email address
amount number Yes Amount in the smallest currency unit (kobo, pesewa, cents)
currency string No Currency code (NGN, GHS, ZAR, KES, USD). Defaults to your account currency
ref string No Transaction reference. If not provided, Paystack generates one
channels array No Payment channels to show: ['card', 'bank', 'ussd', 'mobile_money', 'bank_transfer', 'qr', 'apple_pay']
metadata object No Custom data to attach to the transaction
onSuccess function No Called when payment completes successfully
onClose function No Called when the customer closes the popup
onLoad function No Called when the checkout iframe has loaded

With checkout(), most transaction parameters (amount, email, currency, channels, metadata) are already set during the server-side initialize call. The frontend only needs the access code and the callbacks.

Callbacks Deep Dive: onSuccess, onClose, onLoad

The three callbacks are where your application logic connects to the checkout popup. Here is exactly what each one does and when it fires.

onSuccess(transaction)

Fires when the customer completes the payment successfully. The transaction parameter is an object containing at minimum the transaction reference.

onSuccess: function(transaction) {
  // transaction.reference = "order_142_1721472000000"

  // CRITICAL: Verify on your server before granting value
  fetch('/api/verify-payment', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ reference: transaction.reference }),
  })
  .then(function(res) { return res.json(); })
  .then(function(data) {
    if (data.verified) {
      // Show success message, redirect to thank-you page
      window.location.href = '/order/success?ref=' + transaction.reference;
    } else {
      // Verification failed
      alert('Payment could not be verified. Please contact support.');
    }
  });
}

Do not trust onSuccess alone. The popup runs in the browser, which means a technically skilled user could trigger onSuccess manually. Always verify the transaction on your server by calling Paystack's verify endpoint with the reference.

onClose()

Fires when the customer closes the popup without completing payment. This could mean they clicked the X button, pressed Escape, or clicked outside the popup overlay.

onClose: function() {
  // The customer closed the popup
  // Do NOT assume the payment failed

  // Option A: Show a message
  document.getElementById('payment-status').textContent =
    'Payment was cancelled. You can try again.';

  // Option B: Re-enable the pay button
  document.getElementById('pay-button').disabled = false;
}

Important: onClose does not mean the payment failed. The customer may have authorized the charge (entered their PIN or OTP) and then closed the popup before the success callback fired. The payment may still complete, and you will receive a webhook. Do not mark the order as cancelled just because onClose fired. Wait for the webhook or check the transaction status through the API.

onLoad()

Fires when the Paystack checkout iframe has finished loading and is ready for the customer to interact with. Use this to hide loading spinners or update your UI.

onLoad: function() {
  // The checkout form is now visible to the customer
  document.getElementById('loading-spinner').style.display = 'none';
}

onLoad is optional and most integrations do not use it. The popup shows its own loading state while the iframe loads, so you only need onLoad if you want to coordinate your own UI elements with the popup's readiness.

Complete Integration Example

Here is a full working example that ties together the server-side initialize call and the frontend popup.

<!DOCTYPE html>
<html>
<head>
  <title>Checkout</title>
  <script src="https://js.paystack.co/v2/inline.js"></script>
</head>
<body>
  <h1>Order #142</h1>
  <p>Total: NGN 5,000</p>
  <button id="pay-button" onclick="payWithPaystack()">Pay Now</button>
  <p id="payment-status"></p>

  <script>
    function payWithPaystack() {
      var payButton = document.getElementById('pay-button');
      var statusEl = document.getElementById('payment-status');

      // Disable button to prevent double clicks
      payButton.disabled = true;
      statusEl.textContent = 'Initializing payment...';

      // Step 1: Get access code from your server
      fetch('/api/initialize-payment', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          email: 'customer@example.com',
          orderId: '142',
        }),
      })
      .then(function(res) { return res.json(); })
      .then(function(data) {
        if (!data.access_code) {
          statusEl.textContent = 'Failed to initialize payment';
          payButton.disabled = false;
          return;
        }

        // Step 2: Open the Paystack popup
        var popup = new PaystackPop();
        popup.checkout({
          accessCode: data.access_code,
          onLoad: function() {
            statusEl.textContent = 'Checkout loaded. Complete your payment.';
          },
          onSuccess: function(transaction) {
            statusEl.textContent = 'Verifying payment...';

            // Step 3: Verify on your server
            fetch('/api/verify-payment', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ reference: transaction.reference }),
            })
            .then(function(res) { return res.json(); })
            .then(function(verifyData) {
              if (verifyData.verified) {
                window.location.href = '/order/success';
              } else {
                statusEl.textContent = 'Verification failed. Contact support.';
              }
            });
          },
          onClose: function() {
            statusEl.textContent = 'Payment popup was closed.';
            payButton.disabled = false;
          },
        });
      })
      .catch(function(err) {
        statusEl.textContent = 'Error: ' + err.message;
        payButton.disabled = false;
      });
    }
  </script>
</body>
</html>

This example shows the full three-step flow: initialize on the server, open the popup, verify after success. The secret key never appears in the frontend code. The amount is set server-side. The public key is not even needed here because we are using the access code mode.

Using Paystack Inline JS in React

In a React application, you load the Paystack script in your HTML template and use it in your component.

// PayButton.jsx
var React = require('react');
var useState = React.useState;

function PayButton(props) {
  var orderId = props.orderId;
  var email = props.email;
  var _loading = useState(false);
  var loading = _loading[0];
  var setLoading = _loading[1];
  var _status = useState('');
  var status = _status[0];
  var setStatus = _status[1];

  function handlePay() {
    setLoading(true);
    setStatus('Initializing...');

    fetch('/api/initialize-payment', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: email, orderId: orderId }),
    })
    .then(function(res) { return res.json(); })
    .then(function(data) {
      if (!data.access_code) {
        setStatus('Failed to initialize');
        setLoading(false);
        return;
      }

      var popup = new window.PaystackPop();
      popup.checkout({
        accessCode: data.access_code,
        onSuccess: function(transaction) {
          setStatus('Verifying...');
          // Send reference to your server for verification
          fetch('/api/verify-payment', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ reference: transaction.reference }),
          })
          .then(function(res) { return res.json(); })
          .then(function(result) {
            if (result.verified) {
              window.location.href = '/order/success';
            }
          });
        },
        onClose: function() {
          setStatus('Popup closed');
          setLoading(false);
        },
      });
    });
  }

  return React.createElement('div', null,
    React.createElement('button', {
      onClick: handlePay,
      disabled: loading,
    }, loading ? 'Processing...' : 'Pay Now'),
    status ? React.createElement('p', null, status) : null
  );
}

module.exports = PayButton;

Make sure the Paystack script is loaded before your component tries to use PaystackPop. The simplest way is to add the script tag to your index.html or _document.tsx. If you want to load it dynamically, create a script element in a useEffect hook and wait for it to load before enabling the pay button.

Mobile Browser Considerations

The Paystack popup works on mobile browsers, but there are details to be aware of.

The popup is an iframe, not a window

Paystack Inline JS creates an iframe overlay that covers the page, styled to look like a modal. This means it works on mobile browsers that block popup windows. The iframe approach avoids popup blockers entirely.

Small screens

On mobile, the checkout iframe takes up most or all of the screen. Paystack's checkout is responsive and adjusts to small screens. You do not need to do anything special for mobile responsiveness. The checkout form, payment channel selection, and bank redirects all work on mobile-sized screens.

WebView limitations

If you are embedding a WebView in a native mobile app (React Native WebView, Flutter WebView, Android WebView, WKWebView), some WebView configurations restrict iframe behavior. Test your specific WebView configuration with the Paystack popup. If the popup does not open correctly in a WebView, consider using the redirect checkout instead (redirect to the authorization URL and handle the callback URL in the WebView).

3DS redirects inside the popup

Some banks redirect the customer to a 3DS verification page during card payment. Inside the popup, this redirect happens within the iframe. The customer sees the bank's OTP page inside the popup overlay. After entering the OTP, they are redirected back inside the iframe, and the onSuccess callback fires. You do not need to handle this redirect yourself.

If you find that the popup experience is not working well for your mobile users, consider the redirect checkout pattern as an alternative.

Common Mistakes

Here are the mistakes we see most often in Paystack Inline JS integrations.

1. Granting value in onSuccess without server verification

The onSuccess callback runs in the browser. A user could trigger it manually. Never show premium content, credit an account, or mark an order as paid based solely on onSuccess. Always verify the reference on your server first.

2. Treating onClose as "payment failed"

The customer may have completed the payment before closing the popup. The webhook may arrive after onClose fires. Do not cancel orders or release inventory just because onClose fired. Wait for a definitive status from your server.

3. Using newTransaction() in production with the amount set in frontend code

A technically skilled customer can modify the amount in the browser before the popup opens. Use checkout() with a server-generated access code instead. The amount is locked server-side and cannot be tampered with.

4. Not disabling the pay button during processing

If the customer clicks "Pay" twice before the popup opens, you may initialize two transactions. Disable the button as soon as the customer clicks it and re-enable it in onClose if they cancel.

5. Loading the V1 script instead of V2

The V1 script (https://js.paystack.co/v1/inline.js) has a different API. If you are following a V2 tutorial with a V1 script, nothing will work. Always use /v2/inline.js. See migrating from V1 to V2 if you have an older integration.

6. Not handling script load failure

If the Paystack CDN is slow or unreachable, the PaystackPop class will be undefined. Check for its existence before using it, and show a fallback (like a redirect to the authorization URL) if the script fails to load.

Stay Up to Date

Paystack updates their Inline JS library periodically with new features and security improvements. We keep these guides current when the API changes.

Join the McTaba newsletter to get notified when we publish new Paystack integration guides.

Sign up for the McTaba newsletter

Key Takeaways

  • Load Paystack Inline JS V2 from https://js.paystack.co/v2/inline.js. This is the current version. The old V1 URL (https://js.paystack.co/v1/inline.js) is deprecated.
  • PaystackPop has two main methods: checkout() for using an access code from your server, and newTransaction() for initializing directly with your public key. checkout() is the recommended approach because it keeps your secret key on the server.
  • onSuccess receives a transaction object with a reference property. Always verify this reference on your server before granting value. The popup closing does not mean payment succeeded.
  • onClose fires when the customer closes the popup without completing payment. Use this to show a message or update your UI, but do not assume the payment failed. The customer may have authorized the charge before closing.
  • The popup is an iframe overlay, not a new window. It works on mobile browsers but can be affected by iframe restrictions in certain WebView environments.
  • You can pass channels, metadata, currency, and other parameters to control what the customer sees in the popup checkout.

Frequently Asked Questions

Do I need to install Paystack Inline JS from npm?
No. Paystack Inline JS is loaded from a CDN via a script tag. There is no official npm package from Paystack for the Inline JS library. Third-party npm wrappers exist (like react-paystack), but they wrap the same CDN-hosted script. For most projects, the script tag is the simplest and most reliable approach.
Can I style the Paystack popup to match my brand?
You cannot change the internal styling of the checkout form inside the popup. Paystack controls the checkout UI for security and consistency. However, you can customize your brand name, logo, and colors in your Paystack dashboard under Settings. These branding elements appear on the checkout page.
What happens if the customer refreshes the page while the popup is open?
The popup closes and the page reloads. If the customer had not yet authorized the payment, the transaction stays in a pending or abandoned state. If they had authorized (entered PIN/OTP), the payment may still complete and you will get a webhook. Your pay button should work with the same access code or generate a new one on refresh.
Is there a way to pre-fill the email field in the popup?
Yes. If you use the checkout() method with an access code, the email is already set during server-side initialization. If you use newTransaction(), pass the email in the configuration object. Either way, the email field on the checkout page is pre-filled and the customer cannot change it.
Can I open multiple Paystack popups at the same time?
No. Only one Paystack checkout popup can be open at a time. If you try to open a second popup while one is already open, the behavior is undefined. Always close or wait for the current popup to complete before opening another one.

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