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:
- Serializes the payload as a JSON string.
- Computes an HMAC-SHA256 hash of that JSON string using your webhook secret as the key.
- Encodes the hash as a lowercase hexadecimal string.
- Sends the hex digest in the
X-NueForm-SignatureHTTP 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
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:
curl https://app.nueform.com/api/v1/webhooks/secret \
-H "Authorization: Bearer nf_your_api_key"
Response:
{
"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:
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
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
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
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
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
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.
| Language | Function |
|---|---|
| Node.js | crypto.timingSafeEqual() |
| Python | hmac.compare_digest() |
| Go | hmac.Equal() |
| PHP | hash_equals() |
| Ruby | Rack::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:
- Return a
401 Unauthorizedstatus code. Do not process the payload. - Log the failure for debugging. Include the request IP, timestamp, and (optionally) the received signature.
- Do not expose your secret in error messages or logs.
- 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
| Issue | Solution |
|---|---|
| Signature never matches | Verify you are reading the raw body bytes, not a parsed JSON object |
| Signature stopped matching suddenly | Check if your webhook secret was regenerated |
| Signature matches locally but not in production | Check for proxy or CDN body transformation |
X-NueForm-Signature header is missing | Ensure your framework preserves custom headers (some strip X- prefixed headers) |