Skip to Content
Webhook Security

Webhook Security

Every webhook request from SingleForm is cryptographically signed so your server can verify the request is authentic and hasn’t been tampered with.

Webhook Headers

Each request includes 4 headers:

HeaderDescriptionExample
X-SingleForm-SignatureHMAC-SHA256 hex digesta3f2e1b9c8d7...
X-SingleForm-TimestampUnix timestamp in seconds1706400000
X-SingleForm-NonceUnique request identifier (32-character hex string)a1b2c3d4e5f6a7b8...
X-SingleForm-Form-IdThe form’s unique IDd4e5f6a7-b8c9-...

How Signature Verification Works

The signature is computed as follows:

  1. Construct the payload string: {formId}.{timestamp}.{nonce}
  2. Compute HMAC-SHA256(payload, your_webhook_secret)
  3. The result is a hex-encoded digest

To verify an incoming request:

  1. Extract all 4 headers
  2. Check the timestamp is within 5 minutes of the current time (prevents replay attacks)
  3. Reconstruct the payload: {formId}.{timestamp}.{nonce}
  4. Compute the HMAC-SHA256 of the payload using your webhook secret
  5. Compare the computed signature with X-SingleForm-Signature using a timing-safe comparison

Manual Verification Example (Node.js)

If you’re not using the Express middleware, here’s how to verify signatures manually:

import crypto from "crypto"; function verifyWebhook(req) { const signature = req.headers["x-singleform-signature"]; const timestamp = req.headers["x-singleform-timestamp"]; const nonce = req.headers["x-singleform-nonce"]; const formId = req.headers["x-singleform-form-id"]; // Check all headers are present if (!signature || !timestamp || !nonce || !formId) { return false; } // Check timestamp is within 5 minutes const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp)) > 300) { return false; } // Compute expected signature const payload = `${formId}.${timestamp}.${nonce}`; const expected = crypto .createHmac("sha256", process.env.SINGLEFORM_SECRET) .update(payload) .digest("hex"); // Timing-safe comparison try { return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); } catch { return false; } }

Timestamp Tolerance

By default, requests older than 300 seconds (5 minutes) are rejected. This prevents replay attacks where an attacker captures a valid request and resends it later.

If you’re using the Express middleware, you can configure the tolerance:

singleform({ secret: process.env.SINGLEFORM_SECRET, timestampTolerance: 600, // Allow 10 minutes instead of 5 });

Security Error Types

When signature verification fails, the middleware returns one of these errors:

Error TypeStatusWhen It Occurs
MISSING_HEADERS401One or more required headers are absent
INVALID_TIMESTAMP401Timestamp is not a valid number
TIMESTAMP_EXPIRED401Request is older than the tolerance window
INVALID_SIGNATURE401Signature format is invalid (wrong length)
SIGNATURE_MISMATCH401Computed signature doesn’t match the header

All security errors follow this format:

{ "success": false, "error": { "type": "SIGNATURE_MISMATCH", "message": "Signature verification failed. Check your webhook secret." } }

Webhook Secret Format

Your webhook secret:

  • Starts with the prefix sf_secret_
  • Followed by a 64-character hex string
  • Example: sf_secret_a1b2c3d4e5f6...

Generate or regenerate your secret in the SingleForm dashboard under Settings → Webhook Security.

Store your webhook secret in an environment variable. Never commit it to source code or expose it in client-side code.

Best Practices

  • Always use HTTPS for your webhook endpoint
  • Store secrets in environment variables, never in source code
  • Use the Express SDK when possible — it handles all verification automatically
  • Monitor for SIGNATURE_MISMATCH errors — frequent mismatches may indicate your secret needs to be rotated
  • Don’t disable timestamp validation — it’s your primary defense against replay attacks