Mantissa Hooksnode is a Mantissa product A Mantissa product mantissatech.xyz

Verify Paystack webhook signatures (x-paystack-signature)

Author:
hooksnode
Published:
Updated:
  • paystack
  • webhooks
  • security

Paystack signs every webhook it sends. The x-paystack-signature header holds a hex HMAC SHA512 of the request body, keyed with your Paystack secret key. Your server should compute the same value and reject the request when the two do not match.

The check is short, but it fails on real events more often than it should. Almost every time, the cause is the same: the code hashes a different set of bytes than Paystack signed.

This guide covers:

  1. What Paystack sends.
  2. The raw body bug, with fixes for Node, PHP, Python and Next.js.
  3. Other checks worth adding.
  4. Duplicate events.
  5. Letting Hooksnode check the signature for you.

What Paystack sends

Each webhook is a POST with a JSON body and these parts that matter here:

Part Value
Header x-paystack-signature
Algorithm HMAC SHA512 (not SHA256)
Key Your Paystack secret key (test or live, to match the mode)
Encoding Lowercase hex
Input The raw request body, byte for byte

Two mistakes come up again and again:

  • Wrong algorithm. SHA256 code copied from another provider will never match.
  • Wrong key. Test events are signed with your test secret key and live events with your live secret key. The public key does not work.

The bug: hashing re-serialised JSON

Many frameworks parse the JSON body before your handler runs. If you then call JSON.stringify(req.body) (or json.dumps, or json_encode) and hash that, you are hashing a new string. Key order, spacing, escaped slashes and number formatting can all differ from what Paystack sent, so the HMAC differs too.

Many older snippets online hash JSON.stringify(req.body). That can match for some bodies and fail for others. Paystack's current docs keep the raw body (req.rawBody via the verify option of express.json) and hash that. Do the same: hash the raw bytes.

In every stack below, the rule is the same:

  1. Read the body as raw bytes, before any JSON parsing.
  2. Compute HMAC-SHA512(secret key, raw body) as hex.
  3. Compare with the header in constant time.
  4. Only then parse the JSON.

Node.js (Express)

Use express.raw on the webhook route only, so req.body is a Buffer:

import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/webhooks/paystack", express.raw({ type: "application/json" }), (req, res) => {
  const expected = crypto
    .createHmac("sha512", process.env.PAYSTACK_SECRET_KEY)
    .update(req.body)
    .digest("hex");
  const given = (req.get("x-paystack-signature") ?? "").toLowerCase();
  const ok =
    given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200);
  // Process event after answering (see "Other checks").
});

If app.use(express.json()) runs before this route, the body is already parsed and the raw bytes are gone. Register the webhook route first, or skip the JSON parser for that path.

PHP

Read php://input, not $_POST and not a decoded array:

<?php
$raw = file_get_contents("php://input");
$secret = getenv("PAYSTACK_SECRET_KEY");
$expected = hash_hmac("sha512", $raw, $secret);
$given = strtolower($_SERVER["HTTP_X_PAYSTACK_SIGNATURE"] ?? "");

if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
http_response_code(200);

In Laravel, use $request->getContent() for the raw body.

Python (Flask)

import hashlib, hmac, os
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["PAYSTACK_SECRET_KEY"].encode()

@app.post("/webhooks/paystack")
def paystack():
    raw = request.get_data()
    expected = hmac.new(SECRET, raw, hashlib.sha512).hexdigest()
    given = request.headers.get("x-paystack-signature", "").lower()
    if not hmac.compare_digest(expected, given):
        abort(401)
    event = request.get_json()
    return "", 200

In Django, use request.body.

Next.js (App Router route handler)

Read the text once with await req.text(), verify it, then parse it:

import crypto from "node:crypto";

export async function POST(req: Request) {
  const raw = await req.text();
  const expected = crypto
    .createHmac("sha512", process.env.PAYSTACK_SECRET_KEY!)
    .update(raw, "utf8")
    .digest("hex");
  const given = (req.headers.get("x-paystack-signature") ?? "").toLowerCase();
  const ok =
    given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return new Response(null, { status: 401 });

  const event = JSON.parse(raw);
  return new Response(null, { status: 200 });
}

Do not call req.json() before req.text(). The body can only be read once.

Why constant time

A plain == can return as soon as one character differs, which leaks timing. crypto.timingSafeEqual, hash_equals and hmac.compare_digest take the same time whatever the input.

Other checks

  • IP allowlist. Paystack publishes the IP addresses it sends webhooks from. You can allowlist them as an extra layer. Keep the signature check either way. If Hooksnode sits in front of your service, do not allowlist Paystack IPs there, because the requests then come from Hooksnode.
  • Answer fast. Return 200 quickly and do slow work (emails, third-party calls) in a job queue. Paystack's docs warn that long-running tasks lead to timeouts, and without a 200 Paystack retries (in live mode, every 3 minutes for the first 4 tries, then hourly for up to 72 hours).
  • Confirm the amount. Before you give value, call Paystack's Verify Transaction API with data.reference and check the amount, currency and status match what you expect.

Duplicates

Paystack can send the same event more than once, for example after a retry. A valid signature does not mean the event is new.

Make the handler idempotent on data.reference:

  1. Build a key such as charge.success:<reference>.
  2. Insert it into a table with a unique constraint, in the same transaction as the work.
  3. If the insert finds the key already there, skip the work and still answer 200.

Answering with an error for a duplicate only makes Paystack retry it again.

With Hooksnode

If you send Paystack events to more than one service, each one needs your secret key and its own copy of the check. Hooksnode can do the Paystack check once, at the edge:

  1. Create a project. Its webhook source has a URL like https://hooksnode.com/p/<source-id>.
  2. On the source, turn on Signing, choose the Paystack preset, and paste your Paystack secret key.
  3. Set that URL as the webhook URL in Paystack (Settings, API Keys & Webhooks).

Hooksnode then verifies x-paystack-signature on every request. A missing or wrong signature gets 401 and is not delivered to any destination.

Each destination can have its own signing secret. With it on, Hooksnode adds X-Hooksnode-Signature: sha256=<hex> (HMAC SHA256 of the delivered body) to every forwarded request, so your services verify Hooksnode with their own secret and never hold your Paystack key.

Hooksnode delivers at least once, never exactly once. It drops an exact resend of a signed request (the same body and signature) within 10 minutes, but Paystack can still send the same event again later, so keep the data.reference check in each service.

The full setup, with filters and per-destination retries, is in Send one Paystack webhook to several services. For the wider picture, see Payment webhook patterns.

Try it free

The project Free plan is $0, with 100,000 credits a month and a hard cap. That covers one Paystack source and up to five destinations. Project Pro is $10/month for 1,000,000 credits; see pricing.

Checklist

  • HMAC SHA512, not SHA256
  • Secret key matches the mode (test or live)
  • Hash the raw body, before any JSON parsing
  • Constant-time compare
  • Answer 200 fast, process in a queue
  • Confirm the amount with Verify Transaction before giving value
  • Idempotent on data.reference

Further reading