⌘I

Verify Webhook Signatures

Updated February 28, 2026
Also available in:

Here’s how to verify Formspree webhook signatures in your receiver code.

The key detail is that Formspree does not sign just the raw body. It signs both the timestamp and the raw body, in this format:

{timestamp}.{raw_body}

The signature is sent in the Formspree-Signature header using this format:

t=<unix>,v1=<hex>

Key Requirements

  • Use the raw request body exactly as received.
  • Parse the Formspree-Signature header to extract t and v1.
  • Compute HMAC_SHA256(secret, f"{t}.{raw_body}") and compare to v1.
  • Optional: reject requests older than a tolerance window (replay protection).

Code Examples

Here are a few code snippets to help you get started.

Python (Flask)

import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)

SIGNING_SECRET = "your_signing_secret"  # Set this to the secret shown in Formspree

def verify_formspree_signature(secret, signature_header, raw_body, tolerance=300):
    if not signature_header:
        return False

    try:
        # Header format: "t=<unix>,v1=<hex>"
        parts = dict(item.split("=", 1) for item in signature_header.split(","))
        timestamp = int(parts.get("t", "0"))
        received_sig = parts.get("v1")
    except Exception:
        return False

    if not received_sig:
        return False

    # Optional: reject old requests to prevent replay attacks
    if abs(int(time.time()) - timestamp) > tolerance:
        return False

    # Formspree signs: "{timestamp}.{raw_body}"
    signed_payload = f"{timestamp}.{raw_body}"

    # Compute HMAC-SHA256 with the shared secret
    expected_sig = hmac.new(
        key=secret.encode("utf-8"),
        msg=signed_payload.encode("utf-8"),
        digestmod=hashlib.sha256,
    ).hexdigest()

    # Constant-time compare to avoid timing attacks
    return hmac.compare_digest(expected_sig, received_sig)

@app.post("/webhook")
def webhook():
    # Important: read the raw request body as sent
    raw_body = request.get_data(as_text=True)
    signature = request.headers.get("Formspree-Signature", "")

    if not verify_formspree_signature(SIGNING_SECRET, signature, raw_body):
        abort(401)

    return ("ok", 200)

JavaScript (Node.js + Express)

const crypto = require("crypto");
const express = require("express");
const app = express();

const SIGNING_SECRET = "your_signing_secret"; // Set this to the secret shown in Formspree

// Capture the raw body before JSON parsing changes it
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString("utf8");
  }
}));

function verifyFormspreeSignature(secret, signatureHeader, rawBody, tolerance = 300) {
  if (!signatureHeader) return false;

  // Header format: "t=<unix>,v1=<hex>"
  const parts = Object.fromEntries(
    signatureHeader.split(",").map(s => s.split("=", 2))
  );
  const timestamp = parseInt(parts.t || "0", 10);
  const receivedSig = parts.v1;

  if (!receivedSig) return false;

  // Optional: reject old requests to prevent replay attacks
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > tolerance) return false;

  // Formspree signs: "{timestamp}.{raw_body}"
  const signedPayload = `${timestamp}.${rawBody}`;

  // Compute HMAC-SHA256 with the shared secret
  const expectedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload, "utf8")
    .digest("hex");

  // Constant-time compare to avoid timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig, "utf8"),
    Buffer.from(receivedSig, "utf8")
  );
}

app.post("/webhook", (req, res) => {
  const signature = req.header("Formspree-Signature") || "";

  if (!verifyFormspreeSignature(SIGNING_SECRET, signature, req.rawBody)) {
    return res.status(401).send("invalid signature");
  }

  res.status(200).send("ok");
});

app.listen(3000);

Troubleshooting and common issues

Here are some common mistakes to watch out for:

  • Re-serializing JSON before signing: Verify against the raw request body bytes exactly as received, since parsing and re-stringifying JSON will change the byte sequence and break the HMAC.
  • Signing only the body (missing the t. prefix): As mentioned above, Formspree signs the timestamp and payload together, so you must compute the HMAC over t.<raw_body> (including the t. delimiter) rather than the body alone.
  • Comparing against the entire header string instead of the v1 value: Extract the v1 signature value from the signature header and compare only that hex digest, not the full comma-separated header contents.
  • Using a different secret from the webhook’s configured signing_secret: Make sure the secret you use for HMAC is the exact webhook signing secret from the Formspree webhook settings, not your API key or another project secret.

Still have questions? or .