Paystack CORS errors happen because you are calling the Paystack API (api.paystack.co) directly from browser JavaScript. Paystack intentionally does not set CORS headers on its API because server-to-server calls do not need them, and allowing browser calls would expose your secret key. The fix is to move the API call to your backend server. Create a server endpoint that your frontend calls, and have that endpoint call the Paystack API with your secret key. Never call api.paystack.co from frontend code.
When you call the Paystack API from frontend JavaScript, the browser blocks the request and shows an error like this:
Access to fetch at 'https://api.paystack.co/transaction/initialize'
from origin 'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Or in some browsers:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading
the remote resource at https://api.paystack.co/transaction/initialize.
(Reason: CORS header 'Access-Control-Allow-Origin' missing).
The code that triggers this typically looks like:
// This code is running in the BROWSER. That is the problem.
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_xxxxx', // Secret key exposed in browser!
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: 'user@example.com', amount: 50000 }),
});
// Result: CORS error. The request never reaches Paystack.
This code has two problems: the CORS error, and the fact that your secret key is exposed to anyone who opens browser DevTools.
CORS (Cross-Origin Resource Sharing) is a browser security feature. When your frontend JavaScript on localhost:3000 tries to call api.paystack.co, the browser checks whether Paystack's server allows requests from your domain. It does this by looking for an Access-Control-Allow-Origin header in Paystack's response.
Paystack does not include this header. On purpose.
Here is the part most developers get wrong: you cannot fix this by adding CORS headers to your own server. The CORS check happens on the target server (Paystack's server), not on your server. You do not control Paystack's response headers.
Three "fixes" that do not work:
The real fix is architectural: move the API call to your server.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
The correct Paystack integration has three steps:
The browser never talks to api.paystack.co directly. Only your server does. Server-to-server calls do not have CORS restrictions because CORS is a browser-only security feature.
// Step 1: Frontend calls YOUR server
// frontend/checkout.js
async function initiatePayment(email, amount) {
const response = await fetch('/api/payments/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, amount }),
});
const data = await response.json();
if (data.authorization_url) {
// Redirect to Paystack checkout
window.location.href = data.authorization_url;
}
}
// Step 2: Your server calls Paystack
// server/routes/payments.js
const express = require('express');
const router = express.Router();
router.post('/api/payments/initialize', async (req, res) => {
const { email, amount } = req.body;
// Server-to-server call. No CORS. Secret key stays on the server.
const paystackResponse = 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,
amount: amount * 100, // Convert to kobo
}),
});
const data = await paystackResponse.json();
// Step 3: Return the result to the frontend
res.json(data.data);
});
module.exports = router;
Notice that the secret key (PAYSTACK_SECRET_KEY) only exists on the server. The frontend never sees it. The frontend only knows the URL of your own server endpoint.
If you use Paystack's Inline.js popup, you do not need to call /transaction/initialize at all. The popup handles initialization internally using your public key:
<script src="https://js.paystack.co/v2/inline.js"></script>
<script>
function payWithPaystack() {
const handler = PaystackPop.setup({
key: 'pk_test_xxxxx', // Public key is safe in the browser
email: 'customer@example.com',
amount: 50000, // 500 NGN in kobo
currency: 'NGN',
callback: function(response) {
// Payment complete. Verify on your server.
fetch('/api/payments/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reference: response.reference }),
});
},
onClose: function() {
console.log('Payment window closed');
},
});
handler.openIframe();
}
</script>
With this pattern, the only server call you need is verification after the payment completes. The popup uses the public key (pk_test_ or pk_live_), which is safe to expose in the browser.
You still need a server endpoint for verification:
// server/routes/payments.js
router.post('/api/payments/verify', async (req, res) => {
const { reference } = req.body;
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') {
// Grant access, send receipt, update database
res.json({ verified: true });
} else {
res.json({ verified: false, status: data.data.status });
}
});
The verification call uses your secret key, so it must happen on the server. If you try to call /transaction/verify from the browser, you will get the same CORS error.
In Next.js, your API route acts as the server layer. The frontend component calls the API route, and the API route calls Paystack:
// app/api/payments/initialize/route.ts (App Router)
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { email, amount } = await request.json();
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, amount: amount * 100 }),
});
const data = await response.json();
if (!data.status) {
return NextResponse.json({ error: data.message }, { status: 400 });
}
return NextResponse.json({
authorization_url: data.data.authorization_url,
reference: data.data.reference,
});
}
// app/checkout/page.tsx (Client Component)
'use client';
async function handlePayment() {
const response = await fetch('/api/payments/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'customer@email.com',
amount: 5000,
}),
});
const data = await response.json();
if (data.authorization_url) {
window.location.href = data.authorization_url;
}
}
The client component calls /api/payments/initialize on your own Next.js server. No CORS issues because the request stays on the same origin. The API route then calls Paystack server-to-server.
These are the patterns that cause CORS errors. If you recognize any of them in your code, move the call to your server.
Mistake 1: Calling /transaction/initialize from the frontend.
// WRONG: This runs in the browser
const res = await axios.post('https://api.paystack.co/transaction/initialize', {
email: 'user@example.com',
amount: 50000,
}, {
headers: { Authorization: 'Bearer sk_test_xxxxx' },
});
// CORS error + secret key exposed
Mistake 2: Calling /transaction/verify from the frontend.
// WRONG: Verification must happen on the server
const res = await fetch(`https://api.paystack.co/transaction/verify/${reference}`, {
headers: { Authorization: 'Bearer sk_test_xxxxx' },
});
// CORS error + secret key exposed
Mistake 3: Calling any Paystack endpoint from a React useEffect or event handler.
// WRONG: useEffect runs in the browser
useEffect(() => {
fetch('https://api.paystack.co/bank', {
headers: { Authorization: 'Bearer sk_test_xxxxx' },
}).then(res => res.json()).then(setBanks);
}, []);
// CORS error + secret key exposed
Mistake 4: Using a CORS proxy to bypass the restriction.
// DANGEROUS: Your secret key passes through a third party
const res = await fetch('https://cors-anywhere.herokuapp.com/https://api.paystack.co/transaction/initialize', {
headers: { Authorization: 'Bearer sk_test_xxxxx' },
});
// "Works" but your secret key is now visible to the proxy operator
In every case, the fix is the same: create a server endpoint and move the Paystack call there.
The CORS error is actually saving you from a security breach. If Paystack allowed browser-origin API calls, your secret key would be visible to anyone who opens browser DevTools on your site. With your secret key, an attacker could:
This is why Paystack gives you two key types:
pk_test_, pk_live_) are safe for the browser. They can only initialize the checkout popup. They cannot read data or initiate transfers.sk_test_, sk_live_) have full API access. They must stay on your server.If your secret key has ever been exposed in frontend code (even in a Git commit that was later deleted), regenerate it immediately in your Paystack dashboard under Settings > API Keys & Webhooks.
After moving your Paystack API calls to the server, verify the fix:
Step 1: Check your frontend code.
Search your entire frontend codebase for api.paystack.co. There should be zero matches. Every Paystack API call should go through your own server endpoints.
# Search for direct Paystack API calls in frontend code
grep -r "api.paystack.co" src/
# Should return no results (or only results in server-side files)
Step 2: Check your frontend code for secret keys.
# Search for secret keys in frontend code
grep -r "sk_test_|sk_live_" src/
# Should return no results
Step 3: Test the payment flow.
Open your browser DevTools, go to the Console tab, and complete a test payment. There should be no CORS errors. The Network tab should show requests to your own server (e.g., /api/payments/initialize), not to api.paystack.co.
Step 4: Verify the server endpoint works.
// Quick test from your terminal (not the browser)
curl -X POST http://localhost:3000/api/payments/initialize -H "Content-Type: application/json" -d '{"email": "test@example.com", "amount": 500}'
// Should return { "authorization_url": "https://checkout.paystack.com/...", ... }
If the curl command works but the browser still shows CORS errors, then you do need CORS headers on your own server, but only for your own endpoints. This is different from the original problem. Use the cors middleware in Express or configure CORS in your framework to allow your frontend origin.
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