Pepvote Support Center
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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The header contains two parts separated by a comma:

PartDescription
t=Unix timestamp (seconds) of when the request was signed.
v1=HMAC-SHA256 hex digest of the signed payload.

Verification steps

  1. Extract the timestamp (t) and signature (v1) from the header.
  2. Build the signed payload string: {timestamp}.{raw_request_body}.
  3. Compute the expected HMAC-SHA256 using your endpoint's signing secret as the key.
  4. Compare your computed signature with the received v1 value using constant-time comparison.
  5. 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

On this page