On this page

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
- 1Locate your endpoint's secret
- 2Read the raw request body before any
- 3Read the `X-Signature-256` header
- 4Compute your own HMAC-SHA256 over the raw
- 5Compare using a constant-time function, not `==`
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
- 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.

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'srequest.get_data(), or readingphp://inputdirectly) rather than a route that has already deserialised the body into an object.Read the
X-Signature-256header. It is formattedsha256=<hex digest>; you need the part aftersha256=to compare against.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.
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.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
| Item | What it is | Where it lives |
|---|---|---|
| Signing secret | The HMAC key, formatted whsec_... | Shown once at endpoint creation in Settings > Webhooks |
X-Signature-256 header | sha256=<hex digest> of the raw body | Sent on every delivery |
| Hash algorithm | HMAC-SHA256 | Fixed, not configurable |
| Comparison method | Constant-time (timingSafeEqual, compare_digest, hash_equals) | Your implementation choice, but required |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Signature never matches, even right after creating the endpoint | The framework parsed the body to JSON before your check ran, and you are hashing a re-serialised version | Read the raw body directly (Buffer, bytes, or php://input) before any JSON parsing happens in your route |
| Signature matched in testing but fails in production | A proxy, load balancer or middleware in production is modifying the body (trimming whitespace, re-encoding) before it reaches your handler | Check 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 undefined | Header name casing or a proxy stripping custom headers | HTTP 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 secret | An 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 incomplete | Likely unrelated to signing; check the payload reference for the event's actual field shape | Confirm 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.