On this page

Every webhook delivery from VGraple CRM carries two HMAC-SHA256 signatures computed with your endpoint's own signing secret: X-Webhook-Signature, which also binds the time of sending so an old delivery captured in transit cannot be replayed, and the original X-Signature-256 over the body alone. Verifying one of them 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 signature header
- 4Compute your own HMAC-SHA256
- 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 signature header. Prefer
X-Webhook-Signature, formattedt=<unix seconds>,v1=<hex digest>: the digest is HMAC-SHA256 over the string<t>.<raw body>(the timestamp, a dot, then the exact body bytes), andtlets you refuse anything older than five minutes.X-Signature-256, formattedsha256=<hex digest>over the raw body alone, is still sent on every delivery; the samples below check the timestamped header and show the older one as well.Compute your own HMAC-SHA256. Use your stored secret as the HMAC key. For
X-Webhook-Signaturethe message is the timestamp from the header, a dot, then the exact raw bytes; forX-Signature-256it is the raw bytes alone. 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");
const TOLERANCE_SECONDS = 300; // five minutes, the same window Stripe uses
// X-Webhook-Signature: t=<unix seconds>,v1=<hex digest of HMAC(`${t}.${body}`)>
function verifyTimestampedSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1, "hex");
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return false;
return Math.abs(Math.floor(Date.now() / 1000) - t) <= TOLERANCE_SECONDS;
}
// X-Signature-256: sha256=<hex digest of HMAC(body)>, the original header
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-webhook-signature"] || "";
if (!verifyTimestampedSignature(req.body, signature, process.env.VGRAPLE_WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
// Older receivers can keep checking req.headers["x-signature-256"] with verifySignature() instead.
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
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret_here"
TOLERANCE_SECONDS = 300 # five minutes
def verify_timestamped_signature(raw_body: bytes, header: str, secret: str) -> bool:
# X-Webhook-Signature: t=<unix seconds>,v1=<hex digest of HMAC(f"{t}.{body}")>
parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
t, given = parts.get("t"), parts.get("v1")
if not t or not t.isdigit() or not given:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, given):
return False
return abs(int(time.time()) - int(t)) <= TOLERANCE_SECONDS
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
# X-Signature-256: sha256=<hex digest of HMAC(body)>, the original header
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-Webhook-Signature", "")
if not verify_timestamped_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
// X-Webhook-Signature: t=<unix seconds>,v1=<hex digest of HMAC("$t.$body")>
$header = $_SERVER["HTTP_X_WEBHOOK_SIGNATURE"] ?? "";
$parts = [];
foreach (explode(",", $header) as $kv) {
[$k, $v] = array_pad(explode("=", $kv, 2), 2, "");
$parts[$k] = $v;
}
$t = $parts["t"] ?? "";
$expected = hash_hmac("sha256", $t . "." . $rawBody, $secret);
$fresh = ctype_digit($t) && abs(time() - (int) $t) <= 300; // five minutes
if (!$fresh || !hash_equals($expected, $parts["v1"] ?? "")) {
http_response_code(401);
exit("Invalid signature");
}
// Older receivers: $_SERVER["HTTP_X_SIGNATURE_256"] is "sha256=" . hash_hmac("sha256", $rawBody, $secret)
$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-Webhook-Signature header | t=<unix seconds>,v1=<hex digest> of <t>.<raw body> | Sent on every delivery |
X-Signature-256 header | sha256=<hex digest> of the raw body | Sent on every delivery |
X-Delivery-Id header | The delivery's id, unchanged across retries and redeliveries | Sent on every delivery |
| Timestamp tolerance | Reject when t is more than 300 seconds from your clock | Your implementation choice; five minutes is the convention |
| 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 |
| Timestamped check fails on deliveries that are clearly fresh | Your server's clock is off; the five-minute window is measured against it | Run NTP on the receiver, or widen the tolerance a little while you fix the clock. Redeliveries from the delivery log are signed with the time of the resend, not the original send, so they pass a fresh check |
| 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.