Webhooks
Verifying signatures
Authenticate webhook requests using HMAC-SHA256 signatures.
Every webhook request includes a Pepvote-Signature header that lets your server verify the request came from Pepvote and was not modified in transit.
Signature format
Pepvote-Signature: t=1756735800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdThe header contains two parts separated by a comma:
| Part | Description |
|---|---|
t= | Unix timestamp (seconds) of when the request was signed. |
v1= | HMAC-SHA256 hex digest of the signed payload. |
Verification steps
- Extract the timestamp (
t) and signature (v1) from the header. - Build the signed payload string:
{timestamp}.{raw_request_body}. - Compute the expected HMAC-SHA256 using your endpoint's signing secret as the key.
- Compare your computed signature with the received
v1value using constant-time comparison. - Reject requests older than 5 minutes to prevent replay attacks.
Code examples
import crypto from "node:crypto";
function verifyWebhookSignature(req, secret) {
const signature = req.headers["pepvote-signature"];
if (!signature) return false;
// Parse the header.
const parts = Object.fromEntries(
signature.split(",").map((part) => {
const [key, ...rest] = part.split("=");
return [key, rest.join("=")];
})
);
const timestamp = parts.t;
const receivedSignature = parts.v1;
if (!timestamp || !receivedSignature) return false;
// Reject requests older than 5 minutes.
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) return false;
// Compute the expected signature.
const payload = `${timestamp}.${req.body}`;
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
// Constant-time comparison.
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(receivedSignature)
);
}import hashlib
import hmac
import time
def verify_webhook_signature(body: str, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = parts.get("t")
received = parts.get("v1")
if not timestamp or not received:
return False
# Reject requests older than 5 minutes.
if int(time.time()) - int(timestamp) > 300:
return False
# Compute expected signature.
payload = f"{timestamp}.{body}"
expected = hmac.new(
secret.encode(), payload.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received)Security notes
- Always use constant-time comparison to prevent timing attacks. Do not use
===or==for signature comparison. - Reject requests with a timestamp more than 5 minutes old to prevent replay attacks.
- Store your signing secret securely. Treat it like a password.
- If you suspect your secret has been compromised, roll it immediately from your dashboard.
Was this helpful?
Last updated August 25, 2026