Documentation
Webhook signing
Give a webhook destination a secret and every delivery includes signature headers your server can verify, so you know a request genuinely came from WebForms.
Payload
A webhook delivery POSTs the submission as JSON:
{
"event": "submission.created",
"form": { "id": "frm_...", "name": "Contact form" },
"submission": {
"id": "sub_...",
"data": { "email": "ada@example.com", "message": "Hi" },
"submittedAt": "2026-08-16T20:00:00.000Z",
"spamScore": 0
}
}Signature headers
Every request carries these, but only when the destination has a secret set - an unsigned webhook is delivered without them:
X-WebForms-Timestamp- Unix milliseconds when the request was signed.X-WebForms-Signature-sha256=<hex>, an HMAC-SHA256 over{timestamp}.{raw request body}using your webhook secret.
Verifying it (Node)
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(req: Request, rawBody: string, secret: string) {
const ts = req.headers.get("x-webforms-timestamp")!;
const sig = req.headers.get("x-webforms-signature")!; // "sha256=<hex>"
const expected = "sha256=" + createHmac("sha256", secret)
.update(ts + "." + rawBody)
.digest("hex");
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Verifying it (Python)
import hashlib, hmac
def verify(timestamp: str, signature: str, raw_body: bytes, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), (timestamp + ".").encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)Replay protection
Reject requests whose timestamp is more than five minutes old. Combined with the signature, this means a captured request cannot be replayed later even if the attacker has the raw body and headers.
Always compare signatures with a constant-time comparison (timingSafeEqual / hmac.compare_digest) rather than === or == - a naive string comparison leaks timing information an attacker can use to guess the correct signature byte by byte.
Creating a webhook destination
In the dashboard: Form → Destinations → Add destination → Webhook. Or over the write API:
curl -X POST https://api.webforms.to/v1/forms/frm_xxx/destinations \
-H "Authorization: Bearer wf_live_your_key" \
-H "Content-Type: application/json" \
-d '{"provider": "webhook", "fields": {"url": "https://your-server.com/hook", "secret": "whsec_..."}}'Webhook URLs cannot point at private, loopback or link-local addresses (SSRF protection) - the destination has to be a real, publicly reachable server.
Get your endpoint
Free for 300 submissions a month. No card required.
Start free