Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Errors and Troubleshooting: The Complete Reference

To fix Paystack errors, check the HTTP status code and response body. A 401 means your secret key is wrong or missing. A 400 means your request payload is malformed. For webhook signature failures, hash the raw request body, not a parsed object. For transaction errors, read the gateway_response field to see why the payment failed.

How Paystack Errors Work

When something goes wrong with a Paystack API call, the response tells you three things: the HTTP status code, a JSON body with a status field (always false for errors), and a message field that explains what happened. Some responses also include a data object with extra details.

Here is what a typical error response looks like:

HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "status": false,
  "message": "Invalid key"
}

And here is what a successful transaction that was declined by the bank looks like:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": true,
  "message": "Verification successful",
  "data": {
    "status": "failed",
    "gateway_response": "Declined",
    "amount": 500000,
    "currency": "NGN"
  }
}

Notice that second example. The HTTP status is 200. The top-level status is true. But the transaction itself failed. This catches a lot of developers off guard. A 200 from Paystack does not mean the payment succeeded. You must check data.status for the actual transaction outcome.

The HTTP status codes Paystack uses follow standard conventions:

  • 200 - Request succeeded (but the transaction might still have failed)
  • 201 - Resource created successfully
  • 400 - Bad request. Your payload is missing required fields or has invalid values
  • 401 - Unauthorized. Your API key is wrong, missing, or does not match the environment
  • 404 - Resource not found. The transaction reference, customer code, or plan code does not exist
  • 429 - Rate limited. You are sending too many requests too fast
  • 5xx - Server error on Paystack's side. Retry with backoff

The gateway_response field is specific to transaction verification and charge responses. It contains the message from the card processor or bank, things like "Approved", "Declined", "Insufficient Funds", or "Do Not Honor". This field is your direct line to understanding why a payment failed at the banking level, not at the Paystack level.

For a deep breakdown of every gateway response code and what each one means for your application, see Reading Paystack Gateway Response Codes.

Authentication Errors (401 Unauthorized)

You call /transaction/initialize. You get back 401 Unauthorized with "Invalid key". It is 2am and you have been staring at this for an hour.

Here are the four reasons this happens, in order of likelihood:

1. You used the public key instead of the secret key.

Paystack gives you two key pairs: public keys (starting with pk_test_ or pk_live_) and secret keys (starting with sk_test_ or sk_live_). Public keys are for the frontend checkout popup. Secret keys are for server-side API calls. If you send a public key in the Authorization header of a server API request, you get a 401.

// WRONG: public key on server
const response = await fetch('https://api.paystack.co/transaction/initialize', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer pk_test_xxxxx', // This will 401
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ email, amount }),
});

// RIGHT: secret key on server
const response = await fetch('https://api.paystack.co/transaction/initialize', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer sk_test_xxxxx', // Secret key
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ email, amount }),
});

2. You mixed test and live keys.

Test keys only work in test mode. Live keys only work in live mode. If you initialized a transaction with a test secret key and then try to verify it with a live secret key (or vice versa), you will get a 401 or a "Transaction not found" error. This commonly happens when your environment variables are set differently between your frontend and backend, or between your development and production servers.

3. You forgot the "Bearer " prefix.

// WRONG: missing "Bearer " prefix
headers: {
  Authorization: 'sk_test_xxxxx',
}

// RIGHT: include "Bearer " with the trailing space
headers: {
  Authorization: 'Bearer sk_test_xxxxx',
}

4. Your key has extra whitespace or was truncated.

Copy-paste errors are real. Your .env file might have a trailing space, a newline character, or the key got cut off. Print your key length to verify:

console.log('Key length:', process.env.PAYSTACK_SECRET_KEY?.length);
console.log('Key starts with:', process.env.PAYSTACK_SECRET_KEY?.substring(0, 8));

A valid test secret key starts with sk_test_ and is typically around 40 to 50 characters long. If your length is off, you have a copy-paste problem.

For a full walkthrough with framework-specific fixes, read Paystack Invalid Key: Every Cause and Fix and Paystack 401 Unauthorized on Transaction Initialize.

Webhook Errors

Webhook problems are the second most common category of Paystack errors, and they are harder to debug because you cannot see the request in your browser. Paystack sends the webhook to your server, your server responds with an error, and Paystack stops trying after a few retries. Meanwhile, your users have paid but your app did not record the payment.

Signature verification failed

Paystack signs every webhook with an HMAC SHA-512 hash using your secret key. The signature arrives in the x-paystack-signature header. You are supposed to hash the raw request body with the same secret key and compare the result. If they do not match, you reject the webhook.

The problem: most web frameworks parse the JSON body automatically before your handler sees it. When you re-stringify a parsed object, the output can differ from the original bytes (key ordering, whitespace, unicode escaping). Your hash will not match.

// WRONG: hashing a re-stringified parsed object
app.post('/webhook', express.json(), (req, res) => {
  const hash = crypto
    .createHmac('sha512', secret)
    .update(JSON.stringify(req.body)) // This is NOT the original bytes
    .digest('hex');
  // hash will NOT match x-paystack-signature
});

// RIGHT: hash the raw body
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const hash = crypto
    .createHmac('sha512', secret)
    .update(req.body) // req.body is a Buffer of the raw bytes
    .digest('hex');

  if (hash === req.headers['x-paystack-signature']) {
    const event = JSON.parse(req.body.toString());
    // Process event
    res.sendStatus(200);
  } else {
    res.sendStatus(401);
  }
});

This exact problem manifests differently in every framework. See the dedicated guides for your stack:

Webhook not firing at all

If your webhook endpoint never receives a request:

  1. Check that your webhook URL is set in the Paystack dashboard under Settings > API Keys & Webhooks
  2. Confirm the URL is publicly accessible (not localhost, not behind a VPN)
  3. Verify your SSL certificate is valid. Paystack will not send webhooks to endpoints with invalid or self-signed certificates
  4. Check the Paystack dashboard under Transactions > select a transaction > Timeline to see if the webhook was sent and what response your server returned

For the full diagnostic process, see Paystack Webhook Not Firing: Diagnostic Checklist. For a complete overview of how Paystack webhooks work, read the Paystack Webhooks Complete Engineering Guide.

Transaction Errors

Transaction errors happen after authentication succeeds. Your API call went through, but the transaction itself has a problem.

Duplicate transaction reference

Every transaction you initialize needs a unique reference. If you send a reference that already exists, Paystack returns a 400 with "Duplicate Transaction Reference". This usually happens when you do not generate a new reference for each payment attempt, or when a user clicks the "Pay" button twice quickly.

// Generate a unique reference for each attempt
import crypto from 'crypto';

function generateReference(): string {
  return 'txn_' + crypto.randomBytes(16).toString('hex');
}

// Use it when initializing
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: 'customer@example.com',
    amount: 500000, // 5000 NGN in kobo
    reference: generateReference(),
  }),
});

On the frontend, disable the pay button after the first click to prevent double submissions. See Paystack Duplicate Transaction Reference Error for edge cases and database-level solutions.

Amount too low

Paystack enforces minimum transaction amounts that vary by currency. For NGN, the minimum is typically 100 kobo (1 NGN). For KES, it is 100 cents (1 KES). If your amount is below the minimum, you get a 400 error. Remember that Paystack expects amounts in the smallest currency unit: kobo for NGN, cents for KES and GHS, and so on.

// WRONG: sending Naira instead of kobo
body: JSON.stringify({
  email: 'customer@example.com',
  amount: 50, // This is 50 kobo = 0.5 NGN, might be below minimum
})

// RIGHT: convert to kobo first
const amountInNaira = 5000;
body: JSON.stringify({
  email: 'customer@example.com',
  amount: amountInNaira * 100, // 500000 kobo = 5000 NGN
})

See Paystack Amount Too Low Error and Paystack Invalid Amount Error and Kobo Confusion for the full list of minimums and common mistakes.

Currency not supported

Your Paystack account is tied to specific currencies based on your registered country and business type. If you try to charge in a currency your account does not support, you get "Currency not supported". A Nigerian business account supports NGN by default. To accept KES, GHS, ZAR, or USD, you need to enable those currencies in your dashboard or contact Paystack support. Details in Paystack Currency Not Supported Error.

Transaction timed out

A customer opened the checkout but did not complete payment within the time window. Paystack marks the transaction as abandoned or failed with gateway_response: "Timed out". This is not a bug. It is normal user behaviour. Your app should handle it by allowing the user to try again with a new transaction reference. See Paystack Transaction Timed Out.

Transfer Errors

Transfers (sending money from your Paystack balance to a bank account or mobile wallet) have their own category of errors because they involve your Paystack balance, banking rails, and sometimes manual approval steps.

Insufficient balance

You tried to initiate a transfer but your available Paystack balance is too low. The response looks like:

{
  "status": false,
  "message": "You do not have a sufficient balance to complete this transfer."
}

This is straightforward: your available balance (not your ledger balance) must cover the transfer amount plus Paystack's transfer fee. Check your balance via the API:

const response = await fetch('https://api.paystack.co/balance', {
  headers: {
    Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
  },
});
const { data } = await response.json();
// data is an array of balances by currency
// Each entry has: currency, balance (total), and available_balance (what you can actually transfer)

Your code should check the available balance before attempting a transfer and alert your operations team when it drops below a threshold. See Paystack Insufficient Balance on Transfer.

OTP required

When transfer OTP is enabled on your Paystack account (it is on by default), every transfer requires an OTP sent to your registered email or phone. This is a security feature, not a bug. For automated transfers, you need to either disable OTP via the dashboard (only do this if you have other security controls in place) or handle the OTP flow programmatically using the /transfer/finalize endpoint.

Full walkthrough in Paystack Transfer OTP Required Error.

Failed transfers

A transfer can fail after you initiate it. Common reasons include invalid account numbers, banks rejecting the transfer, or the receiving bank being temporarily unreachable. Failed transfers are reported through webhooks (transfer.failed event) and through the transaction status when you query it.

Your code should never assume a transfer succeeded just because the initiation call returned 200. Always listen for the transfer.success or transfer.failed webhook, or poll the transfer status. Automatic retries for failed transfers are dangerous because you risk sending money twice. Instead, flag the failure, alert your team, and let a human decide whether to retry.

See Paystack Transfer Failed: Reasons and Recovery.

Frontend Errors

Frontend Paystack errors are especially frustrating because they often produce no useful error message. The popup just does not open, or the script fails to load, and your console shows a cryptic message about policies or origins.

Popup not opening

The Paystack Inline JS popup requires that you call PaystackPop.setup() with a valid public key, amount, and email. If any of these are missing or invalid, the popup silently fails. Debug checklist:

  1. Open your browser console. Look for JavaScript errors
  2. Confirm the Paystack Inline script tag is loaded: <script src="https://js.paystack.co/v2/inline.js"></script>
  3. Check that your public key starts with pk_test_ or pk_live_
  4. Verify the amount is in the smallest unit (kobo, pesewas, cents) and is a positive integer
  5. Make sure the email is a valid email string

Full debug guide: Paystack Popup Not Opening: Frontend Debug Guide.

Content Security Policy (CSP) blocking the popup

If your site uses a Content Security Policy header, it may block the Paystack script from loading or the popup iframe from opening. You need to allow Paystack's domains in your CSP:

Content-Security-Policy:
  script-src 'self' https://js.paystack.co;
  frame-src 'self' https://checkout.paystack.com;
  connect-src 'self' https://api.paystack.co;

The exact domains may change, so check the Paystack documentation for the current list. See Paystack Checkout Blocked by Content Security Policy.

Mixed content errors

If your site is served over HTTP (not HTTPS), the browser will block the Paystack script because it is loaded over HTTPS. This is a mixed content violation. The fix is to serve your site over HTTPS. In 2026, there is no reason not to. Free certificates from Let's Encrypt, automatic HTTPS on Vercel, Netlify, and Cloudflare. See Paystack Mixed Content and HTTPS Errors.

CORS errors

If you see a CORS error in your console when calling the Paystack API, you are calling the API from your frontend JavaScript. Do not do this. The Paystack API is meant to be called from your server. The only Paystack code that runs in the browser is the Inline JS popup script. If you call https://api.paystack.co/transaction/initialize from a fetch() call in your React component, the browser will block it with a CORS error, and that is correct behaviour.

The fix: move the API call to your backend. Your frontend sends the payment details to your server, your server calls Paystack, and your server returns the authorization URL or access code to the frontend. Full explanation: Paystack CORS Errors and What They Really Mean.

The Debugging Toolkit

When an error message is not enough, Paystack gives you three tools to dig deeper.

1. Transaction Timeline (Dashboard)

Every transaction in the Paystack dashboard has a Timeline tab. It shows a step-by-step log of everything that happened: when the transaction was initialized, when the customer entered their card, when the charge was sent to the bank, what the bank responded, whether the webhook was sent, and what your server responded to the webhook.

This is the single most useful debugging tool for Paystack. If a customer says "I paid but didn't get my item", go to the dashboard, find the transaction, and read the timeline. It will tell you exactly where things broke. Did the bank decline it? Did your webhook endpoint return a 500? Did the webhook never fire because your URL was wrong?

Detailed walkthrough: Debugging Paystack with the Transaction Timeline.

2. Paystack CLI

The Paystack CLI lets you forward webhooks to your local machine during development. Instead of deploying to a public URL just to test webhooks, you run a command and Paystack sends events directly to your localhost.

# Install the CLI
npm install -g paystack-cli

# Login with your secret key
paystack login

# Forward webhooks to local port
paystack webhook listen --forward-to http://localhost:3000/api/webhook

This eliminates the need for ngrok or Cloudflare Tunnel during development (though those remain valid alternatives). See Debugging Paystack with the Paystack CLI [TODO: verify CLI installation command and exact flags].

3. Gateway response codes

When a card payment fails, the gateway_response field in the transaction data contains the reason. These codes come from the card network or the issuing bank, not from Paystack. Common ones include:

  • "Approved" - Transaction succeeded
  • "Declined" - Generic decline, the bank gave no specific reason
  • "Insufficient Funds" - The card does not have enough money
  • "Do Not Honor" - The bank refused the transaction (fraud suspicion, account restrictions, etc.)
  • "Invalid Transaction" - The card does not support this type of transaction
  • "Card Expired" - The card's expiry date has passed

Your application should display user-friendly messages based on these codes. Telling a customer "Do Not Honor" is not helpful. Telling them "Your bank declined this transaction. Please try a different card or contact your bank" is better. See Reading Paystack Gateway Response Codes for the full code list and suggested user-facing messages.

Error Handling Patterns

Knowing what each error means is only half the job. You also need patterns for handling errors gracefully in production code.

Retry with exponential backoff

For 5xx errors and 429 (rate limit) responses, retry the request after a delay. Double the delay each time. Cap it at a maximum number of retries.

async function callPaystackWithRetry(
  url: string,
  options: RequestInit,
  maxRetries = 3
): Promise<Response> {
  let lastError: Error | null = null;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      // Don't retry client errors (4xx) except 429
      if (response.status < 500 && response.status !== 429) {
        return response;
      }

      // For 429, check the Retry-After header if present
      if (response.status === 429) {
        const retryAfter = response.headers.get('Retry-After');
        const delay = retryAfter
          ? parseInt(retryAfter, 10) * 1000
          : Math.pow(2, attempt) * 1000;
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }

      // For 5xx, use exponential backoff
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
    } catch (error) {
      lastError = error as Error;
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw lastError || new Error(`Paystack API failed after ${maxRetries} retries`);
}

Never retry charge or transfer endpoints blindly. Retrying a /transaction/charge_authorization call could charge the customer twice. Retrying a /transfer call could send money twice. Only retry read operations (verify, list, fetch) and the initial /transaction/initialize (which does not charge anyone, it just creates a payment page).

Graceful degradation

When Paystack is down (it happens, check status.paystack.com), your app should not crash. Show a message like "Payments are temporarily unavailable. Please try again in a few minutes." If you have a secondary payment provider, you could fall back to it. See When Paystack Is Down: Building Graceful Degradation.

Logging without leaking secrets

Log every Paystack API call and response for debugging. But never log the full secret key, full card numbers, or full CVVs. Mask sensitive fields before logging:

function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
  const sanitized = { ...data };
  if (typeof sanitized.authorization_code === 'string') {
    sanitized.authorization_code = '***' + sanitized.authorization_code.slice(-4);
  }
  if (typeof sanitized.bin === 'string') {
    sanitized.bin = sanitized.bin.slice(0, 4) + '****';
  }
  return sanitized;
}

// In your webhook handler:
console.log('Paystack webhook received:', {
  event: event.event,
  reference: event.data.reference,
  status: event.data.status,
  amount: event.data.amount,
  // Do NOT log: event.data.authorization (contains card details)
});

Log the transaction reference, the event type, the status, and the amount. That is enough to debug almost any issue. Leave card details out of your logs entirely.

Every Error Guide in This Section

This article gives you the overview. The guides below go deep on each specific error, with full code examples and step-by-step fixes.

Authentication and key errors:

Webhook errors:

Transaction errors:

Transfer and payout errors:

Frontend and client-side errors:

Recurring payment and subscription errors:

Refund, dispute, and settlement errors:

Infrastructure and tooling:

Channel-specific errors:

This errors and troubleshooting section is part of the Paystack Complete Engineering Guide. For webhook-specific deep dives, see the Webhooks cluster. For testing patterns that prevent errors before they reach production, see the Testing cluster.

If you want to build production payment integrations with hands-on guidance, the McTaba Full-Stack Software and AI Engineering self-paced course covers Paystack integration from first API call to production deployment, with code reviews and mentor support along the way.

Key Takeaways

  • Every Paystack API error returns a JSON body with status, message, and sometimes a data object. The message field is your first clue. For transaction failures, the gateway_response field tells you what the bank or card network actually said.
  • The most common Paystack error is a 401 Unauthorized, almost always caused by using a public key where a secret key is required, mixing up test and live keys, or forgetting the "Bearer " prefix in the Authorization header.
  • Webhook signature verification fails when your framework parses the request body before you hash it. You must hash the raw bytes, not a JSON-parsed-then-re-stringified object. This bites Express, Django, Laravel, and Next.js developers in different ways.
  • Transfer errors like "insufficient balance" and "OTP required" are not bugs in your code. They are operational issues that require dashboard action or Paystack support. Your code should detect them and alert your team instead of retrying blindly.
  • Paystack provides three debugging tools you should know: the Transaction Timeline in the dashboard, the Paystack CLI for local webhook testing, and gateway response codes that map to specific bank-level failures.

Frequently Asked Questions

Why does Paystack return 200 but the payment still failed?
A 200 status code means your API request was valid and Paystack processed it. But the transaction itself can still fail at the bank level. Always check <code>data.status</code> in the response body. If it is <code>"failed"</code> or <code>"abandoned"</code>, the payment did not go through. The <code>gateway_response</code> field tells you why the bank declined it.
How do I fix "Invalid key" on Paystack?
Check four things: (1) You are using the secret key (<code>sk_test_</code> or <code>sk_live_</code>), not the public key, for server-side API calls. (2) You are not mixing test and live keys between your frontend and backend. (3) Your Authorization header includes the <code>Bearer </code> prefix with a space. (4) Your key was not truncated or corrupted during copy-paste. Print the key length and first 8 characters to verify.
Why is my Paystack webhook signature verification failing?
You are almost certainly hashing a parsed-and-re-stringified JSON object instead of the raw request body bytes. When your web framework parses the incoming JSON, then you call <code>JSON.stringify()</code> on the parsed result, the output may differ from the original payload (different key order, different whitespace). Use <code>express.raw()</code> in Express, read <code>php://input</code> in PHP, or use <code>request.body</code> before Django/Laravel middleware processes it.
What should I do when Paystack returns a 429 rate limit error?
Wait and retry. Check the <code>Retry-After</code> header in the response for the recommended wait time. If the header is not present, use exponential backoff: wait 1 second, then 2, then 4, and so on. If you are consistently hitting rate limits, you are likely making too many API calls. Batch operations where possible, cache responses you query frequently, and avoid polling the verify endpoint in a tight loop.
Can I call the Paystack API directly from my frontend JavaScript?
No. The Paystack REST API (<code>api.paystack.co</code>) requires your secret key, which must never be exposed in browser code. API calls from the browser will also fail with CORS errors. The only Paystack code that runs in the browser is the Inline JS popup script, which uses your public key. All other API calls (initialize, verify, transfer, etc.) must go through your backend server.

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