Signing & verification
Every webhook delivery is signed with HMAC-SHA256 over the timestamp and the raw request body, joined by a dot. Verify before you trust.
Header
X-Formspring-Signature: t=1751328000,v1=5b8ad...e3
t is the Unix timestamp the delivery was signed at. v1 is the hex-encoded
HMAC-SHA256 of the string <t>.<raw body>, keyed with your webhook secret.
Parse both parts, recompute, and compare in constant time. Reject a delivery
whose t is far outside your own tolerance window to stop replays.
Read the body as raw bytes. Re-serializing the parsed JSON changes the bytes and the signature will not match.
Verify in Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export default function handler(req, res) {
const header = req.headers['x-formspring-signature'] ?? '';
const m = /t=(\d+),v1=([a-f0-9]+)/.exec(header);
if (!m) return res.status(401).end();
const [, t, sig] = m;
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${t}.${req.rawBody}`)
.digest('hex');
if (
sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return res.status(401).end();
}
// safe to use req.body now
}
Verify in PHP
$header = (string) $request->header('X-Formspring-Signature');
if (! preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $m)) {
abort(401);
}
[$_, $timestamp, $signature] = $m;
$expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), env('WEBHOOK_SECRET'));
if (! hash_equals($expected, $signature)) {
abort(401);
}
Verify in Python
import hmac, hashlib, re
m = re.search(r"t=(\d+),v1=([a-f0-9]+)", request.headers["X-Formspring-Signature"])
if not m:
return 401
timestamp, signature = m.group(1), m.group(2)
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + body_bytes, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
return 401
Verify in Ruby
m = /t=(\d+),v1=([a-f0-9]+)/.match(signature_header)
halt 401 unless m
timestamp, signature = m[1], m[2]
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
unless Rack::Utils.secure_compare(expected, signature)
halt 401
end
Why constant-time compare
A naive == leaks information about how many bytes matched, which an attacker can use to forge signatures one byte at a time. Use timingSafeEqual / hash_equals / compare_digest / secure_compare instead.
Rotate the secret
To rotate, delete the webhook and create a new one. The new webhook gets a fresh secret; flip your service over to the new URL or update the existing destination URL via the API.