Skip to content
Reference

What Formly sends

When someone finishes a form, Formly posts one JSON object to the webhook URL saved in that page’s settings. This page is the whole contract: the body, the headers, the signature and the retry schedule. Nothing here needs a Formly account to read.

Which tools take it

Any tool that accepts an incoming webhook, on both plans. The settings page carries a short guide for each of these; steps were checked against each vendor’s own pages on 2026-09-23.

  • MakeSends each lead into a scenario, which can pass it to almost any tool.
  • ZapierSends each lead into a Zap.
  • n8nFor n8n Cloud or your own install.
  • GoHighLevelStarts a workflow from the Inbound Webhook trigger.
  • HubSpotEvery lead becomes a HubSpot contact, through a Make scenario.
  • Google SheetsA new row per lead, through a Make scenario.
  • Something elseAny tool that can receive a webhook.

The payload

version is 1 and only changes if a field is removed or renamed; new fields may be added without a bump. contact picks out the name, email and phone the visitor gave (null when a field was not on the form). answersById has every answer by field id, which is stable when the owner reorders the form, and answers has the same answers in page order with their labels. Checkbox answers are booleans; everything else is a string.

{
  "version": 1,
  "event": "lead.created",
  "delivery": {
    "id": "6f1c0a8e-2b7a-4e2e-9c8a-1f3a5d6e7b90"
  },
  "business": {
    "name": "Riverside Dental"
  },
  "form": {
    "id": "3c9d2e1a-7b40-4f8e-a1c2-5d6e7f8a9b0c",
    "name": "Riverside Dental",
    "slug": "riverside-dental"
  },
  "page": {
    "url": "https://formlypage.com/f/riverside-dental"
  },
  "lead": {
    "id": "9a7b6c5d-4e3f-4a2b-8c1d-0e9f8a7b6c5d",
    "submittedAt": "2026-09-23T14:02:11.482Z"
  },
  "contact": {
    "name": "Jamie Rowe",
    "email": "jamie@example.com",
    "phone": "216-555-0142"
  },
  "answers": [
    {
      "id": "name",
      "label": "Your name",
      "value": "Jamie Rowe"
    },
    {
      "id": "phone",
      "label": "Best number to reach you",
      "value": "216-555-0142"
    },
    {
      "id": "email",
      "label": "Email",
      "value": "jamie@example.com"
    },
    {
      "id": "service",
      "label": "What do you need?",
      "value": "A check-up"
    },
    {
      "id": "message",
      "label": "Anything we should know?",
      "value": "Evenings suit me best."
    },
    {
      "id": "new_patient",
      "label": "I am a new patient",
      "value": true
    }
  ],
  "answersById": {
    "name": "Jamie Rowe",
    "phone": "216-555-0142",
    "email": "jamie@example.com",
    "service": "A check-up",
    "message": "Evenings suit me best.",
    "new_patient": true
  },
  "receivedAt": "2026-09-23T14:02:11.482Z"
}

The headers

Every post is a POST with these headers.

  • Content-Typeapplication/json. The body is JSON.
  • User-AgentFormly-Webhook/1. Who is calling.
  • X-Formly-Eventlead.created or lead.test. A real lead, or the test button.
  • Idempotency-Keythe lead id (the delivery id for a test lead). The same value on every retry of the same lead, so your tool can ignore a duplicate.
  • webhook-idthe delivery id. Unique per delivery. The same on every retry.
  • webhook-timestampseconds since 1970. When Formly sent it. Reject anything older than five minutes to block replays.
  • webhook-signaturev1,<base64 HMAC-SHA256>. Signed over "id.timestamp.body" with your secret. Standard Webhooks format.

Retries

Each attempt waits up to 5 seconds for an answer (8 seconds for a test lead). A 2xx answer counts as delivered. Redirects count as failures, so give Formly the final URL.

A test lead (X-Formly-Event: lead.test) is sent once and never retried. A real lead follows this schedule until a 2xx comes back:

  • Right awayThen again after 2 seconds and after 8 seconds if your tool did not answer 2xx.
  • 5 minutesThe first scheduled retry.
  • 15 minutesAnother try.
  • 1 hourAnother try.
  • 4 hoursAnother try.
  • 12 hoursThe last try. After it the delivery is marked failed and the log says so. The lead itself is safe in your dashboard and inbox throughout.

Every attempt is written to the delivery log on the page’s settings, with the HTTP status and how long it took. The owner can retry any row by hand. webhook-id and Idempotency-Key stay the same across retries of one lead, so a receiver that stores them can drop a duplicate.

Checking the signature

Optional, and most tools skip it. Each page has its own secret, shown in full on its settings page and rotatable there. The scheme is Standard Webhooks: the signed content is {webhook-id}.{webhook-timestamp}.{raw body}, the key is the secret with its whsec_ prefix removed and base64url-decoded, and the signature is base64 of HMAC-SHA256 over the content, sent as v1,<signature>. A receiver that already verifies Svix or Clerk signatures can reuse its code. Compare against the raw request body, not a re-serialised one.

import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyFormly(secret, headers, rawBody) {
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64url");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
  return String(headers["webhook-signature"]).split(" ").some((part) => {
    const [version, sig = ""] = part.split(",");
    return version === "v1" && sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}

Setting it up

Open the page’s settings, pick your tool under Connect a tool, paste its webhook URL, save, and press Send a test lead. The status pill and the log show what came back.