Skip to content
FormSubmit

Notifications

Webhooks

Receive every submission as signed JSON, verify the signature and understand retries.

Add a Webhook integration to a form and FormSubmit sends an HTTP POST with a JSON body to your URL for every new submission. URLs must use HTTPS and resolve to a public host.

Payload

payload.json
{
  "event": "submission.created",
  "form": { "id": "k3m9p2qabx", "name": "Website contact" },
  "submission": {
    "id": "r8T2kLm0Qa9zXc1V",
    "createdAt": "2026-09-28T10:15:00.000Z",
    "data": {
      "name": "Ada Lovelace",
      "email": "ada@example.com",
      "message": "Hello!"
    },
    "files": [
      {
        "field": "resume",
        "filename": "cv.pdf",
        "url": "https://formsubmit.app/api/files/…?exp=…&sig=…",
        "size": 182044,
        "contentType": "application/pdf"
      }
    ]
  }
}

The Send test button delivers the same shape with "event": "test" and sample data.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentFormSubmit-Webhooks/1.0
X-FormSubmit-Eventsubmission.created or test
X-FormSubmit-Signaturet=<unix seconds>,v1=<hex HMAC>

Verifying signatures

The signature is HMAC-SHA256(secret, "{t}.{rawBody}") as lowercase hex, where secret is the integration's signing secret (auto-generated if you leave it blank). Always verify against the raw request body, and reject old timestamps to prevent replays.

verify.js
import crypto from "node:crypto";

export function verifyFormSubmit(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}

// Next.js route handler
export async function POST(req) {
  const raw = await req.text();
  if (!verifyFormSubmit(raw, req.headers.get("x-formsubmit-signature") ?? "", process.env.FORMSUBMIT_SECRET)) {
    return new Response("Invalid signature", { status: 401 });
  }
  const payload = JSON.parse(raw);
  // …handle payload.submission.data
  return new Response("ok");
}
verify.py
import hmac, hashlib, time

def verify_formsubmit(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Responses, timeouts and retries

  • Respond with any 2xx within 10 seconds. Do slow work asynchronously.
  • Redirects are not followed.
  • Retried: network errors, timeouts, 408, 429 and 5xx.
  • Not retried: other 4xx responses (fix your endpoint or config, then retry manually).

Failed deliveries are retried with exponential backoff: after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours — six attempts in total. Every attempt, status code and error appears in the form's delivery log, where you can also retry manually.

Deliveries are at-least-once: in rare cases you may receive the same submission twice, so de-duplicate on submission.id.

Zapier, Make and n8n

Use a "Catch Hook" (Zapier), "Custom webhook" (Make) or "Webhook" node (n8n) URL as the endpoint, then click Send test so the platform learns the fields.

Frequently asked questions

Can I receive the raw form fields without the wrapper object?

No — the payload shape is fixed so it can be versioned safely. The fields are in submission.data.

Are webhooks sent for spam or locked submissions?

No. Only submissions that land in your inbox are delivered to integrations.