Webhooks
A webhook delivers each processed submission to a URL you control as an HTTP POST with a JSON body. Configure the webhook URL, type (generic, Slack, or Discord), and an optional signing secret in your form's Settings. 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.
Slack & Discord
When the webhook type is Slack or Discord, Formward posts a chat-formatted message (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.