NueForm

Webhook Signature Verification

How to verify NueForm webhook signatures using HMAC-SHA256, with code samples in Node.js, Python, Go, PHP, and Ruby.

Every webhook request from NueForm includes a cryptographic signature that lets you verify the request is authentic and has not been tampered with. You should always verify this signature before processing webhook data.

How Signing Works

When NueForm dispatches a webhook, it:

  1. Serializes the payload as a JSON string.
  2. Computes an HMAC-SHA256 hash of that JSON string using your webhook secret as the key.
  3. Encodes the hash as a lowercase hexadecimal string.
  4. Sends the hex digest in the X-NueForm-Signature HTTP header.

On your end, you perform the same computation on the raw request body and compare your result to the header value. If they match, the request is genuine.

The Signature Header

text
X-NueForm-Signature: 5d41402abc4b2a76b9719d911017c592a3f6e7d4b9c1d5e8f2a7b3c6d9e0f1a2

The header value is a 64-character hexadecimal string (the output of HMAC-SHA256).

Your Webhook Secret

Your webhook secret is a 64-character hexadecimal string generated from 32 random bytes. It is unique to your account and shared across all your webhook endpoints (both per-form and global).

Retrieving Your Secret

Fetch your current secret via the API:

bash
curl https://app.nueform.com/api/v1/webhooks/secret \
  -H "Authorization: Bearer nf_your_api_key"

Response:

json
{
  "secret": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
}

If you do not have a secret yet, NueForm automatically generates one on the first request.

Regenerating Your Secret

If your secret is compromised, regenerate it immediately:

bash
curl -X POST https://app.nueform.com/api/v1/webhooks/secret/regenerate \
  -H "Authorization: Bearer nf_your_api_key"

Regenerating your secret immediately invalidates the old one. All webhook deliveries from that point forward will use the new secret. Update your verification code before or immediately after regenerating to avoid rejecting valid webhooks.

Verification Code Samples

Node.js

javascript
import crypto from 'crypto';

function verifyWebhookSignature(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');

  if (a.length !== b.length) {
    return false;
  }

  return crypto.timingSafeEqual(a, b);
}

// Express.js example
app.post('/webhooks/nueform', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-nueform-signature'];
  const rawBody = req.body.toString();

  if (!verifyWebhookSignature(rawBody, signature, process.env.NUEFORM_WEBHOOK_SECRET)) {
    console.error('Invalid webhook signature');
    return res.status(401).send('Invalid signature');
  }

  const payload = JSON.parse(rawBody);
  console.log('Verified webhook:', payload.event, payload.responseId);

  // Process the webhook asynchronously
  processWebhookAsync(payload);

  res.status(200).send('OK');
});

When using Express.js, you must use express.raw() or express.text() to access the raw request body. If you use express.json(), the body will be parsed and re-serialized, which may produce a different string than what was signed --- causing verification to fail.

Python

python
import hashlib
import hmac

def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    # Use timing-safe comparison
    return hmac.compare_digest(expected, signature)


# Flask example
from flask import Flask, request, abort

app = Flask(__name__)

@app.route('/webhooks/nueform', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-NueForm-Signature', '')
    raw_body = request.get_data()

    if not verify_webhook_signature(raw_body, signature, WEBHOOK_SECRET):
        abort(401, 'Invalid signature')

    payload = request.get_json()
    print(f"Verified webhook: {payload['event']} {payload['responseId']}")

    # Process asynchronously (e.g., enqueue to Celery)
    process_webhook.delay(payload)

    return 'OK', 200

Go

go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
)

func verifyWebhookSignature(body []byte, signature string, secret string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))

	// hmac.Equal performs a constant-time comparison
	return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Failed to read body", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	signature := r.Header.Get("X-NueForm-Signature")
	if !verifyWebhookSignature(body, signature, webhookSecret) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	// Process webhook payload
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("OK"))
}

PHP

php
<?php

function verifyWebhookSignature(string $rawBody, string $signature, string $secret): bool {
    $expected = hash_hmac('sha256', $rawBody, $secret);

    // Use timing-safe comparison
    return hash_equals($expected, $signature);
}

// Usage
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_NUEFORM_SIGNATURE'] ?? '';
$secret = getenv('NUEFORM_WEBHOOK_SECRET');

if (!verifyWebhookSignature($rawBody, $signature, $secret)) {
    http_response_code(401);
    echo 'Invalid signature';
    exit;
}

$payload = json_decode($rawBody, true);
error_log("Verified webhook: {$payload['event']} {$payload['responseId']}");

// Process the webhook
processWebhook($payload);

http_response_code(200);
echo 'OK';

Ruby

ruby
require 'openssl'
require 'json'

def verify_webhook_signature(raw_body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)

  # Use timing-safe comparison
  Rack::Utils.secure_compare(expected, signature)
end

# Sinatra example
post '/webhooks/nueform' do
  raw_body = request.body.read
  signature = request.env['HTTP_X_NUEFORM_SIGNATURE'] || ''

  unless verify_webhook_signature(raw_body, signature, ENV['NUEFORM_WEBHOOK_SECRET'])
    halt 401, 'Invalid signature'
  end

  payload = JSON.parse(raw_body)
  logger.info "Verified webhook: #{payload['event']} #{payload['responseId']}"

  # Process asynchronously
  WebhookProcessorJob.perform_async(payload)

  status 200
  body 'OK'
end

Timing-Safe Comparison

All of the code samples above use timing-safe comparison (also called constant-time comparison) when checking signatures. This is a security best practice that prevents timing attacks.

A timing attack works by measuring how long a string comparison takes. A naive == comparison returns false as soon as it finds the first mismatched character, so an attacker could learn one character of the signature at a time by measuring response latency.

Timing-safe comparison functions always take the same amount of time regardless of how many characters match, making this attack infeasible.

LanguageFunction
Node.jscrypto.timingSafeEqual()
Pythonhmac.compare_digest()
Gohmac.Equal()
PHPhash_equals()
RubyRack::Utils.secure_compare()

Never use ===, ==, or .equals() to compare HMAC signatures. Always use your language's built-in timing-safe comparison function.

What to Do If Verification Fails

If signature verification fails, your endpoint should:

  1. Return a 401 Unauthorized status code. Do not process the payload.
  2. Log the failure for debugging. Include the request IP, timestamp, and (optionally) the received signature.
  3. Do not expose your secret in error messages or logs.
  4. Investigate common causes:
    • Wrong secret. Make sure you are using the current webhook secret. If you recently regenerated it, update your verification code.
    • Body transformation. Ensure you are verifying against the raw request body, not a parsed-and-reserialized version. Middleware that parses JSON before your verification code runs is the most common cause of failures.
    • Encoding issues. The raw body must be treated as UTF-8 bytes. Ensure your framework does not apply unexpected encoding transformations.
    • Proxy modification. If a reverse proxy (e.g., Cloudflare, nginx) is modifying the request body, the signature will not match. Configure your proxy to pass the body through unchanged.

Troubleshooting Checklist

IssueSolution
Signature never matchesVerify you are reading the raw body bytes, not a parsed JSON object
Signature stopped matching suddenlyCheck if your webhook secret was regenerated
Signature matches locally but not in productionCheck for proxy or CDN body transformation
X-NueForm-Signature header is missingEnsure your framework preserves custom headers (some strip X- prefixed headers)

Next Steps

  • Testing --- Test signature verification locally
  • Payloads --- Understand the payload format you are verifying
  • Events --- Learn about event types
Dernière mise à jour : 6 avril 2026