Skip to main content

Webhook Signature Verification

Every webhook delivery from StandShare includes an X-StandShare-Signature header. Verifying this header confirms that the request genuinely came from StandShare and that the body was not modified in transit. Always verify signatures before acting on a delivery.

For background on why signature verification matters, see Understanding Webhooks.


The signature format

X-StandShare-Signature: t=<unix_seconds>,v1=<hmac_sha256_hex>
FieldMeaning
tUnix timestamp (seconds) that was included when the signature was computed
v1HMAC-SHA256 of "${t}.${rawBody}", encoded as 64 lowercase hex characters

The signed string is: the decimal timestamp, a literal period (.), then the exact raw body bytes as delivered — no re-serialization, no whitespace changes.

The key is your signing secret (the full whsec_… string) treated as UTF-8 bytes.


Verification steps

Perform these checks in order. Reject the delivery at the first failure.

  1. Parse — split the header on commas to extract t and v1.
  2. Validate structuret must be a valid integer; v1 must be exactly 64 lowercase hex characters.
  3. Check the timestamp — compute |now − t|. If it exceeds 300 seconds (5 minutes), reject with timestamp_expired. This prevents replay attacks.
  4. Recompute the HMAC — compute HMAC-SHA256(key=secret, data="${t}.${rawBody}") and encode as lowercase hex.
  5. Compare timing-safely — use a constant-time comparison function. Never use a plain === or == equality check, which is vulnerable to timing side-channels.
warning

Use the raw body exactly as received from the network. If your framework parses the JSON body before your handler runs, you must configure it to also provide the raw bytes — otherwise your recomputed HMAC will not match.


TypeScript

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300; // 5 minutes

type VerifyResult =
| { ok: true }
| { ok: false; reason: "missing_header" | "malformed_header" | "timestamp_expired" | "invalid_signature" };

function verifyWebhookSignature(
secret: string,
rawBody: string,
signatureHeader: string | undefined | null
): VerifyResult {
if (!signatureHeader?.trim()) return { ok: false, reason: "missing_header" };

const fields: Record<string, string> = {};
for (const part of signatureHeader.split(",")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const k = part.slice(0, eq).trim();
const v = part.slice(eq + 1).trim();
if (k) fields[k] = v;
}

const tStr = fields["t"];
const v1 = fields["v1"];
if (!tStr || !v1) return { ok: false, reason: "malformed_header" };

const timestamp = Number(tStr);
if (!Number.isInteger(timestamp) || Number.isNaN(timestamp)) {
return { ok: false, reason: "malformed_header" };
}
if (!/^[0-9a-f]{64}$/.test(v1)) return { ok: false, reason: "malformed_header" };

const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) {
return { ok: false, reason: "timestamp_expired" };
}

const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");

const expectedBuf = Buffer.from(expected, "utf8");
const receivedBuf = Buffer.from(v1, "utf8");
if (expectedBuf.length !== receivedBuf.length) return { ok: false, reason: "invalid_signature" };
if (!crypto.timingSafeEqual(expectedBuf, receivedBuf)) return { ok: false, reason: "invalid_signature" };

return { ok: true };
}

Example — Express handler

import express from "express";

const app = express();

// Use express.raw() to capture the body before JSON parsing
app.post(
"/webhooks/standshare",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
const header = req.headers["x-standshare-signature"] as string;
const result = verifyWebhookSignature(process.env.STANDSHARE_WEBHOOK_SECRET!, rawBody, header);

if (!result.ok) {
console.warn("Webhook rejected:", result.reason);
return res.status(400).json({ error: result.reason });
}

const payload = JSON.parse(rawBody);
console.log("Verified delivery:", payload.type);
res.sendStatus(200);
}
);

Python

import hashlib
import hmac
import time
from dataclasses import dataclass
from typing import Optional

TOLERANCE_SECONDS = 300 # 5 minutes

@dataclass(frozen=True)
class VerifyResult:
ok: bool
reason: Optional[str] = None

def verify_webhook_signature(
secret: str,
raw_body: str,
signature_header: Optional[str],
) -> VerifyResult:
if not signature_header or not signature_header.strip():
return VerifyResult(ok=False, reason="missing_header")

fields: dict[str, str] = {}
for part in signature_header.split(","):
if "=" not in part:
continue
k, _, v = part.partition("=")
if k.strip():
fields[k.strip()] = v.strip()

t_str = fields.get("t")
v1 = fields.get("v1")
if not t_str or not v1:
return VerifyResult(ok=False, reason="malformed_header")

try:
timestamp = int(t_str)
except ValueError:
return VerifyResult(ok=False, reason="malformed_header")

if len(v1) != 64 or not all(c in "0123456789abcdef" for c in v1):
return VerifyResult(ok=False, reason="malformed_header")

if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return VerifyResult(ok=False, reason="timestamp_expired")

signed_data = f"{timestamp}.{raw_body}".encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), signed_data, hashlib.sha256).hexdigest()

if not hmac.compare_digest(expected, v1):
return VerifyResult(ok=False, reason="invalid_signature")

return VerifyResult(ok=True)

Example — Flask handler

from flask import Flask, request, abort
import json
import os

app = Flask(__name__)

@app.route("/webhooks/standshare", methods=["POST"])
def standshare_webhook():
raw_body = request.get_data(as_text=True)
header = request.headers.get("X-StandShare-Signature")
result = verify_webhook_signature(os.environ["STANDSHARE_WEBHOOK_SECRET"], raw_body, header)

if not result.ok:
print(f"Webhook rejected: {result.reason}")
abort(400)

payload = json.loads(raw_body)
print(f"Verified delivery: {payload['type']}")
return "", 200

Downloadable reference files

Both verifiers are available as standalone files that include a built-in self-test against the pinned fixture vector. Download and run them to confirm your environment can verify StandShare signatures before wiring them into your application.

FileLanguageRun command
verify-webhook.tsTypeScript (Node.js)npx tsx verify-webhook.ts
verify_webhook.pyPython 3python3 verify_webhook.py

The self-test in each file uses the same pinned test vector published in packages/api/src/fixtures/webhook-signature-fixture.ts in the StandShare platform repo. All three — this TypeScript file, the Python file, and the server-side signer — must agree byte-for-byte on this vector.


Common mistakes

Parsing the body before verifying

If your framework automatically parses the JSON body, the raw string passed to HMAC will differ from what StandShare signed (whitespace, key order, encoding). Always work with the original raw bytes.

Using a plain equality check

A string comparison like expected === received is vulnerable to timing side-channels. Use crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python).

Ignoring the timestamp

Skipping the tolerance check allows replay attacks: an attacker who captures a valid delivery can re-send it later. Always reject deliveries where |now − t| > 300.

Trimming or normalizing the body

Even a single extra space changes the HMAC. Treat the body as opaque bytes from receipt to verification.