Every outbound webhook TruForms sends is signed. You verify the signature using the secret shown in the form's Integrations → Webhook panel. Rotating the secret in the dashboard takes effect on the next delivery.
Why bother? Your webhook URL is just a public address — anyone who learns it could POST fake submissions to your server. The signature is proof a request genuinely came from TruForms (and wasn't tampered with in transit). Verifying it is a few lines of code; the snippets below are copy-paste ready.
Headers we send
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
TruForms-Webhook/1.0 |
X-TruForms-Event |
Always submission.created for now. |
X-TruForms-Submission-Id |
The submission UUID (or a random UUID for test-fires). |
X-TruForms-Timestamp |
Unix timestamp (seconds) when we signed the request. |
X-TruForms-Signature |
sha256=<hex> where hex is HMAC-SHA256(secret, timestamp + '.' + body). |
Payload shape
{
"event": "submission.created",
"id": "b3b1f9e3-...",
"formId": "c9c4f8a2-...",
"submission": {
"name": "Alice",
"email": "[email protected]",
"message": "Hi"
},
"test": false,
"createdAt": "2026-04-22T10:00:00.000Z"
}
test: true indicates a synthetic payload from the "Send test" button in the dashboard.
Verification — Node.js
import crypto from 'node:crypto';
export function verifyTruFormsWebhook(req, secret) {
const signature = req.headers['x-truforms-signature'];
const timestamp = req.headers['x-truforms-timestamp'];
if (!signature || !timestamp) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.rawBody}`)
.digest('hex');
const given = signature.replace(/^sha256=/, '');
return crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
Note you need access to the raw request body (pre-JSON-parse). In Express, use express.json({ verify: (req, _res, buf) => (req.rawBody = buf) }).
Verification — Python
import hmac, hashlib
def verify(headers, body_bytes, secret):
signature = headers.get('x-truforms-signature', '')
timestamp = headers.get('x-truforms-timestamp', '')
if not signature or not timestamp:
return False
signed = f'{timestamp}.'.encode() + body_bytes
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
given = signature.removeprefix('sha256=')
return hmac.compare_digest(given, expected)
Verification — Go
package truforms
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
)
func VerifyWebhook(r *http.Request, body []byte, secret string) bool {
sig := strings.TrimPrefix(r.Header.Get("X-TruForms-Signature"), "sha256=")
ts := r.Header.Get("X-TruForms-Timestamp")
if sig == "" || ts == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
Replay protection
Reject requests where abs(now - timestamp) > 300 seconds. We sign every request, so an attacker can't modify the body without breaking the signature — but they could replay a valid request. The timestamp + 5-minute window defeats that.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('Stale signature');
}
Delivery guarantees
- Retries — up to 8 attempts with exponential backoff (starts at 60s, caps around 2 hours).
- Idempotency — include
X-TruForms-Submission-Idin your dedup key; we may deliver the same event twice after a server restart. - Dead letter — after the final failed attempt, the delivery is marked
deadin the TruForms dashboard and stops retrying. Re-enable the integration to resume new deliveries.
What counts as success?
Any HTTP status in the 2xx range. Return 200 with any body (or no body) as soon as you've persisted or queued the event. Don't do long work in the webhook handler — ACK fast, process async.
Further reading
- RFC 2104 — HMAC: Keyed-Hashing for Message Authentication — the algorithm behind our
X-TruForms-Signature. - FIPS 180-4 — Secure Hash Standard (SHA-256) — the hash function we sign with.
- Node.js
crypto.timingSafeEqual()— why the snippets above avoid===for signature comparison. - OWASP — Timing attack / unsafe comparison — the class of bug constant-time comparison prevents.