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>
| Field | Meaning |
|---|---|
t | Unix timestamp (seconds) that was included when the signature was computed |
v1 | HMAC-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.
- Parse — split the header on commas to extract
tandv1. - Validate structure —
tmust be a valid integer;v1must be exactly 64 lowercase hex characters. - Check the timestamp — compute
|now − t|. If it exceeds 300 seconds (5 minutes), reject withtimestamp_expired. This prevents replay attacks. - Recompute the HMAC — compute
HMAC-SHA256(key=secret, data="${t}.${rawBody}")and encode as lowercase hex. - Compare timing-safely — use a constant-time comparison function. Never use a plain
===or==equality check, which is vulnerable to timing side-channels.
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.
| File | Language | Run command |
|---|---|---|
verify-webhook.ts | TypeScript (Node.js) | npx tsx verify-webhook.ts |
verify_webhook.py | Python 3 | python3 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.
Related pages
- Understanding Webhooks — how delivery, retries, and signatures work
- Configure Webhooks — set up a webhook endpoint and retrieve your signing secret
- Webhook Event Types — full list of supported event types and payload fields