send.webforms.toRead the docs →

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:

POST your-endpoint
{
  "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)

verify.ts
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)

verify.py
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:

Terminal
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