Blog ·

How to verify FormHeron webhook signatures

Verify FormHeron HMAC-SHA256 webhook signatures in Node, Python and PHP — headers, raw body rules, retries, and constant-time safe compares.

  • webhooks
  • security
  • developers

FormHeron signs every webhook body so you can reject forged deliveries. Signature algorithm: HMAC-SHA256 over the string timestamp + "." + raw body, using the secret shown once at endpoint creation. Headers: X-FormHeron-Signature and X-FormHeron-Timestamp.

Rules that matter

  • Verify against raw body bytes — never re-serialize JSON then sign
  • Reject stale timestamps (allow a few minutes of clock skew)
  • Use constant-time comparison (timingSafeEqual / compare_digest / hash_equals)
  • Store the secret encrypted at rest; show plaintext only once at creation

Node.js

verify.js
import crypto from "node:crypto";

export function verify(rawBody, signature, timestamp, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python

verify.py
import hmac, hashlib

def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

PHP

verify.php
function verify(string $rawBody, string $signature, string $timestamp, string $secret): bool {
  $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
  return hash_equals($expected, $signature);
}

Delivery behaviour

Failed deliveries retry at 1m / 5m / 25m, then dead-letter: the endpoint is disabled and the project notification email is told. Spam submissions never fire webhooks. Webhooks require Indie or Pro — see pricing.

SSRF protection runs resolve-then-pin on every attempt (https + port 443 only; private ranges rejected). Full product notes: docs webhooks, integrations.

Payload shape

Body is JSON with submission id, form id, createdAt, and a flat payload object of string fields. System metadata and user fields are separated so custom field names cannot collide with transport fields. Nested objects are not accepted on submit, so webhook payloads stay flat too.

Example Express middleware sketch

middleware.js
app.post("/hooks/formheron", express.raw({ type: "*/*" }), (req, res) => {
  const signature = req.get("x-formheron-signature");
  const timestamp = req.get("x-formheron-timestamp");
  const raw = req.body.toString("utf8");
  if (!verify(raw, signature, timestamp, process.env.FH_WHSEC)) {
    return res.status(401).send("bad signature");
  }
  const event = JSON.parse(raw);
  // enqueue work; return 2xx quickly
  res.status(204).end();
});

Return 2xx quickly and process asynchronously. Slow endpoints plus retries hurt both sides. FormHeron caps response body size and times out outbound fetches; design your handler to be fast.

Rotating secrets

If a secret leaks, create a new webhook endpoint, update the consumer, then disable the old endpoint. Prefer one endpoint per environment (staging vs production) so test traffic never hits production CRM rows.

Next: Sheets via webhook or Slack notifications.

FormHeron is a form backend with spam controls, a lead inbox, HMAC webhooks and Slack. Free plan: 250 submissions/month. Leads stored in the EU (Amsterdam); operated from India. No raw IPs.