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"
  }
}
  • fields contains the submitted form fields. Internal fields (those beginning with _, such as _redirect) are omitted.
  • ai values are null on plans without AI enrichment, or when a given enrichment is disabled for the form.
  • submissionId is 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:

  1. Parse t and v1 from the header.
  2. Recompute HMAC-SHA256 over the string v1:{t}:{rawBody} using your shared secret, where rawBody is the exact bytes of the request body (verify before any JSON parsing or re-serialisation).
  3. Compare your computed hex against v1 using a constant-time comparison.
  4. 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 submissionId more than once. Make handling idempotent by keying on submissionId.
  • HTTPS only. Webhook URLs must use https and 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 3xx response is treated as a failure; we do not follow redirects.
  • Success means 2xx. Respond with a 2xx status 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.

Webhooks, payload schema and signature verification | Formward Docs