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.
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:
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.<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>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();
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():
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.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.Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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_) |
| 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.
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.
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.
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.
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.
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.
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