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. Verifying the x-paystack-signature Header Correctly
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

Verifying the x-paystack-signature Header Correctly

To verify the x-paystack-signature header, compute an HMAC SHA512 hash of the raw request body using your Paystack secret key, then compare the result to the header value. If they match, the request is authentic. Use crypto.createHmac in Node.js, hmac.new in Python, or hash_hmac in PHP. Always hash the raw body bytes, not a re-serialized object.

In this article

  1. 1. What the x-paystack-signature Header Is
  2. 2. The Verification Process Step by Step
  3. 3. Express (Node.js) Implementation
  4. 4. Why You Need Timing-Safe Comparison
  5. 5. Django (Python) Notes
  6. 6. Laravel (PHP) Notes
  7. 7. Common Verification Mistakes
  8. 8. Debugging Signature Failures

What the x-paystack-signature Header Is

Every webhook request from Paystack includes a header called x-paystack-signature. This header contains a hex-encoded HMAC SHA512 hash. The hash is computed over the entire raw request body using your Paystack secret key as the HMAC key.

This serves two purposes:

  • Authentication. It proves the request came from Paystack (or someone who has your secret key). No one else can produce a valid hash without knowing the key.
  • Integrity. It proves the request body was not modified in transit. If a middlebox, proxy, or attacker changes even one byte of the body, the hash will not match.

If you skip this check, your webhook endpoint is open to spoofing. Anyone who discovers your webhook URL (through logs, error messages, code repositories, or brute force) can POST fake events to it. A fake charge.success event could trick your system into granting value for a payment that never happened.

Signature verification is not optional. It is the primary security mechanism for your webhook endpoint.

The Verification Process Step by Step

The process is the same regardless of your programming language or framework:

  1. Read the raw request body. You need the exact bytes that Paystack sent. Not a parsed JSON object. Not a re-serialized string. The raw bytes.
  2. Get your Paystack secret key. This is the sk_live_ or sk_test_ key from your Paystack Dashboard. Store it in an environment variable. Never hardcode it.
  3. Compute the HMAC SHA512 hash. Use the secret key as the HMAC key and the raw body as the message. Output the result as a lowercase hexadecimal string.
  4. Read the x-paystack-signature header. This is the expected hash value from Paystack.
  5. Compare the two values. If they match, the request is authentic. If they do not match, reject the request.

That is the entire process. The complexity comes from step 1 (getting the raw body before your framework parses it) and step 5 (using timing-safe comparison). We will cover both.

Get weekly developer tips

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

No spam. Unsubscribe anytime.

Express (Node.js) Implementation

Here is the complete, production-ready verification in Express:

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

const app = express();

// IMPORTANT: Do NOT apply express.json() globally before this route.
// If you need express.json() on other routes, apply it selectively.

app.post(
  '/webhooks/paystack',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const secret = process.env.PAYSTACK_SECRET_KEY;

    if (!secret) {
      console.error('PAYSTACK_SECRET_KEY is not set');
      return res.status(500).send('Server configuration error');
    }

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

    if (!signature) {
      return res.status(400).send('Missing signature header');
    }

    // Step 1: Compute the HMAC SHA512 hash of the raw body
    const hash = crypto
      .createHmac('sha512', secret)
      .update(req.body) // req.body is a Buffer because of express.raw()
      .digest('hex');

    // Step 2: Compare using timing-safe comparison
    const hashBuffer = Buffer.from(hash, 'utf8');
    const signatureBuffer = Buffer.from(signature, 'utf8');

    if (hashBuffer.length !== signatureBuffer.length) {
      return res.status(400).send('Invalid signature');
    }

    if (!crypto.timingSafeEqual(hashBuffer, signatureBuffer)) {
      return res.status(400).send('Invalid signature');
    }

    // Signature verified. Parse the body and process.
    const event = JSON.parse(req.body.toString());
    console.log('Verified event:', event.event);

    // Return 200 immediately, queue heavy work
    res.status(200).send('OK');
  }
);

Key details in this code:

  • express.raw({ type: 'application/json' }) tells Express to keep the body as a Buffer instead of parsing it as JSON. This is the raw body you need for the hash.
  • crypto.createHmac('sha512', secret) creates an HMAC instance using SHA512 and your secret key.
  • .update(req.body) feeds the raw body into the hash. Since req.body is a Buffer, the bytes are exactly what Paystack sent.
  • .digest('hex') outputs the hash as a lowercase hex string, which matches the format Paystack uses.
  • crypto.timingSafeEqual prevents timing attacks (explained in the next section).

If you are using express.json() as global middleware, your webhook route will break. See the raw body problem for why and how to fix it.

Why You Need Timing-Safe Comparison

A simple string comparison (hash === signature) technically works. It will correctly tell you if the hashes match or not. But it leaks information through timing.

When you compare two strings character by character, the comparison stops at the first mismatch. If the first character is wrong, it returns false almost instantly. If the first 127 characters match and only the last one differs, it takes measurably longer. An attacker who can measure response times (down to microseconds) can use this to guess the correct hash one character at a time.

This is called a timing attack. It is not theoretical. It has been used in real exploits against web applications.

crypto.timingSafeEqual always takes the same amount of time regardless of where the mismatch occurs. It compares every byte, every time. This removes the timing signal.

// WRONG: vulnerable to timing attacks
if (hash === signature) {
  // process event
}

// RIGHT: timing-safe comparison
const hashBuffer = Buffer.from(hash, 'utf8');
const signatureBuffer = Buffer.from(signature, 'utf8');

// Buffers must be the same length for timingSafeEqual
if (hashBuffer.length !== signatureBuffer.length) {
  return res.status(400).send('Invalid signature');
}

if (crypto.timingSafeEqual(hashBuffer, signatureBuffer)) {
  // process event
}

The length check before timingSafeEqual is necessary because the function throws an error if the buffers have different lengths. In practice, both the computed hash and the Paystack signature should always be 128 hex characters (512 bits / 4 bits per hex character), so the length check is a safety net rather than a common branch.

Is this overkill for most applications? Probably. But it costs nothing to implement, and it removes an entire class of attack. Do it.

Django (Python) Notes

Django gives you the raw body through request.body, which returns bytes. This is straightforward because Django does not automatically parse JSON request bodies the way Express does.

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

@csrf_exempt  # Paystack webhook POST will not carry a CSRF token
@require_POST
def paystack_webhook(request):
    secret = os.environ['PAYSTACK_SECRET_KEY'].encode('utf-8')
    signature = request.headers.get('X-Paystack-Signature', '')

    # request.body is already raw bytes
    computed = hmac.new(
        secret,
        msg=request.body,
        digestmod=hashlib.sha512
    ).hexdigest()

    # Python's hmac.compare_digest is timing-safe
    if not hmac.compare_digest(computed, signature):
        return HttpResponse('Invalid signature', status=400)

    event = json.loads(request.body)
    # process event...

    return HttpResponse('OK', status=200)

Key differences from Express:

  • request.body in Django is already raw bytes. No special middleware configuration needed.
  • hmac.compare_digest is Python's built-in timing-safe comparison. Use it instead of ==.
  • The @csrf_exempt decorator is required. Without it, Django rejects the webhook POST with a 403 because it has no CSRF token. See webhooks and CSRF in Django.
  • The secret key must be encoded to bytes (.encode('utf-8')) before passing it to hmac.new.

Laravel (PHP) Notes

In Laravel, the request body is typically consumed by middleware before your controller runs. To get the raw body for HMAC verification, use file_get_contents('php://input') or access it through the request object's getContent() method.

// In your controller or route handler
use Illuminate\Http\Request;

public function handleWebhook(Request $request)
{
    $secret = env('PAYSTACK_SECRET_KEY');
    $signature = $request->header('X-Paystack-Signature');

    // Get the raw body
    $rawBody = $request->getContent();

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

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

    $event = json_decode($rawBody, true);
    // process event...

    return response('OK', 200);
}

Key differences:

  • $request->getContent() returns the raw body as a string.
  • hash_hmac('sha512', $rawBody, $secret) computes the HMAC in one call.
  • hash_equals is PHP's timing-safe comparison function. Use it instead of ===.
  • You must exempt the webhook route from CSRF verification. Add it to the $except array in VerifyCsrfToken middleware. See webhooks and CSRF in Laravel.

Common Verification Mistakes

Mistake 1: Hashing the parsed body instead of the raw body. This is the most common failure. If your framework parsed the JSON into an object and you re-serialize it with JSON.stringify, the output may differ from the original (different whitespace, different key order, different Unicode escaping). The hash will not match. Always hash the raw bytes. See the raw body problem for the full explanation.

Mistake 2: Using the wrong key. Use your secret key (sk_live_ or sk_test_), not your public key (pk_live_ or pk_test_). The public key is for client-side checkout initialization. The secret key is for server-side operations including webhook verification.

Mistake 3: Using the test key in production or vice versa. Test mode events are signed with your test secret key. Live mode events are signed with your live secret key. If your production server is configured with the test key, every live webhook will fail verification. Double-check your environment variables after deployment.

Mistake 4: Not checking for the header at all. If x-paystack-signature is missing from the request, the request did not come from Paystack (or something stripped the header in transit). Reject it. Do not compare your computed hash against undefined or an empty string.

Mistake 5: Logging the secret key. When debugging signature failures, developers sometimes log the secret key. Do not. Log the computed hash and the received signature (both are safe to log), but never the key itself. If your logs are compromised, the attacker can forge any webhook.

Mistake 6: Hardcoding the secret key. Store it in an environment variable. If you hardcode it in your source code and your repository is public (or gets leaked), your webhook endpoint is compromised.

Debugging Signature Failures

When your signature check fails and you cannot figure out why, follow this checklist:

  1. Log the raw body length and the first 100 characters. Compare them to what you see in the Paystack Dashboard webhook logs. If the lengths differ, something is modifying the body (middleware, proxy, or encoding).
  2. Log the computed hash and the received signature. If the computed hash is all zeros or empty, your secret key is probably not loaded correctly.
  3. Check your secret key environment variable. Print its length (not its value) to confirm it is loaded and not truncated.
  4. Send a test webhook from the Paystack Dashboard. Go to Settings, API Keys and Webhooks, and click the test button. This lets you compare a known payload against your verification logic.
  5. Simulate a webhook locally with curl. Compute the signature yourself and send it. If your handler accepts this but rejects real Paystack webhooks, the body is being modified somewhere between Paystack and your handler.
# Simulate a webhook locally
SECRET="sk_test_your_secret_key"
BODY='{"event":"charge.success","data":{"reference":"test-ref-001","amount":50000}}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')

curl -X POST http://localhost:3000/webhooks/paystack   -H "Content-Type: application/json"   -H "x-paystack-signature: $SIGNATURE"   -d "$BODY"

If your handler accepts this curl request but rejects real Paystack webhooks, the problem is almost certainly the raw body issue. A proxy or middleware is modifying the body between Paystack and your verification code. See the raw body problem and webhooks behind Nginx and a reverse proxy.

This article is part of the Paystack webhooks engineering guide.

Key Takeaways

  • ✓The x-paystack-signature header contains an HMAC SHA512 hash of the raw request body, signed with your Paystack secret key.
  • ✓You must hash the raw body bytes exactly as Paystack sent them. Parsing and re-serializing the JSON will change the bytes and break the signature.
  • ✓Use crypto.createHmac("sha512", secret).update(rawBody).digest("hex") in Node.js to compute the hash.
  • ✓Use timing-safe comparison (crypto.timingSafeEqual) instead of simple string equality to prevent timing attacks.
  • ✓Never skip signature verification. Without it, anyone who knows your webhook URL can send fake payment confirmations.
  • ✓Django uses request.body, Laravel uses file_get_contents("php://input"), and Flask uses request.get_data() for the raw body.

Frequently Asked Questions

Which key do I use to verify the Paystack webhook signature?
Use your Paystack secret key (the one starting with sk_live_ or sk_test_). Do not use your public key (pk_live_ or pk_test_). The secret key is the HMAC key that Paystack uses to sign the webhook body. Test mode webhooks are signed with your test secret key, and live mode webhooks are signed with your live secret key.
What hashing algorithm does Paystack use for webhook signatures?
Paystack uses HMAC SHA512. The output is a 128-character lowercase hexadecimal string. In Node.js, compute it with crypto.createHmac("sha512", secret).update(rawBody).digest("hex"). In Python, use hmac.new(secret, msg=body, digestmod=hashlib.sha512).hexdigest(). In PHP, use hash_hmac("sha512", $body, $secret).
Why does my Paystack signature verification fail even though my code looks correct?
The most common cause is that your framework parsed the request body before you could hash it. Express with express.json(), Django REST Framework with parsers, and similar middleware all modify the raw bytes. When you re-serialize the parsed object, the output differs from what Paystack sent. Hash the raw body, not a re-serialized version. See the raw body problem article for framework-specific fixes.
Do I really need timing-safe comparison for Paystack signatures?
Strictly speaking, a timing attack against a webhook signature is difficult because the attacker needs to measure response times precisely over the network. But timing-safe comparison costs nothing to implement (one extra function call) and eliminates the attack entirely. Use crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python, or hash_equals in PHP.
What should I return when the Paystack signature does not match?
Return a 400 status code or simply ignore the request. Do not return detailed error messages that reveal your verification logic. A simple "Invalid signature" or an empty 400 response is sufficient. Do not return 200, because that tells Paystack the delivery succeeded when it did not actually reach a valid handler.

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 Webhooks: Complete Engineering Guide

Everything you need to receive, verify, and process Paystack webhook events in production. Covers signature verification, the 200 OK rule, idempotency, local testing, and the failures that trip up most teams.

Read more

Handling dedicatedaccount.assign Events

A practical guide to handling Paystack dedicated virtual account assignment webhook events. Covers the dedicatedaccount.assign.success payload, mapping virtual accounts to customers, detecting deposits, and building a robust DVA integration in Node.js.

Read more

Replaying Failed Paystack Webhooks Safely

When Paystack webhook processing fails, you need to replay the events from your stored payloads. This guide covers when to replay, how to build a safe replay tool, the idempotency checks that prevent double-processing, and batch replay strategies for recovering from outages.

Read more

Paystack Webhooks on Cloudflare Workers

A complete guide to handling Paystack webhooks on Cloudflare Workers. Covers the Web Crypto API for HMAC-SHA512 signature verification (since Workers do not have Node.js crypto), request body handling, environment secrets, and a production-ready Worker you can deploy in minutes.

Read more

Paystack Payment Requests and Invoices via API

Paystack payment requests let you send invoices to customers that include a payment link. The customer receives an email with the invoice details and a button to pay. This guide covers creating payment requests via the API, tracking their status, and building automated invoicing workflows.

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.