Bonaventure OgetoBy Bonaventure Ogeto|

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().

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.

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?

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