Home/Help Center/Verify signatures

Integrations

Verify Webhook Signatures (HMAC)

Recompute the X-Signature-256 HMAC-SHA256 header on your receiver in Node, Python or PHP, and reject any payload that does not match, before you trust it.

By Chirag Darji · Updated 27 Aug 2026 · 6 min read

On this page
  1. Before you start
  2. Steps
  3. Node.js example
  4. Python example
  5. PHP example
  6. What you will see
  7. Settings and options
  8. Troubleshooting
Outbound webhooks in VGraple CRM with the events each endpoint subscribes to and its delivery status

Every webhook delivery from VGraple CRM carries an X-Signature-256 header, an HMAC-SHA256 hash of the raw request body computed with your endpoint's own signing secret, and verifying it on your side is the only way to know a payload genuinely came from VGraple CRM rather than from anyone who happened to guess your receiving URL. This walkthrough covers the exact check in three languages and the mistakes that most commonly break it.

Verify signatures: first 5 of 6 steps

  1. 1Locate your endpoint's secret
  2. 2Read the raw request body before any
  3. 3Read the `X-Signature-256` header
  4. 4Compute your own HMAC-SHA256 over the raw
  5. 5Compare using a constant-time function, not `==`
The steps on this page, in order.

Before you start

  • You need an endpoint already created in Settings > Webhooks, with its whsec_... secret saved somewhere secure; it was shown to you exactly once at creation. See configuring webhooks if you have not set one up yet.
  • Your receiving server must have access to the raw, unparsed request body, not a version already deserialised into an object, since re-serialising JSON rarely produces the exact same bytes as the original.
  • No specific role or plan is required for this step; it happens entirely on your own server, outside VGraple CRM.

Steps

  1. Locate your endpoint's secret. It was shown once in Settings > Webhooks right after you clicked Create Endpoint. If you saved it in your environment variables or a secrets manager as instructed at the time, retrieve it from there now.

API keys in VGraple CRM with prefix, creation date and last use

  1. Read the raw request body before any JSON parsing. In most frameworks this means registering your webhook route with raw-body access (Express's express.raw(), Flask's request.get_data(), or reading php://input directly) rather than a route that has already deserialised the body into an object.

  2. Read the X-Signature-256 header. It is formatted sha256=<hex digest>; you need the part after sha256= to compare against.

  3. Compute your own HMAC-SHA256 over the raw body. Use your stored secret as the HMAC key and the exact raw bytes as the message. See the code samples below for Node, Python and PHP.

  4. Compare using a constant-time function, not == or ===. A timing-safe comparison avoids leaking information about how many leading bytes matched, which a plain string comparison can expose.

  5. Reject anything that does not match. Return a 401 or 400 status and stop processing. Only proceed to parse and act on the payload once the signature check passes.

Node.js example

const crypto = require("crypto");

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// Express route, mounted with express.raw({ type: "application/json" })
// so req.body is a Buffer of the exact bytes VGraple CRM sent.
app.post("/webhooks/vgraple", (req, res) => {
  const signature = req.headers["x-signature-256"] || "";
  if (!verifySignature(req.body, signature, process.env.VGRAPLE_WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // event.type, event.data are now safe to act on
  res.status(200).send("ok");
});

Python example

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

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret_here"

def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)

@app.route("/webhooks/vgraple", methods=["POST"])
def vgraple_webhook():
    raw_body = request.get_data()  # exact bytes, before Flask parses JSON
    signature = request.headers.get("X-Signature-256", "")
    if not verify_signature(raw_body, signature, WEBHOOK_SECRET):
        abort(401)
    event = request.get_json()
    # event["type"], event["data"] are now safe to act on
    return "", 200

PHP example

<?php
$secret = "whsec_your_secret_here";
$rawBody = file_get_contents("php://input"); // exact bytes, before json_decode
$signatureHeader = $_SERVER["HTTP_X_SIGNATURE_256"] ?? "";

$expected = "sha256=" . hash_hmac("sha256", $rawBody, $secret);

if (!hash_equals($expected, $signatureHeader)) {
    http_response_code(401);
    exit("Invalid signature");
}

$event = json_decode($rawBody, true);
// $event["type"], $event["data"] are now safe to act on
http_response_code(200);

What you will see

A valid delivery's recomputed hash matches the X-Signature-256 header exactly, byte for byte, once you compare the two sha256=... strings (or just their hex portions). A tampered or forged request produces a mismatch, and your endpoint should reject it before parsing or acting on anything inside it.

Settings and options

ItemWhat it isWhere it lives
Signing secretThe HMAC key, formatted whsec_...Shown once at endpoint creation in Settings > Webhooks
X-Signature-256 headersha256=<hex digest> of the raw bodySent on every delivery
Hash algorithmHMAC-SHA256Fixed, not configurable
Comparison methodConstant-time (timingSafeEqual, compare_digest, hash_equals)Your implementation choice, but required

Troubleshooting

SymptomLikely causeFix
Signature never matches, even right after creating the endpointThe framework parsed the body to JSON before your check ran, and you are hashing a re-serialised versionRead the raw body directly (Buffer, bytes, or php://input) before any JSON parsing happens in your route
Signature matched in testing but fails in productionA proxy, load balancer or middleware in production is modifying the body (trimming whitespace, re-encoding) before it reaches your handlerCheck any reverse proxy or API gateway configuration for body rewriting; verify as early in the request pipeline as possible
Comparing req.headers["x-signature-256"] returns undefinedHeader name casing or a proxy stripping custom headersHTTP headers are case-insensitive; most frameworks lowercase them automatically, but confirm your proxy is not filtering non-standard headers
You rotated the endpoint but old code still verifies against the previous secretAn endpoint's secret cannot be rotated without deleting and recreating it (unlike API keys, there is no in-place webhook secret rotation today)Delete the old endpoint, create a new one, and copy the new secret into your receiver's configuration
Signature check passes but the payload looks wrong or incompleteLikely unrelated to signing; check the payload reference for the event's actual field shapeConfirm you are reading the correct field names for that specific event type

Watch out

Never compute or compare signatures using values a client can control, like a query parameter claiming to be the signature, only the actual X-Signature-256 header VGraple CRM sets. And never skip verification "just for now"; an unverified webhook endpoint will eventually receive a forged request from someone who found the URL.

Once verification is working, the payload reference documents every event's exact fields, and configuring webhooks covers adding more endpoints or changing subscribed events.

Frequently asked questions

What header carries the signature?
X-Signature-256, formatted sha256=. It is an HMAC-SHA256 hash of the exact raw request body, computed with the signing secret shown once when you created the endpoint in Settings > Webhooks.
Why does my computed signature never match, even though the code looks right?
Almost always because a web framework parsed the body into a JSON object before your signature check ran, then you re-serialised it to compute the hash. Re-serialisation rarely produces byte-identical output to the original body (key order, spacing), so the hash never matches. Read the raw, unparsed body for the signature check.
Should I use a plain string comparison to check the signature?
No, use a constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP). A plain == or === comparison can leak timing information an attacker could use to guess the correct signature byte by byte, even though the practical risk on a webhook receiver is low.
What should my endpoint do if the signature does not match?
Reject the request, typically with a 401 or 400 status, and do not process the payload. A mismatch means either the request did not come from VGraple CRM or the body was altered in transit.
Does the secret ever appear anywhere else after I create the endpoint?
No. It is shown exactly once, in the panel that appears right after you click Create Endpoint in Settings > Webhooks. If you lose it, delete the endpoint and create a new one; there is no way to retrieve a lost secret.
Is this the same signature scheme WhatsApp payments or other webhooks use?
No, and this matters if you also handle other VGraple CRM webhooks in the same codebase. Outbound event webhooks use X-Signature-256 with the endpoint's own whsec_ secret. Stripe's own webhooks (for a business's in-chat payment collection) use Stripe's stripe-signature header and Stripe's SDK; each system's secret and header are separate and are not interchangeable.

Run your WhatsApp on VGraple CRM

Free forever plan, official Meta WhatsApp Business API, set up in 15 minutes. No card needed.