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"
  }
}
  • 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.

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.

Webhooks, payload schema and signature verification | Formward Docs