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.
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 FalseSecurity 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.
Related help
Was this helpful?
Last updated September 4, 2026