Paystack has its own test card numbers. Stripe cards (4242 4242 4242 4242) will not work. Use 4084 0840 8408 4081 for a successful test charge, enter 408408 as PIN, and 123456 as OTP. Make sure your secret key starts with sk_test_ and you are calling https://api.paystack.co, not a live endpoint. If you are using the Popup or Inline JS, confirm your public key also starts with pk_test_.
You copy a test card number from a tutorial, paste it into the Paystack checkout, and get "Transaction Failed" or "Card number is invalid." No useful error message. Just a dead end.
This happens more often than you would expect, and the root cause is almost always one of three things: wrong card numbers, wrong key type, or incomplete charge flow. The good news is that each one takes less than a minute to fix once you know what to look for.
The most common mistake is copying Stripe's test card number (4242 4242 4242 4242) and using it on Paystack. These are completely different payment processors with completely different test environments. Stripe's numbers mean nothing to Paystack's sandbox. Paystack has its own set of test cards with specific PINs and OTPs that must be used together.
Paystack provides several test cards that simulate different outcomes. Here are the ones you actually need.
Successful charge (Visa):
4084 0840 8408 4081408408408123456Successful charge (Mastercard):
5060 6611 1111 1111 111123408408123456Failed charge (insufficient funds):
4084 0840 8408 4081123457 to simulate failure3D Secure / Bank Authentication:
4000 0000 0000 0002All test cards use the same PIN (408408) and success OTP (123456). These are documented on Paystack's website, but they are easy to miss if you jump straight to building.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Paystack issues two pairs of keys: test keys and live keys. Each pair has a public key and a secret key. The naming convention is straightforward:
pk_test_xxxxxxxxxxxxxxxxsk_test_xxxxxxxxxxxxxxxxpk_live_xxxxxxxxxxxxxxxxsk_live_xxxxxxxxxxxxxxxxIf you use a live secret key to initialize a transaction, Paystack expects a real card. Test cards will fail. If you use a test public key on the frontend but a live secret key on the backend, the transaction will break in confusing ways. Both keys must be from the same mode.
Here is how to verify which keys you are using:
// Quick key check - add this temporarily during debugging
const secretKey = process.env.PAYSTACK_SECRET_KEY;
console.log('Key mode:', secretKey.startsWith('sk_test_') ? 'TEST' : 'LIVE');
console.log('Key prefix:', secretKey.substring(0, 8) + '...');
// Never log the full key. Just enough to confirm the mode.
A surprisingly common scenario: you deploy to staging and copy your live keys from the Paystack dashboard instead of your test keys. Everything looks right until you try a test card and it fails. Check the prefix.
If you are using the Charge API directly (instead of Popup or Inline JS), you must handle the multi-step flow yourself. The Charge API does not complete in a single request. It returns a status telling you what to do next.
Here is the full flow for a test card charge:
const axios = require('axios');
const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY; // Must be sk_test_...
async function chargeTestCard() {
// Step 1: Initiate the charge
const chargeResponse = await axios.post(
'https://api.paystack.co/charge',
{
email: 'test@example.com',
amount: 500000, // 5000 NGN in kobo
card: {
number: '4084084084084081',
cvv: '408',
expiry_month: '12',
expiry_year: '2030',
},
},
{
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET}`,
'Content-Type': 'application/json',
},
}
);
console.log('Charge status:', chargeResponse.data.data.status);
// Expected: "send_pin"
const reference = chargeResponse.data.data.reference;
// Step 2: Submit PIN
const pinResponse = await axios.post(
'https://api.paystack.co/charge/submit_pin',
{
pin: '408408',
reference: reference,
},
{
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET}`,
'Content-Type': 'application/json',
},
}
);
console.log('After PIN:', pinResponse.data.data.status);
// Expected: "send_otp"
// Step 3: Submit OTP
const otpResponse = await axios.post(
'https://api.paystack.co/charge/submit_otp',
{
otp: '123456',
reference: reference,
},
{
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET}`,
'Content-Type': 'application/json',
},
}
);
console.log('Final status:', otpResponse.data.data.status);
// Expected: "success"
console.log('Gateway response:', otpResponse.data.data.gateway_response);
// Expected: "Successful"
}
chargeTestCard().catch(console.error);
The mistake people make is sending only the initial charge request and expecting a "success" response. The sandbox simulates the real bank flow: charge, then PIN, then OTP. If you skip the PIN or OTP step, the transaction stays in "pending" forever.
If you are using Paystack Popup or Inline JS (which is what most integrations use), the PIN and OTP steps are handled inside the popup window. You do not need to build UI for those steps. But you still need to make sure you are in test mode.
// Frontend: Initialize transaction on your server first
// Make sure your server uses sk_test_ key
// Then open the popup with your pk_test_ key
const handler = PaystackPop.setup({
key: 'pk_test_xxxxxxxxxxxxxxxx', // Must be pk_test_
email: 'customer@example.com',
amount: 500000, // 5000 NGN in kobo
currency: 'NGN',
ref: 'test_ref_' + Date.now(),
callback: function (response) {
console.log('Payment complete:', response.reference);
// Verify on your server
},
onClose: function () {
console.log('Popup closed');
},
});
handler.openIframe();
When the popup opens in test mode, you will see a banner at the top saying "Test Mode." Enter the test card number (4084 0840 8408 4081), CVV 408, any future expiry. The popup will then ask for PIN (408408) and OTP (123456) inside the popup window itself.
If you do not see the "Test Mode" banner, you are using a live public key. Stop and swap it.
Common trap: Some developers initialize the transaction on the server with sk_test_ but use pk_live_ on the frontend (or vice versa). The key pair must match. Both test or both live.
The recommended Paystack flow is: initialize on the server, redirect or popup on the client, verify on the server. Here is the full test-mode flow to confirm everything works end to end.
const express = require('express');
const axios = require('axios');
const app = express();
const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;
// Step 1: Initialize transaction
app.post('/api/pay', async (req, res) => {
try {
const response = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: req.body.email,
amount: req.body.amount, // Already in kobo
callback_url: 'https://yoursite.com/payment/callback',
},
{
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET}`,
'Content-Type': 'application/json',
},
}
);
// Log to confirm test mode
console.log('Reference:', response.data.data.reference);
console.log('Auth URL:', response.data.data.authorization_url);
res.json({
authorization_url: response.data.data.authorization_url,
reference: response.data.data.reference,
});
} catch (error) {
console.error('Init failed:', error.response?.data || error.message);
res.status(500).json({ error: 'Payment initialization failed' });
}
});
// Step 2: Verify after callback
app.get('/payment/callback', async (req, res) => {
const reference = req.query.reference;
try {
const response = await axios.get(
`https://api.paystack.co/transaction/verify/${reference}`,
{
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET}`,
},
}
);
const data = response.data.data;
console.log('Status:', data.status); // "success" for test cards
console.log('Amount:', data.amount); // In kobo
console.log('Channel:', data.channel); // "card"
console.log('Card type:', data.authorization.card_type);
if (data.status === 'success') {
// Grant value to customer
res.send('Payment verified successfully');
} else {
res.send('Payment was not successful: ' + data.gateway_response);
}
} catch (error) {
console.error('Verify failed:', error.response?.data || error.message);
res.status(500).send('Verification failed');
}
});
app.listen(3000);
If the initialize call returns a 401, your secret key is wrong or malformed. If it returns a 200 but the test card fails in the checkout page, check that your public key matches the same test account. If verify returns "success" but you expected "failed," remember that OTP 123456 always succeeds. Use 123457 to simulate failure.
Run through this list before opening a support ticket. It catches the problem about 95% of the time.
sk_test_ on the server, pk_test_ on the client. Both from the same Paystack account.// Add to your server startup (remove after debugging)
console.log('=== Paystack Config Check ===');
console.log('Secret key loaded:', !!process.env.PAYSTACK_SECRET_KEY);
console.log('Key mode:', process.env.PAYSTACK_SECRET_KEY?.startsWith('sk_test_')
? 'TEST (correct for sandbox)'
: 'LIVE (test cards will NOT work)');
console.log('============================');
Once your test cards work for the success case, you need to test failure paths. Your code must handle declined cards, insufficient funds, and abandoned checkouts gracefully.
Insufficient funds: Use the success test card but submit OTP 123457 instead of 123456. The transaction will fail with a "declined" gateway response.
Abandoned checkout: Initialize a transaction but never complete it. After some time, it will remain in "abandoned" status. Your webhook handler should handle this (or ignore it, depending on your business logic).
Timeout: Initiate a Charge API call but never submit the PIN. The transaction will eventually time out.
// Test your failure handling
async function testFailureScenario() {
// Initialize and charge as normal, but use wrong OTP
const reference = 'fail_test_' + Date.now();
// ... (charge and PIN steps same as before)
// Submit wrong OTP to trigger failure
const otpResponse = await axios.post(
'https://api.paystack.co/charge/submit_otp',
{
otp: '123457', // Wrong OTP triggers failure
reference: reference,
},
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
}
);
console.log('Status:', otpResponse.data.data.status);
// Expected: "failed"
console.log('Gateway response:', otpResponse.data.data.gateway_response);
// Expected: something like "Declined"
}
testFailureScenario().catch(console.error);
Build your error handling around these test scenarios. In production, cards fail for many reasons: insufficient funds, expired cards, bank downtime, 3DS authentication failure. If your code only handles the happy path, your first production failure will be ugly.
After fixing your test card issue, confirm the full loop works.
authorization_url or access_code back.status: "success" and the correct amount.charge.success event.If all five steps pass, your sandbox integration is working correctly. When you are ready to go live, swap sk_test_ for sk_live_ and pk_test_ for pk_live_. The API endpoints, payload format, and flow remain identical. The only thing that changes is the keys and the fact that real money moves.
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