All docs
3 min read Last updated:

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

text
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

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

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

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

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.