Home/Help Center/Verify signatures

Integrations

Verify Webhook Signatures (HMAC)

Recompute the timestamped X-Webhook-Signature or X-Signature-256 HMAC header in Node, Python or PHP, and reject any payload that does not match or is too old.

By Chirag Darji · Updated 7 Oct 2026 · 8 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 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

  1. 1Locate your endpoint's secret
  2. 2Read the raw request body before any
  3. 3Read the signature header
  4. 4Compute your own HMAC-SHA256
  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 signature header. Prefer X-Webhook-Signature, formatted t=<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), and t lets you refuse anything older than five minutes. X-Signature-256, formatted sha256=<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.

  3. Compute your own HMAC-SHA256. Use your stored secret as the HMAC key. For X-Webhook-Signature the message is the timestamp from the header, a dot, then the exact raw bytes; for X-Signature-256 it is the raw bytes alone. 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");

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

ItemWhat it isWhere it lives
Signing secretThe HMAC key, formatted whsec_...Shown once at endpoint creation in Settings > Webhooks
X-Webhook-Signature headert=<unix seconds>,v1=<hex digest> of <t>.<raw body>Sent on every delivery
X-Signature-256 headersha256=<hex digest> of the raw bodySent on every delivery
X-Delivery-Id headerThe delivery's id, unchanged across retries and redeliveriesSent on every delivery
Timestamp toleranceReject when t is more than 300 seconds from your clockYour implementation choice; five minutes is the convention
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
Timestamped check fails on deliveries that are clearly freshYour server's clock is off; the five-minute window is measured against itRun 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 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?
Two do, both computed with the signing secret shown once when you created the endpoint in Settings > Webhooks. X-Webhook-Signature is formatted t=,v1=, where the digest is HMAC-SHA256 over the timestamp, a dot and the raw body, so you can also reject deliveries older than five minutes. X-Signature-256 is formatted sha256=, an HMAC-SHA256 of the raw body alone, and stays on every delivery for receivers written against it.
What is X-Delivery-Id for?
It names the delivery and does not change when VGraple CRM retries it or you press Redeliver in the delivery log, and the id field inside the body (the event id) is stable in the same way. Store either one and skip a request you have already processed.
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.