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:
| Header | Description | Example |
|---|---|---|
X-SingleForm-Signature | HMAC-SHA256 hex digest | a3f2e1b9c8d7... |
X-SingleForm-Timestamp | Unix timestamp in seconds | 1706400000 |
X-SingleForm-Nonce | Unique request identifier (32-character hex string) | a1b2c3d4e5f6a7b8... |
X-SingleForm-Form-Id | The form’s unique ID | d4e5f6a7-b8c9-... |
How Signature Verification Works
The signature is computed as follows:
- Construct the payload string:
{formId}.{timestamp}.{nonce} - Compute
HMAC-SHA256(payload, your_webhook_secret) - The result is a hex-encoded digest
To verify an incoming request:
- Extract all 4 headers
- Check the timestamp is within 5 minutes of the current time (prevents replay attacks)
- Reconstruct the payload:
{formId}.{timestamp}.{nonce} - Compute the HMAC-SHA256 of the payload using your webhook secret
- Compare the computed signature with
X-SingleForm-Signatureusing 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 Type | Status | When It Occurs |
|---|---|---|
MISSING_HEADERS | 401 | One or more required headers are absent |
INVALID_TIMESTAMP | 401 | Timestamp is not a valid number |
TIMESTAMP_EXPIRED | 401 | Request is older than the tolerance window |
INVALID_SIGNATURE | 401 | Signature format is invalid (wrong length) |
SIGNATURE_MISMATCH | 401 | Computed 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_MISMATCHerrors — frequent mismatches may indicate your secret needs to be rotated - Don’t disable timestamp validation — it’s your primary defense against replay attacks