Webhooks
A webhook delivers each processed submission to a URL you control as an HTTP POST with a JSON body. Each form can have multiple webhooks — configure each one's URL, type (generic, Slack, Discord, Teams, or Mattermost), and an optional signing secret in your form's Settings. Every webhook receives every processed submission independently, with its own delivery log and retries. This page documents the generic webhook: its payload, how to verify the signature, and the delivery guarantees.
Payload schema
Generic webhooks are sent with Content-Type: application/json. The body has this shape:
{
"formId": "<formId>",
"formName": "Contact form",
"submissionId": "<submissionId>",
"createdAt": "2026-01-02T09:30:00.000Z",
"status": "processed",
"fields": {
"email": "a@example.com",
"message": "Hi there"
},
"ai": {
"spamScore": 3,
"summary": "New enquiry from a@example.com",
"leadScore": 72,
"routingCategory": "sales"
}
}fieldscontains the submitted form fields. Internal fields (those beginning with_, such as_redirect) are omitted.aivalues arenullon plans without AI enrichment, or when a given enrichment is disabled for the form.submissionIdis stable for a given submission. Use it as an idempotency key to deduplicate retried deliveries (see below).
Signature verification
When the form has a webhook signing secret, every delivery carries an X-Formward-Signature header containing an HMAC-SHA256 of the raw request body, keyed with your secret. The replay-resistant v1 scheme binds a timestamp into the signed value:
X-Formward-Signature: t=<unix-timestamp>,v1=<hex-hmac>To verify a delivery:
- Parse
tandv1from the header. - Recompute
HMAC-SHA256over the stringv1:{t}:{rawBody}using your shared secret, whererawBodyis the exact bytes of the request body (verify before any JSON parsing or re-serialisation). - Compare your computed hex against
v1using a constant-time comparison. - Reject the delivery if
abs(now - t)exceeds your tolerance (for example 5 minutes) to defeat replay of a captured request.
Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret, toleranceSec = 300) {
const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header || "");
if (!m) return false;
const t = Number(m[1]);
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret)
.update(`v1:${t}:${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(m[2], "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Python:
import hmac, hashlib, time, re
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
m = re.match(r"^t=(\d+),v1=([0-9a-f]+)$", header or "")
if not m:
return False
t = int(m.group(1))
if abs(time.time() - t) > tolerance:
return False
signed = f"v1:{t}:".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, m.group(2))Legacy format. During a secret-rotation or scheme upgrade window a delivery may instead carry a bare hex HMAC (the HMAC-SHA256 of the raw body with no t=/v1= prefix). If your X-Formward-Signature value is plain hex, recompute HMAC-SHA256(rawBody) and constant-time compare. Prefer the v1 scheme where present, since only it is replay-resistant.
Delivery semantics
- At-least-once. Deliveries are queued and retried on failure, so your endpoint may receive the same
submissionIdmore than once. Make handling idempotent by keying onsubmissionId. - HTTPS only. Webhook URLs must use
httpsand may not point at private, loopback, or link-local addresses (an SSRF guard rejects them at save time and at delivery time). - No redirects. A
3xxresponse is treated as a failure; we do not follow redirects. - Success means 2xx. Respond with a
2xxstatus within 10 seconds. Any status≥ 400, a redirect, a timeout, or a connection error is a failure and is retried. - Deliveries are sent after a submission reaches
processed; spam and held submissions are not delivered.
Chat integrations (Slack, Discord, Teams, Mattermost)
When the webhook type is Slack, Discord, Teams, or Mattermost, Formward posts a chat-formatted message (e.g. a text/blocks payload for Slack, a content payload for Discord) to the incoming-webhook URL you paste from that platform, rather than the JSON schema above. These are not signed. For n8n, Make, or Zapier, use a generic webhook.