ROASFormDocs
Webhooks

Verifying signatures

Verify the HMAC signature on every delivery before trusting the body.

Every delivery is signed with your webhook's secret, shown once when the webhook is created (or rotated). It looks like whsec_0mPUUZVb…. The whsec_ prefix is part of the key, so pass the whole string to your HMAC function rather than stripping it.

The secret is never transmitted with a delivery. It is the key we sign with and the key you verify with, which is why deliveries carry no Authorization header.

Verify before processing: recompute the HMAC over the exact raw bytes you received and compare in constant time. Re-serializing parsed JSON will break the signature.

The ROASForm-Signature header has the form:

t=1753712000,v1=5f8a3c…

where t is a Unix timestamp (seconds) and v1 is HMAC-SHA256(secret, "<t>.<rawBody>") hex-encoded. Rejecting old timestamps blunts replay attacks; 5 minutes is a reasonable tolerance.

verify.js
import crypto from "crypto";

// rawBody MUST be the exact bytes received (do not re-serialize parsed JSON).
export function verifyRoasformWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => kv.split("=")),
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;

  // Optional replay window.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
verify.py
import hashlib
import hmac
import time


# raw_body MUST be the exact bytes received (do not re-serialize parsed JSON).
def verify_roasform_webhook(raw_body, signature_header, secret, tolerance_sec=300):
    parts = dict(kv.split("=", 1) for kv in signature_header.split(",") if "=" in kv)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or not t.isdigit():
        return False

    # Optional replay window.
    if abs(int(time.time()) - int(t)) > tolerance_sec:
        return False

    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, v1)
verify.php
<?php

// $rawBody MUST be the exact bytes received: file_get_contents('php://input'),
// never a re-encoded $_POST or json_encode(json_decode(...)).
function verify_roasform_webhook(
    string $rawBody,
    string $signatureHeader,
    string $secret,
    int $toleranceSec = 300
): bool {
    $parts = [];
    foreach (explode(',', $signatureHeader) as $kv) {
        $pair = explode('=', $kv, 2);
        if (count($pair) === 2) {
            $parts[trim($pair[0])] = $pair[1];
        }
    }

    $t = $parts['t'] ?? '';
    $v1 = $parts['v1'] ?? '';
    if ($t === '' || $v1 === '' || !ctype_digit($t)) {
        return false;
    }

    // Optional replay window.
    if (abs(time() - (int) $t) > $toleranceSec) {
        return false;
    }

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);

    return hash_equals($expected, $v1);
}
verify.rb
require "openssl"

# raw_body MUST be the exact bytes received (do not re-serialize parsed JSON).
def verify_roasform_webhook(raw_body, signature_header, secret, tolerance_sec = 300)
  parts = signature_header.split(",").map { |kv| kv.split("=", 2) }.to_h
  t = parts["t"]
  v1 = parts["v1"]
  return false if t.nil? || v1.nil? || t !~ /\A\d+\z/

  # Optional replay window.
  return false if (Time.now.to_i - t.to_i).abs > tolerance_sec

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")

  # Constant-time compare (OpenSSL.fixed_length_secure_compare needs openssl >= 2.2).
  return false unless expected.bytesize == v1.bytesize

  expected.bytes.zip(v1.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
end

If your receiver can't verify

Verification is strongly recommended, not required. No-code receivers (Zapier, Make, n8n, a CRM's inbound webhook trigger) can consume deliveries without ever reading the signature header. In that case the URL is your secret: those platforms generate long, random endpoint URLs that are unguessable in practice, so keep the URL private, and if it ever leaks, create a new endpoint and update the webhook's URL. Deliveries are always HTTPS, so payloads cannot be read or altered in transit either way; the signature's job is proving the request came from ROASForm, which matters most when your endpoint URL is public or shared.

Rotating a secret

Rotate from the webhook editor in the dashboard. The new secret is shown once; update your endpoint immediately, since deliveries sign with the new secret from that point on.

Endpoint requirements

Endpoints must be public HTTPS URLs. Private and internal addresses (localhost, internal IP ranges) are rejected, and redirects are never followed.