Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Sandbox Card Numbers Not Working

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_.

Why Your Test Card Is Being Declined

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.

The Correct Paystack Test Card Numbers

Paystack provides several test cards that simulate different outcomes. Here are the ones you actually need.

Successful charge (Visa):

  • Card number: 4084 0840 8408 4081
  • Expiry: any future date (e.g., 12/30)
  • CVV: 408
  • PIN (when prompted): 408408
  • OTP (when prompted): 123456

Successful charge (Mastercard):

  • Card number: 5060 6611 1111 1111 111
  • Expiry: any future date
  • CVV: 123
  • PIN: 408408
  • OTP: 123456

Failed charge (insufficient funds):

  • Card number: 4084 0840 8408 4081
  • Same details as above, but use OTP 123457 to simulate failure

3D Secure / Bank Authentication:

  • Card number: 4000 0000 0000 0002
  • Simulates 3DS redirect flow

All 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.

Test Mode vs Live Mode: The Key Mismatch Problem

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:

  • Test public key: pk_test_xxxxxxxxxxxxxxxx
  • Test secret key: sk_test_xxxxxxxxxxxxxxxx
  • Live public key: pk_live_xxxxxxxxxxxxxxxx
  • Live secret key: sk_live_xxxxxxxxxxxxxxxx

If 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.

Handling PIN and OTP Challenges on the Charge API

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.

Verifying the Complete Server-Side Flow in Test Mode

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.

Five-Minute Debugging Checklist

Run through this list before opening a support ticket. It catches the problem about 95% of the time.

  1. Check the card number. Is it 4084 0840 8408 4081? Not 4242 4242 4242 4242? Not your actual debit card?
  2. Check the CVV. Must be 408 for the Visa test card, 123 for the Verve test card.
  3. Check the expiry. Must be any date in the future. A past date will fail.
  4. Check your key prefix. sk_test_ on the server, pk_test_ on the client. Both from the same Paystack account.
  5. Check the dashboard toggle. Log into your Paystack dashboard. There is a toggle at the top that switches between Test and Live. Make sure you are looking at Test mode when checking for transactions.
  6. Check the PIN and OTP. If using the Charge API, did you submit PIN 408408 and OTP 123456? Both steps are required.
  7. Check your environment variables. Your .env file might have the right key, but is it actually loaded? Log the first 8 characters of the key at startup to confirm.
// 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('============================');

Simulating Different Failure Scenarios in Sandbox

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.

How to Verify Everything Is Working

After fixing your test card issue, confirm the full loop works.

  1. Initialize a transaction from your server. Confirm you get an authorization_url or access_code back.
  2. Complete the payment using test card 4084 0840 8408 4081, PIN 408408, OTP 123456.
  3. Check your Paystack dashboard (Test mode toggle). The transaction should appear as "Success."
  4. Verify the transaction via the API. The response should show status: "success" and the correct amount.
  5. If you have webhooks configured, check your webhook endpoint logs. You should receive a 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.

Key Takeaways

  • Paystack test cards are completely different from Stripe test cards. Using 4242 4242 4242 4242 on Paystack will always fail.
  • The primary success test card is 4084 0840 8408 4081 with CVV 408, expiry in any future date, PIN 408408, and OTP 123456.
  • Your secret key must start with sk_test_ and your public key with pk_test_ for sandbox mode. Mixing live and test keys causes silent failures.
  • Paystack sandbox simulates the full card flow including PIN and OTP challenges. Your integration must handle the submit_pin and submit_otp steps on the Charge API.
  • If you are using Paystack Popup or Inline JS, the PIN and OTP steps are handled automatically. You only need to manage them when using the Charge API directly.
  • Test mode transactions never hit real banks. They appear in your dashboard under the Test toggle, not Live.
  • Always verify test transactions server-side just like production. The sandbox verify endpoint returns the same structure as live.

Frequently Asked Questions

Can I use Stripe test card numbers on Paystack?
No. Stripe and Paystack are completely separate payment processors with different test environments. Stripe test card 4242 4242 4242 4242 means nothing to Paystack. Use Paystack test card 4084 0840 8408 4081 with CVV 408, PIN 408408, and OTP 123456.
What PIN and OTP do I use for Paystack test cards?
PIN is 408408 for all Paystack test cards. OTP is 123456 for a successful transaction and 123457 to simulate a failed transaction. These values are fixed and do not change.
Why does my test card work in Popup but fail on the Charge API?
The Charge API requires you to handle PIN and OTP submission manually. After the initial charge call, you must call /charge/submit_pin with PIN 408408, then /charge/submit_otp with OTP 123456. The Popup handles these steps automatically inside the iframe.
My test transactions do not appear in the Paystack dashboard. Where are they?
The Paystack dashboard has a toggle at the top that switches between Test and Live mode. Test transactions only appear when you are viewing Test mode. Click the toggle to switch.
Do sandbox transactions trigger webhooks?
Yes. Paystack sandbox sends webhook events just like live mode. You will receive charge.success, charge.failed, and other events to your configured webhook URL. This is one of the best things about the Paystack sandbox. Use it to test your webhook handlers before going live.

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