# n8n form submission: a reliable setup for your leads

_By Luka Brzin, Senior Product Marketing Manager_

n8n takes form leads in two ways. Its Form Trigger node hosts a simple form for you. For a form on your own website, point the form at a Webhook node's Production URL. Either way the workflow has to be published (n8n 2.x calls it Publish; older versions had an Active toggle), the lead should be stored before anything else runs, and you need a plan for the moments n8n can't answer, because a browser has nowhere else to send that lead. This page covers the setup, the settings that make it reliable, and a pattern where a form backend catches the lead and n8n does the routing.

## Key takeaways

-   Use the Form Trigger for a form n8n hosts, and the Webhook node for a form on your own site. Both run only after you publish the workflow.
-   The Test URL (`/webhook-test/`) only listens for 120 seconds after you click Listen for test event. Real visitors must use the Production URL (`/webhook/`).
-   Fields from your form arrive under `$json.body`, for example `{{ $json.body.email }}`.
-   Store the lead first (a data table, Google Sheets or a database), then send alerts, and set an error workflow so a failed run tells you.
-   If n8n is down when someone presses Send, that lead is gone. A form backend that stores first and delivers signed, retried webhooks to n8n closes that gap.

## Form Trigger or Webhook node?

Form Trigger

Webhook node

Where the form lives

A page n8n hosts

Your own site

Design

n8n's layout, restyled with CSS

Whatever your site does

Production URL

`/form/your-path`

`/webhook/your-path`

Good for

Internal and quick forms

Contact and quote forms on your site

The [Form Trigger](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.formtrigger/) builds the page from the fields you add (text, email, dropdowns, files, hidden fields and more), can wait for the workflow to finish before it answers (Respond When), and can drop the "Form automated with n8n" line. It can't take your own site's HTML form: its Custom HTML element strips `<form>` and `<input>` tags. For a form you already have, use the Webhook node.

## Set up the Webhook node for your site's form

1.  Add a **Webhook** node. Set **HTTP Method** to POST and **Path** to something like `contact`.
2.  Set **Respond** to **Using 'Respond to Webhook' Node**, so you decide what the visitor sees.
3.  If you'll send the form with JavaScript (below), open **Add Option** and set **Allowed Origins (CORS)** to your site, for example `https://www.example.com`; the default is `*`. CORS only decides which pages may read n8n's reply. It doesn't stop anyone posting.
4.  Add your storage step (next section), then a **Respond to Webhook** node with **Respond With** set to Redirect and your thank-you page as the Redirect URL. Add the **Response Code** option and set it to 303, so the browser loads the thank-you page with a normal GET.
5.  Copy the **Production URL** from the Webhook node and put it in your form's `action`.
6.  Click **Publish**. Until you do, the Production URL isn't registered ([n8n's publish docs](https://docs.n8n.io/build/understand-workflows/save-and-publish-workflows/), checked October 2026).

```
<form action="https://your-n8n.example.com/webhook/contact" method="post">
  <label>Name <input name="name" required></label>
  <label>Email <input name="email" type="email" required></label>
  <label>Message <textarea name="message"></textarea></label>

  <!-- Honeypot: hidden from people, filled in by simple bots -->
  <div aria-hidden="true" style="position:absolute;left:-9999px">
    <label>Leave this empty <input name="website" tabindex="-1" autocomplete="off"></label>
  </div>

  <button type="submit">Send</button>
</form>
```

In later nodes the fields are under `body`: `{{ $json.body.name }}`, `{{ $json.body.email }}`. Every input needs a `name` attribute, or it never reaches n8n.

If your form uses JavaScript instead of a page change, send it URL-encoded with no custom headers. That makes it a "simple" request, so the browser skips the CORS preflight; Allowed Origins still has to include your site. For this version, set the Webhook node's Respond to Immediately instead of using a redirect.

```
const form = document.querySelector('form');

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  // URL-encoded body, no custom headers: a "simple" request, so no CORS preflight.
  const res = await fetch(form.action, {
    method: 'POST',
    body: new URLSearchParams(new FormData(form)),
  }).catch(() => null);

  if (res?.ok) {
    form.replaceWith('Thanks, we got your message.');
  } else {
    alert('That did not send. Please try again, or email us directly.');
  }
});
```

Self-hosting behind a reverse proxy? If the Production URL shows `localhost`, set `N8N_WEBHOOK_URL` to your public address (`WEBHOOK_URL` is deprecated from n8n 2.35.0) ([n8n docs](https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/configure-webhook-urls-with-reverse-proxy/)).

## Make it reliable

The Webhook node only receives. Whether the lead survives is up to the rest of the workflow.

-   **Store first.** Put a **Data Table** node (Insert row), a Google Sheets row or a Postgres insert straight after the Webhook node, before any alert. A failed Slack message then doesn't cost you the lead. n8n's data tables share 200 MiB per instance by default ([data tables docs](https://docs.n8n.io/build/work-with-data/data-tables/), checked October 2026), plenty for leads.
-   **Drop honeypot hits.** The webhook URL is public, so bots will find it. Add an **If** node after storage that stops when `{{ $json.body.website }}` is not empty, or before storage if you'd rather not keep them. Header auth doesn't help here: a header your page's JavaScript sends is visible to anyone who opens the page's source.
-   **Alert after storing.** Email, Slack or whatever you use, reading from the stored row.
-   **Get told when a run fails.** Make a second workflow that starts with an **Error Trigger** and sends you a message, then pick it as the error workflow in the form workflow's settings. It doesn't need publishing, and it only fires for real runs, not manual tests ([Error Trigger docs](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.errortrigger/)).
-   **Re-publish after edits.** n8n saves edits as a draft automatically; production keeps running the published version until you publish again.
-   **Count executions.** Each lead is one run of this workflow. On n8n Cloud, Starter is €20 a month billed annually for 2,500 executions a month and Pro is €50 for 10,000; the self-hosted Community Edition is free ([n8n pricing](https://n8n.io/pricing/), checked October 2026).

One gap no node can close: if n8n is down, restarting or unpublished when a visitor presses Send, the browser's request fails and that lead never exists anywhere. For a contact form on a busy site, that is the reason to put something in front of n8n.

## When n8n form leads go missing

What you see

Cause

Fix

404 or "not registered" on the Production URL

The workflow isn't published

Click Publish

Worked in testing, nothing now

The form still points at the Test URL

Use the Production URL (`/webhook/`)

Visitors see a bare JSON message

Respond is Immediately on a plain HTML form

Respond to Webhook with a 303 redirect

CORS error in the browser console

JSON body or custom headers, or your site isn't in Allowed Origins

Send URL-encoded; add your site

Empty fields in n8n

Inputs without `name`, or reading `$json.email`

Add names; read `$json.body.email`

Your changes don't take effect

Production runs the published version

Publish again

The workflow's **Executions** tab shows every production run, failed ones included. If a lead isn't there, the request never reached n8n; if it's there and red, the execution shows which node broke.

If leads reach n8n but the alert email never lands, that's a delivery problem, not an n8n one: see [contact form not sending email](/blog/contact-form-not-sending-email).

## Let a form backend catch the lead, and n8n route it

Here the form posts to a form backend, which stores the lead first and then sends a signed webhook to n8n. If n8n is down, the backend retries; nothing depends on n8n being up at the moment a visitor presses Send. OllieLeads works this way (it's ours, checked October 2026 against our [developer docs](/developers)):

-   Leads are stored in the EU (Frankfurt) before anything is sent. Posts caught by the honeypot are never stored as leads, and posts from sites that aren't yours are refused.
-   Each delivery is a POST of a small JSON event that carries IDs only, such as `lead.submitted` with `brand_id` and `lead_id`, signed in the `Ollie-Signature: t=<unix>,v1=<hex>` header.
-   Answer with any 2xx within 10 seconds. Anything else is retried after 1, 5 and 15 minutes, then 1, 3, 6 and 12 hours: 8 attempts over about 22 hours. The event ID never changes across retries, so you can skip IDs you've already handled.
-   Free covers 500 leads in total, no card; Pro is $29 a month for 1,000 leads a month ([pricing](/pricing)).

The n8n side:

1.  **Webhook** node: POST, path `ollieleads`, Respond set to Using 'Respond to Webhook' Node, and the **Raw Body** option on. Publish, then add the Production URL as an endpoint in the OllieLeads app (Integrations, Webhooks) with the `lead.submitted` event, and copy the signing secret, which is shown once. The URL must be public HTTPS.
2.  **Code** node to check the signature. It hashes the body exactly as it arrived, which is why Raw Body is on:

```
// Code node, Mode: Run Once for All Items, Language: JavaScript
const crypto = require('crypto');

// The endpoint's signing secret (whsec_...), shown once when you added the endpoint.
const SECRET = 'whsec_paste_yours_here';

const header = $input.first().json.headers['ollie-signature'] ?? '';
const parts = Object.fromEntries(header.split(',').map((pair) => pair.split('=')));
const t = Number(parts.t);
const v1 = parts.v1 ?? '';

// The body exactly as it arrived. Needs the Webhook node's Raw Body option.
const raw = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');

const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${raw}`).digest('hex');
const fresh = Number.isFinite(t) && Math.abs(Date.now() / 1000 - t) <= 300;
const valid = v1.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));

if (!fresh || !valid) {
  throw new Error('Ollie-Signature check failed');
}

// {"id": "evt_...", "type": "lead.submitted", "brand_id": "...", "lead_id": "...", ...}
return [{ json: JSON.parse(raw) }];
```

1.  **Respond to Webhook** (No Data) right after the check, so the reply goes out well inside 10 seconds. If the check throws, n8n answers 500 and a forged request gets nothing.
2.  **If** node on `{{ $json.type }}` equal to `lead.submitted`. The "Send a test event" button sends `webhook.test`, which has no lead to fetch.
3.  **HTTP Request** node to read the lead: GET `https://api.ollieleads.com/v1/brands/{{ $json.brand_id }}/leads/{{ $json.lead_id }}` with Header Auth, name `Authorization`, value `Bearer ol_api_live_...` (an API key with `leads:read`).

The lead comes back with the email under `contact.email` and everything the visitor typed under `untrusted_visitor_input` (name, phone and `answers` by field name). Treat that part as data from a stranger: if an AI Agent node reads it, keep it out of the instructions. On n8n Cloud, `require('crypto')` works in the Code node as it is; self-hosted, set `NODE_FUNCTION_ALLOW_BUILTIN=crypto` (on the task runners, if you use them). If you'd rather catch people who leave halfway too, the same webhook can fire `lead.left_early`; see [people who leave halfway](/features/partial-submissions).

## FAQ

### Why is my n8n form submission not arriving?

Usually the workflow isn't published, or the form points at the Test URL, which only listens for 120 seconds after you click Listen for test event. Publish the workflow, use the Production URL, then send a test and look in the Executions tab. No execution means the request never arrived.

### What's the difference between the n8n test URL and production URL?

The Test URL (`/webhook-test/your-path`) is for building: data shows in the editor, and it stops listening after 120 seconds. The Production URL (`/webhook/your-path`) works while the workflow is published, and its runs appear in the Executions tab instead of the editor.

### Can I use the n8n Form Trigger with my own HTML form?

No. The Form Trigger serves its own page, and its Custom HTML element strips form and input tags. Point your own form at a Webhook node instead.

### How do I stop spam on an n8n webhook form?

Add a hidden honeypot field and an If node that drops any lead where it's filled. CORS settings won't help, since bots ignore them, and the IP(s) Allowlist option only suits server-to-server calls, since visitors' IPs vary. For more than that, put a form backend with spam filtering and rate limits in front of n8n.

### Does each form lead count as an n8n execution?

Yes, each request that starts the workflow is one execution. On n8n Cloud, Starter includes 2,500 a month and Pro 10,000 (billed annually, checked October 2026); the self-hosted Community Edition is free.

This page was made by [SuperDuperSEO](https://superduperseo.com/published-by)
