McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Paystack Signature Verification Failed: The Definitive Fix
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

Paystack Signature Verification Failed: The Definitive Fix

Your signature check fails because you are hashing a parsed-and-re-serialized JSON body instead of the raw bytes Paystack sent. Paystack computes the HMAC-SHA512 hash over the exact raw request body. If your framework (Express, Django, Laravel, Next.js) parses the body into an object before your verification code runs, and you then JSON.stringify that object, the resulting string may differ from the original. Whitespace, key order, and Unicode escaping can all change. The fix: capture the raw, unparsed request body and hash that. In Express, use express.raw() or a verify callback on express.json(). In Django, use request.body. In Laravel, use file_get_contents("php://input"). In Next.js App Router, use request.text().

In this article

  1. 1. What the Error Looks Like
  2. 2. How Paystack Signatures Work
  3. 3. The Raw Body Problem (Most Common Cause)
  4. 4. Fix for Express (Node.js)
  5. 5. Fix for Django (Python)
  6. 6. Fix for Laravel (PHP)
  7. 7. Other Causes of Signature Failure
  8. 8. Verification Test
  9. 9. Preventing This Error in Future Projects

What the Error Looks Like

You set up your Paystack webhook endpoint. You compute the HMAC hash. You compare it to the x-paystack-signature header. The comparison fails. Every single time.

Your logs show something like this:

// Your computed hash
Expected: a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0...

// Paystack's signature header
Received: 7d1e9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0...

Signature verification FAILED

The two hex strings do not match. Your webhook handler rejects the request, or worse, you skip verification and process unverified webhooks.

This is one of the most common Paystack integration issues. It is almost always caused by the same root problem: you are not hashing the raw request body.

How Paystack Signatures Work

When Paystack sends a webhook to your server, it does two things:

  1. Takes the exact JSON string it is about to send as the request body
  2. Computes HMAC-SHA512 of that string using your Paystack secret key
  3. Puts the resulting hex digest in the x-paystack-signature header

Your job is to do the same computation on your end and check that the results match:

const crypto = require('crypto');

// This is the CORRECT approach (conceptual)
const rawBody = getTheExactBytesPaystackSent(); // <-- this is where things go wrong
const secret = process.env.PAYSTACK_SECRET_KEY;

const hash = crypto
  .createHmac('sha512', secret)
  .update(rawBody)
  .digest('hex');

const signature = request.headers['x-paystack-signature'];

// Compare using constant-time function
const isValid = crypto.timingSafeEqual(
  Buffer.from(hash),
  Buffer.from(signature)
);

The algorithm is simple. The hard part is "get the exact bytes Paystack sent." That is where every framework trips you up.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

The Raw Body Problem (Most Common Cause)

Here is what happens in a typical Express app:

  1. Paystack sends a POST request with body: {"event":"charge.success","data":{"id":123}}
  2. Express middleware (express.json() or body-parser) intercepts the request
  3. The middleware parses the JSON into a JavaScript object: { event: "charge.success", data: { id: 123 } }
  4. Your webhook handler receives req.body as this parsed object
  5. You call JSON.stringify(req.body) to get a string for hashing
  6. JSON.stringify may produce: {"event":"charge.success","data":{"id":123}}

Step 6 looks the same. But it is not always the same. Here is why:

  • Key order. JSON.stringify may reorder keys. {"data":{"id":123},"event":"charge.success"} is valid JSON with the same data, but produces a completely different hash.
  • Whitespace. Paystack might send {"event": "charge.success"} with a space after the colon. JSON.stringify omits those spaces by default.
  • Unicode escaping. A character like é can be represented as the literal UTF-8 byte or as \u00e9. Parse-then-serialize may change one to the other.
  • Number formatting. The number 100.0 might become 100 after parsing and re-serializing.

Any of these differences means a different hash. The fix is to never parse the body before hashing it.

Fix for Express (Node.js)

You have two options in Express. Both work. Pick one.

Option 1: Use express.raw() on the webhook route only

const express = require('express');
const crypto = require('crypto');

const app = express();

// Use express.json() for all OTHER routes
app.use((req, res, next) => {
  if (req.path === '/webhooks/paystack') {
    return next(); // skip JSON parsing for webhook route
  }
  express.json()(req, res, next);
});

// Webhook route gets raw buffer
app.post(
  '/webhooks/paystack',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const secret = process.env.PAYSTACK_SECRET_KEY;
    const signature = req.headers['x-paystack-signature'];

    if (!signature) {
      return res.status(401).send('No signature');
    }

    // req.body is a Buffer here, not a parsed object
    const hash = crypto
      .createHmac('sha512', secret)
      .update(req.body)
      .digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature))) {
      return res.status(401).send('Invalid signature');
    }

    // Now parse the verified raw body
    const event = JSON.parse(req.body.toString());

    // Process the event
    console.log('Verified event:', event.event);

    res.sendStatus(200);
  }
);

Option 2: Use the verify callback on express.json()

const express = require('express');
const crypto = require('crypto');

const app = express();

app.use(
  express.json({
    verify: (req, res, buf) => {
      // Store the raw buffer on the request object
      req.rawBody = buf;
    },
  })
);

app.post('/webhooks/paystack', (req, res) => {
  const secret = process.env.PAYSTACK_SECRET_KEY;
  const signature = req.headers['x-paystack-signature'];

  if (!signature) {
    return res.status(401).send('No signature');
  }

  // Use the stored raw buffer, not JSON.stringify(req.body)
  const hash = crypto
    .createHmac('sha512', secret)
    .update(req.rawBody)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature))) {
    return res.status(401).send('Invalid signature');
  }

  // req.body is already parsed, safe to use
  const event = req.body;
  console.log('Verified event:', event.event);

  res.sendStatus(200);
});

Option 1 is cleaner because the webhook route explicitly gets raw bytes. Option 2 is convenient if you already have express.json() applied globally and cannot easily change the middleware order.

Fix for Django (Python)

Django gives you the raw body through request.body. This is the raw bytes before any processing. Do not use request.data from Django REST Framework, that is already parsed.

import hashlib
import hmac
import json
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.conf import settings


@csrf_exempt
@require_POST
def paystack_webhook(request):
    secret = settings.PAYSTACK_SECRET_KEY.encode('utf-8')
    signature = request.headers.get('X-Paystack-Signature', '')

    if not signature:
        return HttpResponse('No signature', status=401)

    # request.body is the raw bytes. This is what you hash.
    computed = hmac.new(
        secret,
        msg=request.body,
        digestmod=hashlib.sha512,
    ).hexdigest()

    if not hmac.compare_digest(computed, signature):
        return HttpResponse('Invalid signature', status=401)

    # Now parse the verified body
    event = json.loads(request.body)

    # Process the event
    print(f"Verified event: {event['event']}")

    return HttpResponse(status=200)

Key points for Django:

  • request.body is raw bytes. Always use this for hashing.
  • request.data (DRF) is parsed. Never use this for hashing.
  • hmac.compare_digest() does constant-time comparison. Do not use ==.
  • The @csrf_exempt decorator is required because Paystack cannot send a CSRF token. See Paystack Webhook Returning 403 in Django for details.

Fix for Laravel (PHP)

Laravel's middleware processes the request body before your controller sees it. Use file_get_contents('php://input') to read the raw body directly from PHP's input stream.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PaystackWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $secret = config('services.paystack.secret_key');
        $signature = $request->header('X-Paystack-Signature');

        if (!$signature) {
            return response('No signature', 401);
        }

        // Read the raw body directly from PHP input
        $rawBody = file_get_contents('php://input');

        $computed = hash_hmac('sha512', $rawBody, $secret);

        if (!hash_equals($computed, $signature)) {
            return response('Invalid signature', 401);
        }

        // Parse the verified raw body
        $event = json_decode($rawBody, true);

        // Process the event
        logger()->info('Verified event: ' . $event['event']);

        return response('OK', 200);
    }
}

Important notes for Laravel:

  • Do not use $request->all() or $request->getContent() after middleware has touched the request. file_get_contents('php://input') reads the raw stream directly.
  • hash_equals() is PHP's constant-time string comparison. Do not use ===.
  • You must exclude this route from CSRF verification. See Paystack Webhook Returning 419 in Laravel for how to do this in Laravel 11 and older versions.
  • Make sure no middleware is decompressing or transforming the body before your controller runs.

Other Causes of Signature Failure

If you have confirmed you are hashing the raw body and the signature still fails, check these:

1. Wrong secret key

You are using your test secret key but the webhook came from live mode (or vice versa). Paystack uses the secret key of the environment that triggered the event. If a live transaction fires a webhook, you must verify with your live secret key. Check your environment variables.

2. Secret key has extra whitespace

// WRONG: trailing newline from .env
PAYSTACK_SECRET_KEY="sk_live_abc123
"

// RIGHT: no trailing whitespace
PAYSTACK_SECRET_KEY=sk_live_abc123

Copy-pasting keys from the dashboard sometimes adds trailing whitespace or newline characters. Trim your key: process.env.PAYSTACK_SECRET_KEY.trim().

3. Reverse proxy modifying the body

If you have Nginx, Apache, or a load balancer in front of your app, check that it is not modifying, decompressing, or re-encoding the request body. Gzip decompression at the proxy level and then re-compression can change the bytes.

4. Encoding mismatch

Paystack sends UTF-8. If your server interprets the body as a different encoding (like Latin-1) and then re-encodes it, the bytes will differ. Make sure your server reads the body as UTF-8.

5. Request body being read twice

In some frameworks, the request body is a stream that can only be read once. If middleware reads it first, your verification code gets an empty body. Make sure no middleware consumes the body before your handler runs, or store the raw bytes on the first read.

Verification Test

After implementing the fix, verify it works with this test:

const crypto = require('crypto');

// Simulate what Paystack does
function simulatePaystackWebhook(secret) {
  const body = JSON.stringify({
    event: 'charge.success',
    data: {
      id: 123456789,
      reference: 'test_ref_001',
      amount: 50000,
      currency: 'NGN',
      status: 'success',
    },
  });

  const signature = crypto
    .createHmac('sha512', secret)
    .update(body)
    .digest('hex');

  return { body, signature };
}

// Test your verification function
function testVerification() {
  const secret = 'sk_test_xxxxx'; // use your test key

  const { body, signature } = simulatePaystackWebhook(secret);

  // Your verification logic
  const hash = crypto
    .createHmac('sha512', secret)
    .update(body) // must be the exact same string
    .digest('hex');

  const isValid = crypto.timingSafeEqual(
    Buffer.from(hash),
    Buffer.from(signature)
  );

  console.log('Signature valid:', isValid); // should be true

  // Now test with parsed-and-re-serialized body (should fail)
  const parsed = JSON.parse(body);
  const reserialized = JSON.stringify(parsed);

  const wrongHash = crypto
    .createHmac('sha512', secret)
    .update(reserialized)
    .digest('hex');

  console.log('Same after re-serialize:', body === reserialized);
  console.log('Hash matches with re-serialized:', wrongHash === hash);
  // These may or may not match. That is the problem.
}

testVerification();

You can also test with a real webhook. Set up your webhook URL using ngrok or Cloudflare Tunnel, add logging to your handler that prints both the computed hash and the received signature, and trigger a test transaction from the Paystack dashboard. If both hashes match, your fix is working.

For the complete list of Paystack errors, see Paystack Errors and Troubleshooting: The Complete Reference. For webhook setup patterns beyond signature verification, see Verifying the x-paystack-signature Header Correctly.

Preventing This Error in Future Projects

Set up your webhook route correctly from the start with these rules:

  1. Isolate the webhook route from body-parsing middleware. In Express, apply express.raw() specifically to the webhook path. In Django, the raw body is always available. In Laravel, read php://input directly.
  2. Write a signature verification utility once. Put it in a shared module. Reuse it across projects. Do not re-implement it each time.
  3. Add a unit test for signature verification. Generate a known body/signature pair and assert your verification function accepts it. Add a test where you tamper with the body and assert verification fails.
  4. Never skip verification in production. "It works without verification" is not a reason to skip it. Unsigned webhooks mean anyone who discovers your endpoint can fake payment confirmations.
  5. Log verification failures. When a signature check fails, log the event (without the secret key) so you can debug. Include the request path, the first 10 characters of the expected and received hashes, and the content type header.

Key Takeaways

  • ✓Paystack signs the exact raw bytes of the request body with HMAC-SHA512 using your secret key. Your verification must hash those same exact bytes. If even one character differs, the signature will not match.
  • ✓The number one cause of signature failure is framework middleware parsing the body before your verification code runs. Express body-parser, Django middleware, and Laravel all do this by default.
  • ✓Never call JSON.parse() on the body and then JSON.stringify() it back for hashing. Re-serialization can change key order, whitespace, and Unicode escapes, producing a different hash.
  • ✓In Express, use express.raw({ type: "application/json" }) on your webhook route, or pass a verify callback to express.json() that stores the raw buffer.
  • ✓In Django, use request.body directly. It is the raw bytes before Django processes anything. Do not use request.data from Django REST Framework for signature verification.
  • ✓In Laravel, use file_get_contents("php://input") to get the raw body. Do not use $request->all() or $request->getContent() after middleware has processed the request.
  • ✓Always compare signatures using a constant-time comparison function (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python) to prevent timing attacks.

Frequently Asked Questions

Can I use JSON.stringify(req.body) instead of the raw body for signature verification?
No. JSON.stringify may produce a different string than what Paystack sent. Key order, whitespace, Unicode escaping, and number formatting can all differ after a parse-and-re-serialize cycle. You must use the raw, unparsed request body.
Does Paystack always use HMAC-SHA512 for webhook signatures?
Yes. Paystack uses HMAC-SHA512 with your secret key to sign all webhook payloads. The resulting hex digest is placed in the x-paystack-signature header. This has been consistent across all Paystack API versions.
Should I use the test secret key or live secret key for verification?
Use the secret key that matches the environment that sent the webhook. If the webhook was triggered by a live transaction, use your live secret key. If it was triggered by a test transaction, use your test secret key. In production, you should only receive live webhooks, so use the live key.
What is a timing attack and why do I need timingSafeEqual?
A timing attack measures how long a string comparison takes to fail. Regular === comparison exits on the first mismatched character, so an attacker can guess the correct hash one character at a time by measuring response times. crypto.timingSafeEqual (Node.js) and hmac.compare_digest (Python) compare all characters in constant time, preventing this attack.
My signature was working and suddenly stopped. What changed?
Check three things. First, did you regenerate your Paystack secret key? A new key means the old one no longer verifies incoming webhooks. Second, did you change your framework version or middleware configuration? A framework update might change how body parsing works. Third, did you add a reverse proxy or CDN that modifies request bodies?

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Errors and Troubleshooting: The Complete Reference

Every Paystack error you will hit in production, what causes it, and how to fix it. Authentication failures, webhook signature problems, transaction errors, transfer issues, and frontend bugs. Code-first solutions for each one.

Read more

Paystack "Declined by Financial Institution" Error

The "Declined by financial institution" response means the customer's bank rejected the transaction. This is not a Paystack error or a bug in your code. The bank made the decision, and neither you nor Paystack can override it.

Read more

Paystack Transfer OTP Required Error

Your transfer call returns an OTP required error because Paystack protects every transfer with a one-time password by default. To automate transfers, you must call the disable_otp endpoint, confirm via the finalize_disable_otp endpoint, and accept the security trade-off. This guide walks through every step with code.

Read more

Paystack Rate Limit and 429 Handling

Paystack returns 429 Too Many Requests when you exceed their API rate limit. This guide covers the actual limits, how to implement exponential backoff, when to cache responses, and how to batch operations to avoid hitting the limit.

Read more

Testing Paystack Webhooks in CI Without a Public URL

Your CI server has no public URL. Paystack cannot reach it. This guide shows how to test your webhook handler in GitHub Actions, GitLab CI, and other pipelines by generating valid HMAC-signed payloads locally and sending them to your handler with HTTP requests.

Read more

Simulating Declined Cards on Paystack

A card decline is one of the most common failure states in a payment flow, and your application must handle it gracefully. This guide shows how to simulate specific card declines in Paystack test mode and how to verify your decline handling code works correctly.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.