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.

Read the raw request body

Verify the exact request text before parsing it as JSON. Parsing and re-serializing JSON can change whitespace or key order and cause a valid signature to fail.

With the Fetch API used by Next.js route handlers:

const rawBody = await request.text();
const signature = request.headers.get("pepvote-signature");

if (!verifyWebhookSignature(rawBody, signature, process.env.PEPVOTE_WEBHOOK_SECRET)) {
  return new Response("Invalid signature", { status: 401 });
}

const event = JSON.parse(rawBody);

Code examples

import crypto from "node:crypto";

function verifyWebhookSignature(rawBody, header, secret) {
  if (typeof header !== "string" || typeof secret !== "string") {
    return false;
  }

  const parts = Object.fromEntries(
    header.split(",").map((part) => {
      const [key, ...rest] = part.split("=");
      return [key, rest.join("=")];
    })
  );
  const timestamp = parts.t;
  const receivedSignature = parts.v1;

  if (
    !timestamp ||
    !/^\d+$/.test(timestamp) ||
    !receivedSignature ||
    !/^[a-f\d]{64}$/i.test(receivedSignature)
  ) {
    return false;
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > 300) return false;

  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const expected = Buffer.from(expectedSignature, "hex");
  const received = Buffer.from(receivedSignature, "hex");

  return (
    expected.length === received.length &&
    crypto.timingSafeEqual(expected, received)
  );
}
import hashlib
import hmac
import time

def verify_webhook_signature(raw_body: str, header: str, secret: str) -> bool:
    try:
        parts = dict(part.split("=", 1) for part in header.split(","))
        timestamp = parts.get("t")
        received = parts.get("v1")

        if not timestamp or not timestamp.isdigit() or not received:
            return False
        if len(received) != 64:
            return False
        int(received, 16)

        if abs(int(time.time()) - int(timestamp)) > 300:
            return False

        payload = f"{timestamp}.{raw_body}"
        expected = hmac.new(
            secret.encode(), payload.encode(), hashlib.sha256
        ).hexdigest()
        return hmac.compare_digest(expected, received)
    except (TypeError, ValueError):
        return False

Security notes

  • Use a constant-time comparison. Do not compare signatures with === or ==.
  • Reject timestamps more than five minutes in the past or future.
  • Return an error before processing an event with a missing or malformed header.
  • Store the signing secret outside your source code and logs. Treat it like a password.
  • Roll the secret from the endpoint settings if you believe it was exposed.
Was this helpful?

Last updated September 4, 2026

On this page