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-Signatureheader to extracttandv1. - Compute
HMAC_SHA256(secret, f"{t}.{raw_body}")and compare tov1. - 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
v1value: Extract thev1signature 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.